symmetric-encryption 0.4.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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