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

- old
+ new

@@ -5,31 +5,42 @@ # # Cipher is thread safe so that the same instance can be called by multiple # threads at the same time without needing an instance of Cipher per thread class Cipher # Cipher to use for encryption and decryption - attr_reader :cipher_name, :version - attr_accessor :encoding + attr_reader :cipher_name, :version, :iv + attr_accessor :encoding, :always_add_header # Available encodings ENCODINGS = [:none, :base64, :base64strict, :base16] # Backward compatibility alias_method :cipher, :cipher_name + # Defines the Header Structure returned when parsing the header + HeaderStruct = Struct.new( + :compressed, # [true|false] Whether the data is compressed, if supplied in the header + :binary, # [true|false] Whether the data is binary, if supplied in the header + :iv, # [String] IV used to encrypt the data, if supplied in the header + :key, # [String] Key used to encrypt the data, if supplied in the header + :cipher_name, # [String] Name of the cipher used, if supplied in the header + :version, # [Integer] Version of the cipher used, if supplied in the header + :decryption_cipher, # [SymmetricEncryption::Cipher] Cipher matching the header, or SymmetricEncryption.cipher(default_version) + ) + # Generate a new Symmetric Key pair # # Returns a hash containing a new random symmetric_key pair # consisting of a :key and :iv. # The cipher_name is also included for compatibility with the Cipher initializer - def self.random_key_pair(cipher_name = 'aes-256-cbc', generate_iv = true) + def self.random_key_pair(cipher_name = 'aes-256-cbc') openssl_cipher = ::OpenSSL::Cipher.new(cipher_name) openssl_cipher.encrypt { :key => openssl_cipher.random_key, - :iv => generate_iv ? openssl_cipher.random_iv : nil, + :iv => openssl_cipher.random_iv, :cipher_name => cipher_name } end # Create a Symmetric::Key for encryption and decryption purposes @@ -63,22 +74,38 @@ # # :version [Fixnum] # Optional. The version number of this encryption key # Used by SymmetricEncryption to select the correct key when decrypting data # Maximum value: 255 - def initialize(parms={}) - raise "Missing mandatory parameter :key" unless @key = parms[:key] - @iv = parms[:iv] - @cipher_name = parms[:cipher_name] || parms[:cipher] || 'aes-256-cbc' - @version = parms[:version] - raise "Cipher version has a maximum of 255. #{@version} is too high" if @version.to_i > 255 - @encoding = (parms[:encoding] || :base64).to_sym + # + # :always_add_header [true|false] + # 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 a few bytes, but makes + # migration to a new key trivial + # Default: false + # Recommended: true + # + def initialize(params={}) + parms = params.dup + @key = parms.delete(:key) + @iv = parms.delete(:iv) + @cipher_name = parms.delete(:cipher_name) || parms.delete(:cipher) || 'aes-256-cbc' + @version = parms.delete(:version) + @always_add_header = parms.delete(:always_add_header) || false + @encoding = (parms.delete(:encoding) || :base64).to_sym - raise("Invalid Encoding: #{@encoding}") unless ENCODINGS.include?(@encoding) + raise "Missing mandatory parameter :key" unless @key + raise "Invalid Encoding: #{@encoding}" unless ENCODINGS.include?(@encoding) + raise "Cipher version has a valid rage of 0 to 255. #{@version} is too high, or negative" if (@version.to_i > 255) || (@version.to_i < 0) + parms.each_pair {|k,v| warn "SymmetricEncryption::Cipher Ignoring unknown option #{k.inspect} = #{v.inspect}"} end - # Returns encrypted and then encoded string + # Encrypt and then encode a binary or UTF-8 string + # + # Returns data encrypted and then encoded according to the encoding setting + # of this cipher # Returns nil if str is nil # Returns "" str is empty # # Parameters # @@ -103,11 +130,11 @@ # Highly Recommended where feasible: true # # compress [true|false] # Whether to compress str before encryption # Should only be used for large strings since compression overhead and - # the overhead of adding the 'magic' header may exceed any benefits of + # the overhead of adding the encryption header may exceed any benefits of # compression # Note: Adds a 6 byte header prior to encoding, only if :random_iv is false # Default: false def encrypt(str, random_iv=false, compress=false) return if str.nil? @@ -115,118 +142,132 @@ return str if str.empty? encrypted = binary_encrypt(str, random_iv, compress) self.encode(encrypted) end - # Decryption of supplied string + # Decode and Decrypt string + # Returns a decrypted string after decoding it first according to the + # encoding setting of this cipher + # Returns nil if encrypted_string is nil + # Returns '' if encrypted_string == '' # - # Decodes string first if decode is true + # Parameters + # encrypted_string [String] + # Binary encrypted string to decrypt # - # Returns a UTF-8 encoded, decrypted string - # Returns nil if the supplied str is nil - # Returns "" if it is a string and it is empty - if defined?(Encoding) - def decrypt(str) - decoded = self.decode(str) - return unless decoded + # header [HeaderStruct] + # Optional header for the supplied encrypted_string + # + # binary [true|false] + # If no header is supplied then determines whether the string returned + # is binary or UTF8 + # + # Reads the 'magic' header if present for key, iv, cipher_name and compression + # + # encrypted_string must be in raw binary form when calling this method + # + # Creates a new OpenSSL::Cipher with every call so that this call + # is thread-safe and can be called concurrently by multiple threads with + # the same instance of Cipher + def decrypt(str) + decoded = self.decode(str) + return unless decoded - return decoded if decoded.empty? - binary_decrypt(decoded).force_encoding(SymmetricEncryption::UTF8_ENCODING) - end - else - def decrypt(str) - decoded = self.decode(str) - return unless decoded - - return decoded if decoded.empty? - crypt(:decrypt, decoded) - end + return decoded if decoded.empty? + binary_decrypt(decoded).force_encoding(SymmetricEncryption::UTF8_ENCODING) end - # Return a new random key using the configured cipher_name - # Useful for generating new symmetric keys - def random_key - ::OpenSSL::Cipher::Cipher.new(@cipher_name).random_key - end - - # Returns the block size for the configured cipher_name - def block_size - ::OpenSSL::Cipher::Cipher.new(@cipher_name).block_size - end - # Returns UTF8 encoded string after encoding the supplied Binary string # # Encode the supplied string using the encoding in this cipher instance # Returns nil if the supplied string is nil # Note: No encryption or decryption is performed # # Returned string is UTF8 encoded except for encoding :none def encode(binary_string) - return unless binary_string + return binary_string if binary_string.nil? || (binary_string == '') # Now encode data based on encoding setting case encoding when :base64 - ::Base64.encode64(binary_string).force_encoding(SymmetricEncryption::UTF8_ENCODING) + encoded_string = ::Base64.encode64(binary_string) + # Support Ruby 1.9 encoding + defined?(Encoding) ? encoded_string.force_encoding(SymmetricEncryption::UTF8_ENCODING) : encoded_string when :base64strict - ::Base64.encode64(binary_string).gsub(/\n/, '').force_encoding(SymmetricEncryption::UTF8_ENCODING) + encoded_string = ::Base64.encode64(binary_string).gsub(/\n/, '') + # Support Ruby 1.9 encoding + defined?(Encoding) ? encoded_string.force_encoding(SymmetricEncryption::UTF8_ENCODING) : encoded_string when :base16 - binary_string.to_s.unpack('H*').first.force_encoding(SymmetricEncryption::UTF8_ENCODING) + encoded_string = binary_string.to_s.unpack('H*').first + # Support Ruby 1.9 encoding + defined?(Encoding) ? encoded_string.force_encoding(SymmetricEncryption::UTF8_ENCODING) : encoded_string else binary_string end end # Decode the supplied string using the encoding in this cipher instance # Note: No encryption or decryption is performed # # Returned string is Binary encoded def decode(encoded_string) - return unless encoded_string + return encoded_string if encoded_string.nil? || (encoded_string == '') case encoding when :base64, :base64strict - ::Base64.decode64(encoded_string).force_encoding(SymmetricEncryption::BINARY_ENCODING) + decoded_string = ::Base64.decode64(encoded_string) + # Support Ruby 1.9 encoding + defined?(Encoding) ? decoded_string.force_encoding(SymmetricEncryption::BINARY_ENCODING) : decoded_string when :base16 - [encoded_string].pack('H*').force_encoding(SymmetricEncryption::BINARY_ENCODING) + decoded_string = [encoded_string].pack('H*') + # Support Ruby 1.9 encoding + defined?(Encoding) ? decoded_string.force_encoding(SymmetricEncryption::BINARY_ENCODING) : decoded_string else encoded_string end end - # Returns an Array of the following values extracted from header or nil - # if any value was not specified in the header - # compressed [true|false] - # iv [String] - # key [String] - # cipher_name [String} - # decryption_cipher [SymmetricEncryption::Cipher] + # Return a new random key using the configured cipher_name + # Useful for generating new symmetric keys + def random_key + ::OpenSSL::Cipher::Cipher.new(@cipher_name).random_key + end + + # Returns the block size for the configured cipher_name + def block_size + ::OpenSSL::Cipher::Cipher.new(@cipher_name).block_size + end + + # Returns whether the supplied buffer starts with a symmetric_encryption header + # Note: The encoding of the supplied buffer is forced to binary if not already binary + def self.has_header?(buffer) + return false if buffer.nil? || (buffer == '') + buffer.force_encoding(SymmetricEncryption::BINARY_ENCODING) if buffer.respond_to?(:force_encoding) + buffer.start_with?(MAGIC_HEADER) + end + + # Returns HeaderStruct of the header parsed from the supplied string + # Returns nil if no header is present # - # The supplied buffer will be updated directly and will have the header - # portion removed + # The supplied buffer will be updated directly and its header will be + # stripped if present # # Parameters # buffer - # String to extract the header from if present + # String to extract the header from # - # default_version - # If no header is present, this is the default value for the version - # of the cipher to use - # - # default_compressed - # If no header is present, this is the default value for the compression - def self.parse_magic_header!(buffer, default_version=nil, default_compressed=false) - buffer.force_encoding(SymmetricEncryption::BINARY_ENCODING) if buffer - return [default_compressed, nil, nil, nil, nil, SymmetricEncryption.cipher(default_version)] unless buffer && buffer.start_with?(MAGIC_HEADER) + def self.parse_header!(buffer) + return unless has_header?(buffer) # Header includes magic header and version byte # Remove header and extract flags _, flags = buffer.slice!(0..MAGIC_HEADER_SIZE+1).unpack(MAGIC_HEADER_UNPACK) compressed = (flags & 0b1000_0000_0000_0000) != 0 include_iv = (flags & 0b0100_0000_0000_0000) != 0 include_key = (flags & 0b0010_0000_0000_0000) != 0 include_cipher= (flags & 0b0001_0000_0000_0000) != 0 + binary = (flags & 0b0000_1000_0000_0000) != 0 # Version of the key to use to decrypt the key if present, # otherwise to decrypt the data following the header version = flags & 0b0000_0000_1111_1111 decryption_cipher = SymmetricEncryption.cipher(version) raise "Cipher with version:#{version.inspect} not found in any of the configured SymmetricEncryption ciphers" unless decryption_cipher @@ -236,18 +277,18 @@ len = buffer.slice!(0..1).unpack('v').first iv = buffer.slice!(0..len-1) end if include_key len = buffer.slice!(0..1).unpack('v').first - key = decryption_cipher.binary_decrypt(buffer.slice!(0..len-1)) + key = decryption_cipher.binary_decrypt(buffer.slice!(0..len-1), header=false, binary=true) end if include_cipher len = buffer.slice!(0..1).unpack('v').first cipher_name = buffer.slice!(0..len-1) end - [compressed, iv, key, cipher_name, version, decryption_cipher] + HeaderStruct.new(compressed, binary, iv, key, cipher_name, version, decryption_cipher) end # Returns a magic header for this cipher instance that can be placed at # the beginning of a file or stream to indicate how the data was encrypted # @@ -267,34 +308,34 @@ # # cipher_name # Includes the cipher_name used. For example 'aes-256-cbc' # The cipher_name string to to put in the header # Default: nil : Exclude cipher_name name from header - def self.magic_header(version, compressed=false, iv=nil, key=nil, cipher_name=nil) + # + # binary + # Whether the data being encrypted is binary. + # When the header is read, it sets the encoding of the string returned to Binary + def self.build_header(version, compressed=false, iv=nil, key=nil, cipher_name=nil, binary=false) # Ruby V2 named parameters would be perfect here - # Encryption version indicator if available - flags = version || 0 # Same as 0b0000_0000_0000_0000 + # Version number of supplied encryption key, or use the global cipher version if none was supplied + flags = iv || key ? (SymmetricEncryption.cipher.version || 0) : (version || 0) # Same as 0b0000_0000_0000_0000 - # Replace version with global cipher that will be used to encrypt the random key - if iv || key - flags = (SymmetricEncryption.cipher.version || 0) - end - # If the data is to be compressed before being encrypted, set the # compressed bit in the flags word flags |= 0b1000_0000_0000_0000 if compressed flags |= 0b0100_0000_0000_0000 if iv flags |= 0b0010_0000_0000_0000 if key flags |= 0b0001_0000_0000_0000 if cipher_name + flags |= 0b0000_1000_0000_0000 if binary header = "#{MAGIC_HEADER}#{[flags].pack('v')}".force_encoding(SymmetricEncryption::BINARY_ENCODING) if iv header << [iv.length].pack('v') header << iv end if key - encrypted = SymmetricEncryption.cipher.binary_encrypt(key, false, false) + encrypted = SymmetricEncryption.cipher.binary_encrypt(key, false, false, false) header << [encrypted.length].pack('v').force_encoding(SymmetricEncryption::BINARY_ENCODING) header << encrypted end if cipher_name header << [cipher_name.length].pack('v') @@ -305,74 +346,119 @@ # Advanced use only # # Returns a Binary encrypted string without applying any Base64, or other encoding # - # Adds the 'magic' header if a random_iv is required or compression is enabled + # add_header [nil|true|false] + # Whether to add a header to the encrypted string + # If not supplied it defaults to true if always_add_header || random_iv || compress + # Default: nil # # Creates a new OpenSSL::Cipher with every call so that this call # is thread-safe # # See #encrypt to encrypt and encode the result as a string - def binary_encrypt(string, random_iv=false, compress=false) + def binary_encrypt(str, random_iv=false, compress=false, add_header=nil) + return if str.nil? + string = str.to_s + return string if string.empty? + + # Creates a new OpenSSL::Cipher with every call so that this call + # is thread-safe openssl_cipher = ::OpenSSL::Cipher.new(self.cipher_name) openssl_cipher.encrypt openssl_cipher.key = @key - result = if random_iv || compress + add_header = always_add_header || random_iv || compress if add_header.nil? + result = if add_header # Random iv and compress both add the magic header iv = random_iv ? openssl_cipher.random_iv : @iv openssl_cipher.iv = iv if iv - self.class.magic_header(version, compress, random_iv ? iv : nil) + + # Set the binary indicator on the header if string is Binary Encoded + binary = (string.encoding == SymmetricEncryption::BINARY_ENCODING) + self.class.build_header(version, compress, random_iv ? iv : nil, binary) + openssl_cipher.update(compress ? Zlib::Deflate.deflate(string) : string) else openssl_cipher.iv = @iv if @iv openssl_cipher.update(string) end result << openssl_cipher.final end # Advanced use only + # See #decrypt to decrypt encoded strings # # Returns a Binary decrypted string without decoding the string first # + # Decryption of supplied string + # Returns the decrypted string + # Returns nil if encrypted_string is nil + # Returns '' if encrypted_string == '' + # + # Parameters + # encrypted_string [String] + # Binary encrypted string to decrypt + # + # header [HeaderStruct] + # Optional header for the supplied encrypted_string + # + # binary [true|false] + # If no header is supplied then determines whether the string returned + # is binary or UTF8 + # # Reads the 'magic' header if present for key, iv, cipher_name and compression # # encrypted_string must be in raw binary form when calling this method # # Creates a new OpenSSL::Cipher with every call so that this call - # is thread-safe + # is thread-safe and can be called concurrently by multiple threads with + # the same instance of Cipher # - # See #decrypt to decrypt encoded strings - def binary_decrypt(encrypted_string) + # Note: + # When a string is encrypted and the header is used, its decrypted form + # is automatically set to the same UTF-8 or Binary encoding + def binary_decrypt(encrypted_string, header=nil, binary=false) + return if encrypted_string.nil? str = encrypted_string.to_s - if str.start_with?(MAGIC_HEADER) + str.force_encoding(SymmetricEncryption::BINARY_ENCODING) if str.respond_to?(:force_encoding) + return str if str.empty? + + decrypted_string = if header || self.class.has_header?(str) str = str.dup - compressed, iv, key, cipher_name = self.class.parse_magic_header!(str) - openssl_cipher = ::OpenSSL::Cipher.new(cipher_name || self.cipher_name) + header ||= self.class.parse_header!(str) + binary = header.binary + + openssl_cipher = ::OpenSSL::Cipher.new(header.cipher_name || self.cipher_name) openssl_cipher.decrypt - openssl_cipher.key = key || @key - iv ||= @iv + openssl_cipher.key = header.key || @key + iv = header.iv || @iv openssl_cipher.iv = iv if iv result = openssl_cipher.update(str) result << openssl_cipher.final - compressed ? Zlib::Inflate.inflate(result) : result + header.compressed ? Zlib::Inflate.inflate(result) : result else openssl_cipher = ::OpenSSL::Cipher.new(self.cipher_name) openssl_cipher.decrypt openssl_cipher.key = @key openssl_cipher.iv = @iv if @iv - result = openssl_cipher.update(encrypted_string) + result = openssl_cipher.update(str) result << openssl_cipher.final end + + # Support Ruby 1.9 and above Encoding + if defined?(Encoding) + # Sets the encoding of the result string to UTF8 or BINARY based on the binary header + binary ? decrypted_string.force_encoding(SymmetricEncryption::BINARY_ENCODING) : decrypted_string.force_encoding(SymmetricEncryption::UTF8_ENCODING) + else + decrypted_string + end end - # Returns [String] object represented as a string - # Excluding the key and iv + # Returns [String] object represented as a string, filtering out the key def inspect - "#<#{self.class}:0x#{self.__id__.to_s(16)} @cipher_name=#{cipher_name.inspect}, @version=#{version.inspect}, @encoding=#{encoding.inspect}" + "#<#{self.class}:0x#{self.__id__.to_s(16)} @key=\"[FILTERED]\" @iv=#{iv.inspect} @cipher_name=#{cipher_name.inspect}, @version=#{version.inspect}, @encoding=#{encoding.inspect}" end private - attr_reader :key, :iv + attr_reader :key end -end \ No newline at end of file +end