lib/gibberish/aes.rb in gibberish-1.4.0 vs lib/gibberish/aes.rb in gibberish-2.0.0

- old
+ new

@@ -1,33 +1,211 @@ +require 'json' +require 'delegate' +require 'securerandom' + module Gibberish - # Handles AES encryption and decryption in a way that is compatible - # with OpenSSL. + # # 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 # - # Defaults to 256-bit CBC encryption, ideally you should leave it - # this way + # ## 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") - # #=> "U2FsdGVkX1/D7z2azGmmQELbMNJV/n9T/9j2iBPy2AM=\n" - # cipher.encrypt_file("secret.txt", "secret.txt.enc") + # #=> Outputs a JSON string containing all the necessary information # # ### Decrypting # # cipher = Gibberish::AES.new('p4ssw0rd') - # cipher.decrypt(""U2FsdGVkX1/D7z2azGmmQELbMNJV/n9T/9j2iBPy2AM=\n"") + # 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" - # cipher.decrypt_file("secret.txt.enc", "secret.txt") # - # ## OpenSSL Interop + # #### 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 - # openssl aes-256-cbc -d -in secret.txt.enc -out secret.txt -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 + raise "Unable to parse JSON of crypted text" + 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.message}" + 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(); + 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