require 'json'
require 'delegate'
require 'securerandom'

module Gibberish
  # # Handles AES encryption and decryption with some sensible defaults
  #   - 256 bit AES encryption
  #   - GCM mode with Authentication
  #   - 100,000 iterations of PBKDF2_HMAC for key strengthening
  #
  # ## Compatibility with SJCL
  #   It outputs into a format that is compatible with SJCL and easy to
  #   consume in browsers/Node.js
  #
  # ## Basic Usage
  #
  # ### Encrypting
  #
  #     cipher = Gibberish::AES.new('p4ssw0rd')
  #     cipher.encrypt("some secret text")
  #     #=> Outputs a JSON string containing all the necessary information
  #
  # ### Decrypting
  #
  #     cipher = Gibberish::AES.new('p4ssw0rd')
  #     cipher.decrypt('{"iv":"I4XKgNfMNkYhvzXc","v":1,"iter":1000,"ks":128,"ts":64,"mode":"gcm","adata":"123abc","cipher":"aes","salt":"PJsit8L16Ug=","ct":"5sEBsHXQqLXLOjxuVQK7fGZVdrMyRGDJ"}')
  #     #=> "some secret text"
  #
  # #### Including Authenticated data.
  #
  # GCM mode allows you to include "Authenticated Data" with the ciphertext, if you wish.
  # For an overview of Authenticated Data, see this post: [http://crypto.stackexchange.com/a/15701](http://crypto.stackexchange.com/a/15701)
  #
  # Using AD is easy with Gibberish
  #
  #     cipher = Gibberish::AES.new('p4ssw0rd')
  #     ciphertext = cipher.encrypt("Some secret data", "my authenticated data")
  #     plaintext = cipher.decrypt(ciphertext)
  #     #=> "some secret text"
  #     plaintext.adata
  #     # => "my authenticated data"
  #
  # ## Interoperability with SJCL's GCM mode AES
  #
  # #### Decrypting
  #
  # ```javascript
  # // In the browser
  # var cleartext = sjcl.decrypt('key', '[output from Gibberish AES]');
  # ```
  #
  # #### Encrypting
  #
  # Ruby OpenSSL cannot handle an IV longer than 12 bytes, therefore we need to tell SJCL to
  # only use a 3 word IV value. See: https://github.com/bitwiseshiftleft/sjcl/issues/180
  #
  # ```javascript
  # // In the browser
  # var ciphertext = sjcl.encrypt('key', 'plain text', {mode: 'gcm', iv: sjcl.random.randomWords(3, 0)});
  # ```
  #
  # ## Backward compatibility with older pre 2.0 Gibberish
  #
  #  Gibberish was previously designed to be compatible with OpenSSL on the command line with CBC mode AES.
  #  This has been deprecated in favor of GCM mode. However, you may still
  #  decrypt and encrypt using legacy convenience methods below:
  #
  #  (Note: OpenSSL "enc" uses a non-standard file format which lacks [key stretching](http://en.wikipedia.org/wiki/Key_stretching), this means less secure passwords are more susceptible to brute forcing.)
  #
  # ### AES-256-CBC mode
  #
  #     cipher = Gibberish::AES::CBC.new('p4ssw0rd')
  #     cipher_text = cipher.encrypt("some secret text")
  #     # => U2FsdGVkX1/D7z2azGmmQELbMNJV/n9T/9j2iBPy2AM=
  #
  #     cipher.decrypt(cipher_text)
  #
  #     # From the command line
  #     echo "U2FsdGVkX1/D7z2azGmmQELbMNJV/n9T/9j2iBPy2AM=\n" | openssl enc -d -aes-256-cbc -a -k p4ssw0rd
  #
  class AES
    # Returns the AES object
    #
    # @param [String] password
    # @param [Hash] opts
    # @option opts [Symbol] :mode ('gcm') the AES mode to use
    # @option opts [Symbol] :ks (256) keystrength
    # @option opts [Symbol] :iter (100_000) number of PBKDF2 iterations to run on the password
    # @option opts [Symbol] :max_iter (100_000) maximum allow iterations, set to prevent DOS attack of someone setting a large 'iter' value in the ciphertext JSON
    # @option opts [Symbol] :ts (64) length of the authentication data hash
    def initialize(password, opts={})
      @cipher = SJCL.new(password, opts)
    end

    # Returns the ciphertext in the form of a JSON string
    #
    # @param [String] data
    # @param [String] authenticated_data (Won't be encrypted)
    def encrypt(data, authenticated_data='')
      @cipher.encrypt(data, authenticated_data)
    end

    # Returns a Plaintext object (essentially a String with an additional 'adata' attribute)
    #
    # @param [String] ciphertext
    def decrypt(ciphertext)
      @cipher.decrypt(ciphertext)
    end

  end

  class AES::SJCL
    class CipherOptionsError < ArgumentError; end
    class DecryptionError < StandardError; end
    class Plaintext < SimpleDelegator
      attr_reader :adata
      def initialize(str, adata)
        @adata = adata;
        super(str)
      end
    end

    MAX_ITER = 100_000
    ALLOWED_MODES = ['ccm', 'gcm']
    ALLOWED_KS = [128, 192, 256]
    ALLOWED_TS = [64, 96, 128]
    DEFAULTS = {
      v:1, iter:100_000, ks:256, ts:96,
      mode:"gcm", adata:"", cipher:"aes", max_iter: MAX_ITER
    }
    def initialize(password, opts={})
      @password = password
      @opts = DEFAULTS.merge(opts)
      check_cipher_options(@opts)
    end

    def encrypt(plaintext, adata='')
      salt = SecureRandom.random_bytes(8)
      iv = SecureRandom.random_bytes(12)
      key = OpenSSL::PKCS5.pbkdf2_hmac(@password, salt, @opts[:iter], @opts[:ks]/8, 'SHA256')
      cipherMode = "#{@opts[:cipher]}-#{@opts[:ks]}-#{@opts[:mode]}"
      c = OpenSSL::Cipher.new(cipherMode)
      c.encrypt
      c.key = key
      c.iv = iv
      c.auth_data = adata
      ct = c.update(plaintext) + c.final
      tag = c.auth_tag(@opts[:ts]/8);
      ct = ct + tag
      out = {
        v: @opts[:v], adata: adata, ks: @opts[:ks], ct: Base64.strict_encode64(ct).encode('utf-8'), ts: tag.length * 8,
        mode: @opts[:mode], cipher: 'aes', iter: @opts[:iter], iv:  Base64.strict_encode64(iv),
        salt: Base64.strict_encode64(salt)
      }
      out.to_json
    end

    def decrypt(h)
      begin
        h = JSON.parse(h, {:symbolize_names => true})
      rescue => e
        raise "Unable to parse JSON of crypted text. #{e.inspect}"
      end
      check_cipher_options(h)
      key = OpenSSL::PKCS5.pbkdf2_hmac(@password, Base64.decode64(h[:salt]), h[:iter], h[:ks]/8, 'SHA256')
      iv = Base64.decode64(h[:iv])
      ct = Base64.decode64(h[:ct])
      tag = ct[ct.length-h[:ts]/8,ct.length]
      ct = ct[0,ct.length-h[:ts]/8]
      cipherMode = "#{h[:cipher]}-#{h[:ks]}-#{h[:mode]}"
      begin
        c = OpenSSL::Cipher.new(cipherMode)
      rescue RuntimeError => e
        raise "OpenSSL error when initializing: #{e.inspect}"
      end
      c.decrypt
      c.key = key
      c.iv = iv
      c.auth_tag = tag;
      c.auth_data = h[:adata] || ""
      begin
        out = c.update(ct) + c.final();
      rescue OpenSSL::Cipher::CipherError => e
        raise DecryptionError.new(e.inspect);
      end
      return Plaintext.new(out.force_encoding('utf-8'), h[:adata])
    end

    # Assume the worst
    def check_cipher_options(c_opts)
      if @opts[:max_iter] < c_opts[:iter]
        # Prevent DOS attacks from high PBKDF iterations
        # You an increase this by passing in opts[:max_iter]
        raise CipherOptionsError.new("Iteration count of #{c_opts[:iter]} exceeds the maximum of #{@opts[:max_iter]}")
      elsif !ALLOWED_MODES.include?(c_opts[:mode])
        raise CipherOptionsError.new("Mode '#{c_opts[:mode]}' not supported")
      elsif !ALLOWED_KS.include?(c_opts[:ks])
        raise CipherOptionsError.new("Keystrength of #{c_opts[:ks]} not supported")
      elsif !ALLOWED_TS.include?(c_opts[:ts])
        raise CipherOptionsError.new("Tag length of #{c_opts[:ts]} not supported")
      elsif c_opts[:iv] && Base64.decode64(c_opts[:iv]).length > 12
        raise CipherOptionsError.new("Initialization vector's greater than 12 bytes are not supported in Ruby.")
      end
    end
  end

  class AES::CBC

    BUFFER_SIZE = 4096

    attr_reader :password, :size, :cipher

    # Initialize with the password
    #
    # @param [String] password
    # @param [Integer] size
    # @param [String] mode
    def initialize(password, size=256, mode="cbc")
      @password = password
      @size = size
      @mode = mode
      @cipher = OpenSSL::Cipher.new("aes-#{size}-#{mode}")
    end

    def encrypt(data, opts={})
      salt = generate_salt(opts[:salt])
      setup_cipher(:encrypt, salt)
      e = cipher.update(data) + cipher.final
      e = "Salted__#{salt}#{e}" #OpenSSL compatible
      opts[:binary] ? e : Base64.encode64(e)
    end
    alias :enc :encrypt
    alias :e :encrypt

    def decrypt(data, opts={})
      raise ArgumentError, 'Data is too short' unless data.length >= 16
      data = Base64.decode64(data) unless opts[:binary]
      salt = data[8..15]
      data = data[16..-1]
      setup_cipher(:decrypt, salt)
      cipher.update(data) + cipher.final
    end
    alias :dec :decrypt
    alias :d :decrypt

    def encrypt_file(from_file, to_file, opts={})
      salt = generate_salt(opts[:salt])
      setup_cipher(:encrypt, salt)
      buf = ""
      File.open(to_file, "wb") do |outf|
        outf << "Salted__#{salt}"
        File.open(from_file, "rb") do |inf|
          while inf.read(4096, buf)
            outf << self.cipher.update(buf)
          end
          outf << self.cipher.final
        end
      end
    end
    alias :enc_file :encrypt_file
    alias :ef :encrypt_file

    def decrypt_file(from_file, to_file)
      buf = ""
      salt = ""
      File.open(to_file, "wb") do |outf|
        File.open(from_file, "rb") do |inf|
          inf.seek(8, IO::SEEK_SET)
          inf.read(8, salt)
          setup_cipher(:decrypt, salt)
          while inf.read(4096, buf)
            outf << self.cipher.update(buf)
          end
          outf << self.cipher.final
        end
      end
    end
    alias :dec_file :decrypt_file
    alias :df :decrypt_file

    def encrypt_stream(in_stream, out_stream, opts={})
      salt = generate_salt(opts[:salt])
      setup_cipher(:encrypt, salt)
      out_stream << "Salted__#{salt}"
      copy_stream in_stream, out_stream
    end

    def decrypt_stream(in_stream, out_stream)
      header = in_stream.read(16)
      salt = header[8..15]
      setup_cipher(:decrypt, salt)
      copy_stream in_stream, out_stream
    end

    private

    def generate_salt(supplied_salt)
      if supplied_salt
        return supplied_salt.to_s[0,8].ljust(8,'.')
      end
      s = ''
      8.times {s << rand(255).chr}
      s
    end

    def setup_cipher(method, salt)
      cipher.send(method)
      cipher.pkcs5_keyivgen(password, salt, 1)
    end

    def copy_stream(in_stream, out_stream)
      buf = ''
      while in_stream.read(BUFFER_SIZE, buf)
        out_stream << cipher.update(buf)
      end
      out_stream << cipher.final
      out_stream.flush
    end

  end
end
