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