symmetric-encryption 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,17 +2,17 @@
2
2
  #
3
3
  # Example:
4
4
  # class MyModel < ActiveRecord::Base
5
- # validates :encrypted_ssn, :symmetric_encrypted => true
5
+ # validates :encrypted_ssn, :symmetric_encryption => true
6
6
  # end
7
7
  #
8
8
  # m = MyModel.new
9
9
  # m.valid?
10
10
  # # => false
11
- # m.encrypted_ssn = Symmetric::Encryption.encrypt('123456789')
11
+ # m.encrypted_ssn = SymmetricEncryption.encrypt('123456789')
12
12
  # m.valid?
13
13
  # # => true
14
- class SymmetricEncryptedValidator < ActiveModel::EachValidator
14
+ class SymmetricEncryptionValidator < ActiveModel::EachValidator
15
15
  def validate_each(record, attribute, value)
16
- record.errors.add(attribute, "must be a value encrypted using Symmetric::Encryption.encrypt") unless Symmetric::Encryption.encrypted?(value)
16
+ record.errors.add(attribute, "must be a value encrypted using SymmetricEncryption.encrypt") unless SymmetricEncryption.encrypted?(value)
17
17
  end
18
18
  end
@@ -0,0 +1,221 @@
1
+ module SymmetricEncryption
2
+ # Read from encrypted files and other IO streams
3
+ #
4
+ # Features:
5
+ # * Decryption on the fly whilst reading files
6
+ # * Large file support by only buffering small amounts of data in memory
7
+ #
8
+ # # Example: Read and decrypt a line at a time from a file
9
+ # SymmetricEncryption::Reader.open('test_file') do |file|
10
+ # file.each_line {|line| p line }
11
+ # end
12
+ #
13
+ # # Example: Read and decrypt entire file in memory
14
+ # # Not recommended for large files
15
+ # SymmetricEncryption::Reader.open('test_file') {|f| f.read }
16
+ #
17
+ # # Example: Reading a limited number of bytes at a time from the file
18
+ # SymmetricEncryption::Reader.open('test_file') do |file|
19
+ # file.read(1)
20
+ # file.read(5)
21
+ # file.read
22
+ # end
23
+ #
24
+ # # Example: Read and decrypt 5 bytes at a time until the end of file is reached
25
+ # SymmetricEncryption::Reader.open('test_file') do |file|
26
+ # while !file.eof? do
27
+ # file.read(5)
28
+ # end
29
+ # end
30
+ #
31
+ # # Example: Read, Unencrypt and decompress data in a file
32
+ # SymmetricEncryption::Reader.open('encrypted_compressed.zip', :compress => true) do |file|
33
+ # file.each_line {|line| p line }
34
+ # end
35
+ class Reader
36
+ # Open a file for reading, or use the supplied IO Stream
37
+ #
38
+ # Parameters:
39
+ # filename_or_stream:
40
+ # The filename to open if a string, otherwise the stream to use
41
+ # The file or stream will be closed on completion, use .initialize to
42
+ # avoid having the stream closed automatically
43
+ #
44
+ # options:
45
+ # :compress [true|false]
46
+ # Uses Zlib to decompress the data after it is decrypted
47
+ # Note: This option is only used if the file does not have a header
48
+ # indicating whether it is compressed
49
+ # Default: false
50
+ #
51
+ # :version
52
+ # Version of the encryption key to use when decrypting and the
53
+ # file/stream does not include a header at the beginning
54
+ # Default: Current primary key
55
+ #
56
+ # :mode
57
+ # See File.open for open modes
58
+ # Default: 'r'
59
+ #
60
+ # :buffer_size
61
+ # Amount of data to read at a time
62
+ # Default: 4096
63
+ #
64
+ # Note: Decryption occurs before decompression
65
+ def self.open(filename_or_stream, options={}, &block)
66
+ raise "options must be a hash" unless options.respond_to?(:each_pair)
67
+ mode = options.fetch(:mode, 'r')
68
+ compress = options.fetch(:compress, false)
69
+ ios = filename_or_stream.is_a?(String) ? ::File.open(filename_or_stream, mode) : filename_or_stream
70
+
71
+ begin
72
+ file = self.new(ios, options)
73
+ file = Zlib::GzipReader.new(file) if file.compressed? || compress
74
+ block.call(file)
75
+ ensure
76
+ file.close if file
77
+ end
78
+ end
79
+
80
+ # Decrypt data before reading from the supplied stream
81
+ def initialize(ios,options={})
82
+ @ios = ios
83
+ @buffer_size = options.fetch(:buffer_size, 4096).to_i
84
+ @compressed = nil
85
+ @read_buffer = ''
86
+
87
+ # Read first block and check for the header
88
+ buf = @ios.read(@buffer_size)
89
+ if buf.start_with?(SymmetricEncryption::MAGIC_HEADER)
90
+ # Header includes magic header and version byte
91
+ # Remove header and extract flags
92
+ header, flags = buf.slice!(0..MAGIC_HEADER_SIZE).unpack(MAGIC_HEADER_UNPACK)
93
+ @compressed = flags & 0b1000_0000_0000_0000
94
+ @version = @compressed ? flags - 0b1000_0000_0000_0000 : flags
95
+ else
96
+ @version = options[:version]
97
+ end
98
+
99
+ # Use primary cipher by default, but allow a secondary cipher to be selected for encryption
100
+ @cipher = SymmetricEncryption.cipher(@version)
101
+ raise "Cipher with version:#{@version} not found in any of the configured SymmetricEncryption ciphers" unless @cipher
102
+ @stream_cipher = @cipher.send(:openssl_cipher, :decrypt)
103
+
104
+ # First call to #update should return an empty string anyway
105
+ @read_buffer << @stream_cipher.update(buf)
106
+ @read_buffer << @stream_cipher.final if @ios.eof?
107
+ end
108
+
109
+ # Returns whether the stream being read is compressed
110
+ #
111
+ # Should be called before any reads are performed to determine if the file or
112
+ # stream is compressed.
113
+ #
114
+ # Returns true when the header is present in the stream and it is compressed
115
+ # Returns false when the header is present in the stream and it is not compressed
116
+ # Returns nil when the header is not present in the stream
117
+ #
118
+ # Note: The file will not be decompressed automatically when compressed.
119
+ # To decompress the data automatically call SymmetricEncryption.open
120
+ def compressed?
121
+ @compressed
122
+ end
123
+
124
+ # Returns the Cipher encryption version used to encrypt this file
125
+ # Returns nil when the header was not present in the stream and no :version
126
+ # option was supplied
127
+ #
128
+ # Note: When no header is present, the version is set to the one supplied
129
+ # in the options
130
+ def version
131
+ @version
132
+ end
133
+
134
+ # Close the IO Stream
135
+ #
136
+ # Note: Also closes the passed in io stream or file
137
+ #
138
+ # It is recommended to call Symmetric::EncryptedStream.open or Symmetric::EncryptedStream.io
139
+ # rather than creating an instance of Symmetric::EncryptedStream directly to
140
+ # ensure that the encrypted stream is closed before the stream itself is closed
141
+ def close(close_child_stream = true)
142
+ @ios.close if close_child_stream
143
+ end
144
+
145
+ # Read from the stream and return the decrypted data
146
+ # See IOS#read
147
+ #
148
+ # Reads at most length bytes from the I/O stream, or to the end of file if
149
+ # length is omitted or is nil. length must be a non-negative integer or nil.
150
+ #
151
+ # At end of file, it returns nil or "" depending on length.
152
+ def read(length=nil)
153
+ data = nil
154
+ if length
155
+ return '' if length == 0
156
+ # Read length bytes
157
+ while (@read_buffer.length < length) && !@ios.eof?
158
+ read_block
159
+ end
160
+ if @read_buffer.length > length
161
+ data = @read_buffer.slice!(0..length-1)
162
+ else
163
+ data = @read_buffer
164
+ @read_buffer = ''
165
+ end
166
+ else
167
+ # Capture anything already in the buffer
168
+ data = @read_buffer
169
+ @read_buffer = ''
170
+
171
+ if !@ios.eof?
172
+ # Read entire file
173
+ buf = @ios.read || ''
174
+ data << @stream_cipher.update(buf) if buf && buf.length > 0
175
+ data << @stream_cipher.final
176
+ end
177
+ end
178
+ data
179
+ end
180
+
181
+ # Reads a single decrypted line from the file up to and including the optional sep_string.
182
+ # Returns nil on eof
183
+ # The stream must be opened for reading or an IOError will be raised.
184
+ def readline(sep_string = "\n")
185
+ # Read more data until we get the sep_string
186
+ while (index = @read_buffer.index(sep_string)).nil? && !@ios.eof?
187
+ read_block
188
+ end
189
+ index ||= -1
190
+ @read_buffer.slice!(0..index)
191
+ end
192
+
193
+ # ios.each(sep_string="\n") {|line| block } => ios
194
+ # ios.each_line(sep_string="\n") {|line| block } => ios
195
+ # Executes the block for every line in ios, where lines are separated by sep_string.
196
+ # ios must be opened for reading or an IOError will be raised.
197
+ def each_line(sep_string = "\n")
198
+ while !eof?
199
+ yield readline(sep_string)
200
+ end
201
+ self
202
+ end
203
+
204
+ alias_method :each, :each_line
205
+
206
+ # Returns whether the end of file has been reached for this stream
207
+ def eof?
208
+ (@read_buffer.size == 0) && @ios.eof?
209
+ end
210
+
211
+ private
212
+
213
+ # Read a block of data and append the decrypted data in the read buffer
214
+ def read_block
215
+ buf = @ios.read(@buffer_size)
216
+ @read_buffer << @stream_cipher.update(buf) if buf && buf.length > 0
217
+ @read_buffer << @stream_cipher.final if @ios.eof?
218
+ end
219
+
220
+ end
221
+ end
@@ -0,0 +1,280 @@
1
+ require 'base64'
2
+ require 'openssl'
3
+ require 'zlib'
4
+ require 'yaml'
5
+
6
+ # Encrypt using 256 Bit AES CBC symmetric key and initialization vector
7
+ # The symmetric key is protected using the private key below and must
8
+ # be distributed separately from the application
9
+ module SymmetricEncryption
10
+
11
+ # Defaults
12
+ @@cipher = nil
13
+ @@secondary_ciphers = []
14
+
15
+ # Set the Primary Symmetric Cipher to be used
16
+ def self.cipher=(cipher)
17
+ raise "Cipher must be similar to SymmetricEncryption::Ciphers" unless cipher.respond_to?(:encrypt) && cipher.respond_to?(:decrypt)
18
+ @@cipher = cipher
19
+ end
20
+
21
+ # Returns the Primary Symmetric Cipher being used
22
+ # If a version is supplied, then the cipher matching that version will be
23
+ # returned or nil if no match was found
24
+ def self.cipher(version = nil)
25
+ return @@cipher if version.nil? || (@@cipher.version == version)
26
+ secondary_ciphers.find {|c| c.version == version}
27
+ end
28
+
29
+ # Set the Secondary Symmetric Ciphers Array to be used
30
+ def self.secondary_ciphers=(secondary_ciphers)
31
+ raise "secondary_ciphers must be a collection" unless secondary_ciphers.respond_to? :each
32
+ secondary_ciphers.each do |cipher|
33
+ raise "secondary_ciphers can only consist of SymmetricEncryption::Ciphers" unless cipher.respond_to?(:encrypt) && cipher.respond_to?(:decrypt)
34
+ end
35
+ @@secondary_ciphers = secondary_ciphers
36
+ end
37
+
38
+ # Returns the Primary Symmetric Cipher being used
39
+ def self.secondary_ciphers
40
+ @@secondary_ciphers
41
+ end
42
+
43
+ # AES Symmetric Decryption of supplied string
44
+ # Returns decrypted string
45
+ # Returns nil if the supplied str is nil
46
+ # Returns "" if it is a string and it is empty
47
+ #
48
+ # Note: If secondary ciphers are supplied in the configuration file the
49
+ # first key will be used to decrypt 'str'. If it fails each cipher in the
50
+ # order supplied will be tried.
51
+ # It is slow to try each cipher in turn, so should be used during migrations
52
+ # only
53
+ #
54
+ # Raises: OpenSSL::Cipher::CipherError when 'str' was not encrypted using
55
+ # the supplied key and iv
56
+ #
57
+ def self.decrypt(str)
58
+ raise "Call SymmetricEncryption.load! or SymmetricEncryption.cipher= prior to encrypting or decrypting data" unless @@cipher
59
+ binary = ::Base64.decode64(str) if str
60
+ begin
61
+ @@cipher.decrypt(binary)
62
+ rescue OpenSSL::Cipher::CipherError => exc
63
+ @@secondary_ciphers.each do |cipher|
64
+ begin
65
+ return cipher.decrypt(binary)
66
+ rescue OpenSSL::Cipher::CipherError
67
+ end
68
+ end
69
+ raise exc
70
+ end
71
+ end
72
+
73
+ # AES Symmetric Encryption of supplied string
74
+ # Returns result as a Base64 encoded string
75
+ # Returns nil if the supplied str is nil
76
+ # Returns "" if it is a string and it is empty
77
+ def self.encrypt(str)
78
+ raise "Call SymmetricEncryption.load! or SymmetricEncryption.cipher= prior to encrypting or decrypting data" unless @@cipher
79
+
80
+ # Encrypt data as a binary string
81
+ result = @@cipher.encrypt(str)
82
+
83
+ # Base 64 Encoding of binary data
84
+ result = ::Base64.encode64(result) if result
85
+ result
86
+ end
87
+
88
+ # Invokes decrypt
89
+ # Returns decrypted String
90
+ # Return nil if it fails to decrypt a String
91
+ #
92
+ # Useful for example when decoding passwords encrypted using a key from a
93
+ # different environment. I.e. We cannot decode production passwords
94
+ # in the test or development environments but still need to be able to load
95
+ # YAML config files that contain encrypted development and production passwords
96
+ def self.try_decrypt(str)
97
+ raise "Call SymmetricEncryption.load! or SymmetricEncryption.cipher= prior to encrypting or decrypting data" unless @@cipher
98
+ begin
99
+ decrypt(str)
100
+ rescue OpenSSL::Cipher::CipherError
101
+ nil
102
+ end
103
+ end
104
+
105
+ # Returns [true|false] as to whether the data could be decrypted
106
+ # Parameters:
107
+ # encrypted_data: Encrypted string
108
+ def self.encrypted?(encrypted_data)
109
+ raise "Call SymmetricEncryption.load! or SymmetricEncryption.cipher= prior to encrypting or decrypting data" unless @@cipher
110
+
111
+ # First make sure Base64 encoded data still ends with "\n" since it could be used in a key field somewhere
112
+ return false unless encrypted_data.end_with?("\n")
113
+
114
+ # For now have to decrypt it fully
115
+ !try_decrypt(encrypted_data).nil?
116
+ end
117
+
118
+ # Load the Encryption Configuration from a YAML file
119
+ # filename:
120
+ # Name of file to read.
121
+ # Mandatory for non-Rails apps
122
+ # Default: Rails.root/config/symmetric-encryption.yml
123
+ # environment:
124
+ # Which environments config to load. Usually: production, development, etc.
125
+ # Default: Rails.env
126
+ def self.load!(filename=nil, environment=nil)
127
+ config = read_config(filename, environment)
128
+
129
+ # Check for hard coded key, iv and cipher
130
+ if config[:key]
131
+ @@cipher = Cipher.new(config)
132
+ @@secondary_ciphers = []
133
+ else
134
+ private_rsa_key = config[:private_rsa_key]
135
+ @@cipher, *@@secondary_ciphers = config[:ciphers].collect do |cipher_conf|
136
+ cipher_from_encrypted_files(
137
+ private_rsa_key,
138
+ cipher_conf[:cipher],
139
+ cipher_conf[:key_filename],
140
+ cipher_conf[:iv_filename])
141
+ end
142
+ end
143
+
144
+ true
145
+ end
146
+
147
+ # Future: Generate private key in config file generator
148
+ #new_key = OpenSSL::PKey::RSA.generate(2048)
149
+
150
+ # Generate new random symmetric keys for use with this Encryption library
151
+ #
152
+ # Note: Only the current Encryption key settings are used
153
+ #
154
+ # Creates Symmetric Key .key
155
+ # and initilization vector .iv
156
+ # which is encrypted with the above Public key
157
+ #
158
+ # Warning: Existing files will be overwritten
159
+ def self.generate_symmetric_key_files(filename=nil, environment=nil)
160
+ config = read_config(filename, environment)
161
+ cipher_cfg = config[:ciphers].first
162
+ key_filename = cipher_cfg[:key_filename]
163
+ iv_filename = cipher_cfg[:iv_filename]
164
+ cipher = cipher_cfg[:cipher]
165
+
166
+ raise "The configuration file must contain a 'private_rsa_key' parameter to generate symmetric keys" unless config[:private_rsa_key]
167
+ rsa_key = OpenSSL::PKey::RSA.new(config[:private_rsa_key])
168
+
169
+ # Generate a new Symmetric Key pair
170
+ key_pair = SymmetricEncryption::Cipher.random_key_pair(cipher || 'aes-256-cbc', !iv_filename.nil?)
171
+
172
+ # Save symmetric key after encrypting it with the private RSA key, backing up existing files if present
173
+ File.rename(key_filename, "#{key_filename}.#{Time.now.to_i}") if File.exist?(key_filename)
174
+ File.open(key_filename, 'wb') {|file| file.write( rsa_key.public_encrypt(key_pair[:key]) ) }
175
+
176
+ if iv_filename
177
+ File.rename(iv_filename, "#{iv_filename}.#{Time.now.to_i}") if File.exist?(iv_filename)
178
+ File.open(iv_filename, 'wb') {|file| file.write( rsa_key.public_encrypt(key_pair[:iv]) ) }
179
+ end
180
+ puts("Generated new Symmetric Key for encryption. Please copy #{key_filename} and #{iv_filename} to the other web servers in #{environment}.")
181
+ end
182
+
183
+ # Generate a 22 character random password
184
+ def self.random_password
185
+ Base64.encode64(OpenSSL::Cipher.new('aes-128-cbc').random_key)[0..-4]
186
+ end
187
+
188
+ # Binary encrypted data includes this magic header so that we can quickly
189
+ # identify binary data versus base64 encoded data that does not have this header
190
+ unless defined? MAGIC_HEADER
191
+ MAGIC_HEADER = '@EnC'
192
+ MAGIC_HEADER_SIZE = MAGIC_HEADER.size
193
+ MAGIC_HEADER_UNPACK = "A#{MAGIC_HEADER_SIZE}v"
194
+ end
195
+
196
+ protected
197
+
198
+ # Returns the Encryption Configuration
199
+ #
200
+ # Read the configuration from the YAML file and return in the latest format
201
+ #
202
+ # filename:
203
+ # Name of file to read.
204
+ # Mandatory for non-Rails apps
205
+ # Default: Rails.root/config/symmetric-encryption.yml
206
+ # environment:
207
+ # Which environments config to load. Usually: production, development, etc.
208
+ def self.read_config(filename=nil, environment=nil)
209
+ config = YAML.load_file(filename || File.join(Rails.root, "config", "symmetric-encryption.yml"))[environment || Rails.env]
210
+
211
+ # Default cipher
212
+ default_cipher = config['cipher'] || 'aes-256-cbc'
213
+ cfg = {}
214
+
215
+ # Hard coded symmetric_key? - Dev / Testing use only!
216
+ if symmetric_key = (config['key'] || config['symmetric_key'])
217
+ raise "SymmetricEncryption Cannot hard code Production encryption keys in #{filename}" if (environment || Rails.env) == 'production'
218
+ cfg[:key] = symmetric_key
219
+ cfg[:iv] = config['iv'] || config['symmetric_iv']
220
+ cfg[:cipher] = default_cipher
221
+
222
+ elsif ciphers = config['ciphers']
223
+ raise "Missing mandatory config parameter 'private_rsa_key'" unless cfg[:private_rsa_key] = config['private_rsa_key']
224
+
225
+ cfg[:ciphers] = ciphers.collect do |cipher_cfg|
226
+ key_filename = cipher_cfg['key_filename'] || cipher_cfg['symmetric_key_filename']
227
+ raise "Missing mandatory 'key_filename' for environment:#{environment} in #{filename}" unless key_filename
228
+ iv_filename = cipher_cfg['iv_filename'] || cipher_cfg['symmetric_iv_filename']
229
+ {
230
+ :cipher => cipher_cfg['cipher'] || default_cipher,
231
+ :key_filename => key_filename,
232
+ :iv_filename => iv_filename,
233
+ }
234
+ end
235
+
236
+ else
237
+ # Migrate old format config
238
+ raise "Missing mandatory config parameter 'private_rsa_key'" unless cfg[:private_rsa_key] = config['private_rsa_key']
239
+ cfg[:ciphers] = [ {
240
+ :cipher => default_cipher,
241
+ :key_filename => config['symmetric_key_filename'],
242
+ :iv_filename => config['symmetric_iv_filename'],
243
+ } ]
244
+ end
245
+
246
+ cfg
247
+ end
248
+
249
+ # Returns an instance of SymmetricEncryption::Cipher initialized from keys
250
+ # stored in files
251
+ #
252
+ # Raises an Exception on failure
253
+ #
254
+ # Parameters:
255
+ # cipher
256
+ # Encryption cipher for the symmetric encryption key
257
+ # private_key
258
+ # Key used to unlock file containing the actual symmetric key
259
+ # key_filename
260
+ # Name of file containing symmetric key encrypted using the public
261
+ # key matching the supplied private_key
262
+ # iv_filename
263
+ # Optional. Name of file containing symmetric key initialization vector
264
+ # encrypted using the public key matching the supplied private_key
265
+ def self.cipher_from_encrypted_files(private_rsa_key, cipher, key_filename, iv_filename = nil)
266
+ # Load Encrypted Symmetric keys
267
+ encrypted_key = File.read(key_filename)
268
+ encrypted_iv = File.read(iv_filename) if iv_filename
269
+
270
+ # Decrypt Symmetric Keys
271
+ rsa = OpenSSL::PKey::RSA.new(private_rsa_key)
272
+ iv = rsa.private_decrypt(encrypted_iv) if iv_filename
273
+ Cipher.new(
274
+ :key => rsa.private_decrypt(encrypted_key),
275
+ :iv => iv,
276
+ :cipher => cipher
277
+ )
278
+ end
279
+
280
+ end