lib/symmetric_encryption/symmetric_encryption.rb in symmetric-encryption-2.2.0 vs lib/symmetric_encryption/symmetric_encryption.rb in symmetric-encryption-3.0.0

- old
+ new

@@ -1,18 +1,20 @@ require 'base64' require 'openssl' require 'zlib' require 'yaml' +require 'erb' # Encrypt using 256 Bit AES CBC symmetric key and initialization vector # The symmetric key is protected using the private key below and must # be distributed separately from the application module SymmetricEncryption # Defaults @@cipher = nil @@secondary_ciphers = [] + @@select_cipher = nil # Set the Primary Symmetric Cipher to be used # # Example: For testing purposes the following test cipher can be used: # @@ -53,33 +55,49 @@ # AES Symmetric Decryption of supplied string # Returns decrypted string # Returns nil if the supplied str is nil # Returns "" if it is a string and it is empty # - # Note: If secondary ciphers are supplied in the configuration file the - # first key will be used to decrypt 'str'. If it fails each cipher in the - # order supplied will be tried. - # It is slow to try each cipher in turn, so should be used during migrations - # only + # Parameters + # str + # Encrypted string to decrypt + # version + # Specify which cipher version to use if no header is present on the + # encrypted string # + # If the supplied string has an encryption header then the cipher matching + # the version number in the header will be used to decrypt the string + # + # When no header is present in the encrypted data, a custom Block/Proc can + # be supplied to determine which cipher to use to decrypt the data. + # see #cipher_selector= + # # Raises: OpenSSL::Cipher::CipherError when 'str' was not encrypted using - # the supplied key and iv + # the primary key and iv # - def self.decrypt(str) + # NOTE: #decrypt will _not_ attempt to use a secondary cipher if it fails + # to decrypt the current string. This is because in a very small + # yet significant number of cases it is possible to decrypt data using + # the incorrect key. Clearly the data returned is garbage, but it still + # successfully returns a string of data + def self.decrypt(encrypted_and_encoded_string, version=nil) raise "Call SymmetricEncryption.load! or SymmetricEncryption.cipher= prior to encrypting or decrypting data" unless @@cipher + return if encrypted_and_encoded_string.nil? || (encrypted_and_encoded_string == '') - # Decode and then decrypt supplied string - begin - @@cipher.decrypt(str) - rescue OpenSSL::Cipher::CipherError => exc - @@secondary_ciphers.each do |cipher| - begin - return cipher.decrypt(str) - rescue OpenSSL::Cipher::CipherError - end - end - raise exc + str = encrypted_and_encoded_string.to_s + + # Decode before decrypting supplied string + decoded = @@cipher.decode(str) + return unless decoded + return decoded if decoded.empty? + + if header = Cipher.parse_header!(decoded) + header.decryption_cipher.binary_decrypt(decoded, header) + else + # Use cipher_selector if present to decide which cipher to use + c = @@select_cipher.nil? ? cipher(version) : @@select_cipher.call(str, decoded) + c.binary_decrypt(decoded) end end # AES Symmetric Encryption of supplied string # Returns result as a Base64 encoded string @@ -127,10 +145,13 @@ # # Useful for example when decoding passwords encrypted using a key from a # different environment. I.e. We cannot decode production passwords # in the test or development environments but still need to be able to load # YAML config files that contain encrypted development and production passwords + # + # WARNING: It is possible to decrypt data using the wrong key, so the value + # returned should not be relied upon def self.try_decrypt(str) raise "Call SymmetricEncryption.load! or SymmetricEncryption.cipher= prior to encrypting or decrypting data" unless @@cipher begin decrypt(str) rescue OpenSSL::Cipher::CipherError @@ -139,40 +160,63 @@ end # Returns [true|false] as to whether the data could be decrypted # Parameters: # encrypted_data: Encrypted string + # + # WARNING: This method can only be relied upon if the encrypted data includes the + # symmetric encryption header. In some cases data decrypted using the + # wrong key will decrypt and return garbage def self.encrypted?(encrypted_data) raise "Call SymmetricEncryption.load! or SymmetricEncryption.cipher= prior to encrypting or decrypting data" unless @@cipher # For now have to decrypt it fully result = try_decrypt(encrypted_data) !(result.nil? || result == '') end + # When no header is present in the encrypted data, this custom Block/Proc is + # used to determine which cipher to use to decrypt the data. + # + # The Block must return a valid cipher + # + # Parameters + # encoded_str + # The original encoded string + # + # decoded_str + # The string after being decoded using the global encoding + # + # NOTE: Do _not_ attempt to use a secondary cipher if the previous fails + # to decrypt due to an OpenSSL::Cipher::CipherError exception. + # This is because in a very small, yet significant number of cases it is + # possible to decrypt data using the incorrect key. + # Clearly the data returned is garbage, but it still successfully + # returns a string of data + # + # Example: + # SymmetricEncryption.select_cipher do |encoded_str, decoded_str| + # # Use cipher version 0 if the encoded string ends with "\n" otherwise + # # use the current default cipher + # encoded_str.end_with?("\n") ? SymmetricEncryption.cipher(0) : SymmetricEncryption.cipher + # end + def self.select_cipher(&block) + @@select_cipher = block ? block : nil + end + # Load the Encryption Configuration from a YAML file # filename: # Name of file to read. # Mandatory for non-Rails apps # Default: Rails.root/config/symmetric-encryption.yml # environment: # Which environments config to load. Usually: production, development, etc. # Default: Rails.env def self.load!(filename=nil, environment=nil) - config = read_config(filename, environment) - - # Check for hard coded key, iv and cipher - if config[:key] - @@cipher = Cipher.new(config) - @@secondary_ciphers = [] - else - private_rsa_key = config[:private_rsa_key] - @@cipher, *@@secondary_ciphers = config[:ciphers].collect do |cipher_conf| - cipher_from_encrypted_files(private_rsa_key, cipher_conf) - end - end - + ciphers = read_config(filename, environment) + @@cipher = ciphers.shift + @@secondary_ciphers = ciphers true end # Generate new random symmetric keys for use with this Encryption library # @@ -182,36 +226,57 @@ # and initilization vector .iv # which is encrypted with the above Public key # # Existing key files will be renamed if present def self.generate_symmetric_key_files(filename=nil, environment=nil) - config = read_config(filename, environment) - cipher_cfg = config[:ciphers].first - key_filename = cipher_cfg[:key_filename] - iv_filename = cipher_cfg[:iv_filename] - cipher_name = cipher_cfg[:cipher_name] || cipher_cfg[:cipher] + config_filename = filename || File.join(Rails.root, "config", "symmetric-encryption.yml") + config = YAML.load(ERB.new(File.new(config_filename).read).result)[environment || Rails.env] - raise "The configuration file must contain a 'private_rsa_key' parameter to generate symmetric keys" unless config[:private_rsa_key] - rsa_key = OpenSSL::PKey::RSA.new(config[:private_rsa_key]) + # RSA key to decrypt key files + private_rsa_key = config.delete('private_rsa_key') + raise "The configuration file must contain a 'private_rsa_key' parameter to generate symmetric keys" unless private_rsa_key + rsa_key = OpenSSL::PKey::RSA.new(private_rsa_key) + # Check if config file contains 1 or multiple ciphers + ciphers = config.delete('ciphers') + cfg = ciphers.nil? ? config : ciphers.first + + # Convert keys to symbols + cipher_cfg = {} + cfg.each_pair{|k,v| cipher_cfg[k.to_sym] = v} + + cipher_name = cipher_cfg[:cipher_name] || cipher_cfg[:cipher] + # Generate a new Symmetric Key pair + iv_filename = cipher_cfg[:iv_filename] key_pair = SymmetricEncryption::Cipher.random_key_pair(cipher_name || 'aes-256-cbc', !iv_filename.nil?) - # Save symmetric key after encrypting it with the private RSA key, backing up existing files if present - File.rename(key_filename, "#{key_filename}.#{Time.now.to_i}") if File.exist?(key_filename) - File.open(key_filename, 'wb') {|file| file.write( rsa_key.public_encrypt(key_pair[:key]) ) } + if key_filename = cipher_cfg[:key_filename] + # Save symmetric key after encrypting it with the private RSA key, backing up existing files if present + File.rename(key_filename, "#{key_filename}.#{Time.now.to_i}") if File.exist?(key_filename) + File.open(key_filename, 'wb') {|file| file.write( rsa_key.public_encrypt(key_pair[:key]) ) } + puts("Generated new Symmetric Key for encryption. Please copy #{key_filename} to the other web servers in #{environment}.") + elsif !cipher_cfg[:key] + key = rsa_key.public_encrypt(key_pair[:key]) + puts "Generated new Symmetric Key for encryption. Set the KEY environment variable in #{environment} to:" + puts ::Base64.encode64(key) + end if iv_filename File.rename(iv_filename, "#{iv_filename}.#{Time.now.to_i}") if File.exist?(iv_filename) File.open(iv_filename, 'wb') {|file| file.write( rsa_key.public_encrypt(key_pair[:iv]) ) } + puts("Generated new Symmetric Key for encryption. Please copy #{iv_filename} to the other web servers in #{environment}.") + elsif !cipher_cfg[:iv] + iv = rsa_key.public_encrypt(key_pair[:iv]) + puts "Generated new Symmetric Key for encryption. Set the IV environment variable in #{environment} to:" + puts ::Base64.encode64(key) end - puts("Generated new Symmetric Key for encryption. Please copy #{key_filename} and #{iv_filename} to the other web servers in #{environment}.") end # Generate a 22 character random password def self.random_password - Base64.encode64(OpenSSL::Cipher.new('aes-128-cbc').random_key)[0..-4] + Base64.encode64(OpenSSL::Cipher.new('aes-128-cbc').random_key)[0..-4].strip end # Binary encrypted data includes this magic header so that we can quickly # identify binary data versus base64 encoded data that does not have this header unless defined? MAGIC_HEADER @@ -220,110 +285,132 @@ MAGIC_HEADER_UNPACK = "a#{MAGIC_HEADER_SIZE}v" end protected - # Returns the Encryption Configuration + # Returns [Array(SymmetricEncrytion::Cipher)] ciphers specified in the configuration file # # Read the configuration from the YAML file and return in the latest format # # filename: # Name of file to read. # Mandatory for non-Rails apps # Default: Rails.root/config/symmetric-encryption.yml # environment: # Which environments config to load. Usually: production, development, etc. def self.read_config(filename=nil, environment=nil) - config = YAML.load_file(filename || File.join(Rails.root, "config", "symmetric-encryption.yml"))[environment || Rails.env] + config_filename = filename || File.join(Rails.root, "config", "symmetric-encryption.yml") + config = YAML.load(ERB.new(File.new(config_filename).read).result)[environment || Rails.env] - # Default cipher - default_cipher = config['cipher_name'] || config['cipher'] || 'aes-256-cbc' - cfg = {} + # RSA key to decrypt key files + private_rsa_key = config.delete('private_rsa_key') - # Hard coded symmetric_key? - Dev / Testing use only! - if symmetric_key = (config['key'] || config['symmetric_key']) - raise "SymmetricEncryption Cannot hard code Production encryption keys in #{filename}" if (environment || Rails.env) == 'production' - cfg[:key] = symmetric_key - cfg[:iv] = config['iv'] || config['symmetric_iv'] - cfg[:cipher_name] = default_cipher - cfg[:version] = config['version'] - - elsif ciphers = config['ciphers'] - raise "Missing mandatory config parameter 'private_rsa_key'" unless cfg[:private_rsa_key] = config['private_rsa_key'] - - cfg[:ciphers] = ciphers.collect do |cipher_cfg| - key_filename = cipher_cfg['key_filename'] || cipher_cfg['symmetric_key_filename'] - raise "Missing mandatory 'key_filename' for environment:#{environment} in #{filename}" unless key_filename - iv_filename = cipher_cfg['iv_filename'] || cipher_cfg['symmetric_iv_filename'] - { - :cipher_name => cipher_cfg['cipher_name'] || cipher_cfg['cipher'] || default_cipher, - :key_filename => key_filename, - :iv_filename => iv_filename, - :encoding => cipher_cfg['encoding'], - :version => cipher_cfg['version'] - } - end - + if ciphers = config.delete('ciphers') + ciphers.collect {|cipher_conf| cipher_from_config(cipher_conf, private_rsa_key)} else - # Migrate old format config - raise "Missing mandatory config parameter 'private_rsa_key'" unless cfg[:private_rsa_key] = config['private_rsa_key'] - cfg[:ciphers] = [ { - :cipher_name => default_cipher, - :key_filename => config['symmetric_key_filename'], - :iv_filename => config['symmetric_iv_filename'], - } ] + [cipher_from_config(config, private_rsa_key)] end - - cfg end - # Returns an instance of SymmetricEncryption::Cipher initialized from keys - # stored in files + # Returns an instance of SymmetricEncryption::Cipher created from + # the supplied configuration and optional rsa_encryption_key # # Raises an Exception on failure # # Parameters: - # private_rsa_key - # Key used to unlock file containing the actual symmetric key # cipher_conf Hash: - # cipher_name + # :cipher_name # Encryption cipher name for the symmetric encryption key - # key_filename + # + # :version + # The version number of this cipher + # Default: 0 + # + # :encoding [Symbol] + # Encoding to use after encrypting with this cipher + # + # :always_add_header + # Whether to always include the header when encrypting data. + # Highly recommended to set this value to true. + # Increases the length of the encrypted data by 6 bytes, but makes + # migration to a new key trivial + # Default: false + # + # :key + # The actual key to use for encryption/decryption purposes + # + # :key_filename # Name of file containing symmetric key encrypted using the public - # key matching the supplied private_key - # iv_filename - # Optional. Name of file containing symmetric key initialization vector - # encrypted using the public key matching the supplied private_key - def self.cipher_from_encrypted_files(private_rsa_key, cipher_conf) + # key from the private_rsa_key + # + # :encrypted_key + # Symmetric key encrypted using the public key from the private_rsa_key + # + # :iv + # Optional: The actual iv to use for encryption/decryption purposes + # + # :encrypted_iv + # Initialization vector encrypted using the public key from the private_rsa_key + # + # :iv_filename + # Optional: Name of file containing symmetric key initialization vector + # encrypted using the public key from the private_rsa_key + # + # private_rsa_key [String] + # RSA Key used to decrypt key and iv as applicable + def self.cipher_from_config(cipher_conf, private_rsa_key=nil) + config = {} + cipher_conf.each_pair{|k,v| config[k.to_sym] = v} + + # To decrypt encrypted key or iv files + rsa = OpenSSL::PKey::RSA.new(private_rsa_key) if private_rsa_key + # Load Encrypted Symmetric keys - key_filename = cipher_conf[:key_filename] - encrypted_key = begin - File.read(key_filename, :open_args => ['rb']) - rescue Errno::ENOENT - puts "\nSymmetric Encryption key file: '#{key_filename}' not found or readable." - puts "To generate the keys for the first time run: rails generate symmetric_encryption:new_keys\n\n" - return + if key_filename = config.delete(:key_filename) + raise "Missing mandatory config parameter :private_rsa_key when :key_filename is supplied" unless rsa + encrypted_key = begin + File.read(key_filename, :open_args => ['rb']) + rescue Errno::ENOENT + puts "\nSymmetric Encryption key file: '#{key_filename}' not found or readable." + puts "To generate the keys for the first time run: rails generate symmetric_encryption:new_keys\n\n" + return + end + config[:key] = rsa.private_decrypt(encrypted_key) end - iv_filename = cipher_conf[:iv_filename] - encrypted_iv = begin - File.read(iv_filename, :open_args => ['rb']) if iv_filename - rescue Errno::ENOENT - puts "\nSymmetric Encryption initialization vector file: '#{iv_filename}' not found or readable." - puts "To generate the keys for the first time run: rails generate symmetric_encryption:new_keys\n\n" - return + if iv_filename = config.delete(:iv_filename) + raise "Missing mandatory config parameter :private_rsa_key when :iv_filename is supplied" unless rsa + encrypted_iv = begin + File.read(iv_filename, :open_args => ['rb']) if iv_filename + rescue Errno::ENOENT + puts "\nSymmetric Encryption initialization vector file: '#{iv_filename}' not found or readable." + puts "To generate the keys for the first time run: rails generate symmetric_encryption:new_keys\n\n" + return + end + config[:iv] = rsa.private_decrypt(encrypted_iv) end + if encrypted_key = config.delete(:encrypted_key) + raise "Missing mandatory config parameter :private_rsa_key when :encrypted_key is supplied" unless rsa + # Decode value first using encoding specified + encrypted_key = ::Base64.decode64(encrypted_key) + config[:key] = rsa.private_decrypt(encrypted_key) + end + + if encrypted_iv = config.delete(:encrypted_iv) + raise "Missing mandatory config parameter :private_rsa_key when :encrypted_iv is supplied" unless rsa + # Decode value first using encoding specified + encrypted_iv = ::Base64.decode64(encrypted_iv) + config[:iv] = rsa.private_decrypt(encrypted_iv) + end + + # Backward compatibility + if old_key_name_cipher = config.delete(:cipher) + config[:cipher_name] = old_key_name_cipher + end + # Decrypt Symmetric Keys - rsa = OpenSSL::PKey::RSA.new(private_rsa_key) - iv = rsa.private_decrypt(encrypted_iv) if iv_filename - Cipher.new( - :key => rsa.private_decrypt(encrypted_key), - :iv => iv, - :cipher_name => cipher_conf[:cipher_name], - :encoding => cipher_conf[:encoding], - :version => cipher_conf[:version] - ) + Cipher.new(config) end # With Ruby 1.9 strings have encodings if defined?(Encoding) BINARY_ENCODING = Encoding.find("binary") \ No newline at end of file