lockbox 0.2.5 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 33199b663aaa289ff221bd83278063e366501185112f9aea61ea52997c6646fb
4
- data.tar.gz: 3d8556b252b574c69cd50244cc752302825cae423daf6bcbac5a0f07b6a83d9d
3
+ metadata.gz: 6ba8d72d01b0dd6f8562bfb0980f9131e3670378299de01762cfefb6dd808857
4
+ data.tar.gz: ace013580f48bb9c0950f2393726ff8a05923e626babdc9102a89bc6bd5c5043
5
5
  SHA512:
6
- metadata.gz: 9246245fe128a27292ee9a365ab04fcb0acfd3336b838aeef5756353d35add2248911916e6dec4ed46cb0adf01576ac8fb98066cb6a35aec7827144d5d208d40
7
- data.tar.gz: 98273c362d05eca1b66cc578264c18db1a66007fd461ae0d3d8919305f9c34b9af70450a5fe3b318357fe296fc0455b52f52d7b2313a04269101fdf50b408df4
6
+ metadata.gz: 29c1efa284ef09cb81e30bbc26b0f53a29793412e5f6983dd47af8a2b4258c56449cfd9f3410218b354abf56f655cbde33e60f6f5b2ef03037c32cfa258685f2
7
+ data.tar.gz: a8d52b8a2a650bfe6c792c68b89dade741913081f10cda760dd76ccf74bbf5c6ac282b87fd02380eb4eff2899caba591fec1a95e325819f7688baa7cedfc081f
data/CHANGELOG.md CHANGED
@@ -1,3 +1,13 @@
1
+ ## 0.3.0 (2019-12-22)
2
+
3
+ - Added support for custom types
4
+ - Added support for virtual attributes
5
+ - Made many Mongoid methods consistent with unencrypted columns
6
+ - Made `was` and `in_database` methods consistent with unencrypted columns before an update
7
+ - Made `restore` methods restore ciphertext
8
+ - Fixed virtual attribute being saved with `nil` for Mongoid
9
+ - Changed `Lockbox` to module
10
+
1
11
  ## 0.2.5 (2019-12-14)
2
12
 
3
13
  - Made `model.attribute?` consistent with unencrypted columns
data/README.md CHANGED
@@ -5,7 +5,7 @@
5
5
  - Uses state-of-the-art algorithms
6
6
  - Works with database fields, files, and strings
7
7
  - Stores encrypted data in a single field
8
- - Requires you to only manage a single encryption key
8
+ - Requires you to only manage a single encryption key (with the option to have more)
9
9
  - Makes migrating existing data and key rotation easy
10
10
 
11
11
  Learn [the principles behind it](https://ankane.org/modern-encryption-rails), [how to secure emails](https://ankane.org/securing-user-emails-lockbox), and [how to secure sensitive data in Rails](https://ankane.org/sensitive-data-rails)
@@ -117,10 +117,22 @@ Lockbox automatically works with serialized fields for maximum compatibility wit
117
117
  ```ruby
118
118
  class User < ApplicationRecord
119
119
  serialize :properties, JSON
120
- encrypts :properties
121
-
122
120
  store :settings, accessors: [:color, :homepage]
123
- encrypts :settings
121
+ attribute :configuration, CustomType.new
122
+
123
+ encrypts :properties, :settings, :configuration
124
+ end
125
+ ```
126
+
127
+ For [StoreModel](https://github.com/DmitryTsepelev/store_model), use:
128
+
129
+ ```ruby
130
+ class User < ApplicationRecord
131
+ encrypts :configuration, type: Configuration.to_type
132
+
133
+ after_initialize do
134
+ self.configuration ||= {}
135
+ end
124
136
  end
125
137
  ```
126
138
 
@@ -307,6 +319,8 @@ Finally, drop the unencrypted column.
307
319
 
308
320
  To make key rotation easy, you can pass previous versions of keys that can decrypt.
309
321
 
322
+ ### Active Record
323
+
310
324
  For Active Record, use:
311
325
 
312
326
  ```ruby
@@ -321,6 +335,24 @@ To rotate, use:
321
335
  user.update!(email: user.email)
322
336
  ```
323
337
 
338
+ ### Mongoid
339
+
340
+ For Mongoid, use:
341
+
342
+ ```ruby
343
+ class User
344
+ encrypts :email, previous_versions: [{key: previous_key}]
345
+ end
346
+ ```
347
+
348
+ To rotate, use:
349
+
350
+ ```ruby
351
+ user.update!(email: user.email)
352
+ ```
353
+
354
+ ### Active Storage
355
+
324
356
  For Active Storage use:
325
357
 
326
358
  ```ruby
@@ -335,6 +367,8 @@ To rotate existing files, use:
335
367
  user.license.rotate_encryption!
336
368
  ```
337
369
 
370
+ ### CarrierWave
371
+
338
372
  For CarrierWave, use:
339
373
 
340
374
  ```ruby
@@ -349,6 +383,8 @@ To rotate existing files, use:
349
383
  user.license.rotate_encryption!
350
384
  ```
351
385
 
386
+ ### Strings
387
+
352
388
  For strings, use:
353
389
 
354
390
  ```ruby
@@ -2,7 +2,7 @@
2
2
  # however, there isn't really a great place to define encryption settings there
3
3
  # instead, we encrypt and decrypt at the attachment level,
4
4
  # and we define encryption settings at the model level
5
- class Lockbox
5
+ module Lockbox
6
6
  module ActiveStorageExtensions
7
7
  module Attached
8
8
  protected
@@ -1,4 +1,4 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  class AES_GCM
3
3
  def initialize(key)
4
4
  raise ArgumentError, "Key must be 32 bytes" unless key && key.bytesize == 32
data/lib/lockbox/box.rb CHANGED
@@ -1,11 +1,11 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  class Box
3
3
  def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil, padding: false)
4
4
  raise ArgumentError, "Cannot pass both key and public/private key" if key && (encryption_key || decryption_key)
5
5
 
6
6
  key = Lockbox::Utils.decode_key(key) if key
7
- encryption_key = Lockbox::Utils.decode_key(encryption_key) if encryption_key
8
- decryption_key = Lockbox::Utils.decode_key(decryption_key) if decryption_key
7
+ encryption_key = Lockbox::Utils.decode_key(encryption_key, size: 64) if encryption_key
8
+ decryption_key = Lockbox::Utils.decode_key(decryption_key, size: 64) if decryption_key
9
9
 
10
10
  algorithm ||= "aes-gcm"
11
11
 
@@ -44,6 +44,7 @@ class Lockbox
44
44
  nonce = generate_nonce(@encryption_box)
45
45
  ciphertext = @encryption_box.encrypt(nonce, message)
46
46
  when "xsalsa20"
47
+ raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
47
48
  nonce = generate_nonce(@box)
48
49
  ciphertext = @box.encrypt(nonce, message)
49
50
  else
@@ -62,6 +63,7 @@ class Lockbox
62
63
  nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
63
64
  @decryption_box.decrypt(nonce, ciphertext)
64
65
  when "xsalsa20"
66
+ raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
65
67
  nonce, ciphertext = extract_nonce(@box, ciphertext)
66
68
  @box.decrypt(nonce, ciphertext)
67
69
  else
@@ -1,4 +1,4 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  module CarrierWaveExtensions
3
3
  def encrypt(**options)
4
4
  class_eval do
@@ -1,13 +1,92 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  class Encryptor
3
+ def initialize(**options)
4
+ options = Lockbox.default_options.merge(options)
5
+ previous_versions = options.delete(:previous_versions)
6
+
7
+ @boxes =
8
+ [Box.new(options)] +
9
+ Array(previous_versions).map { |v| Box.new({key: options[:key]}.merge(v)) }
10
+ end
11
+
12
+ def encrypt(message, **options)
13
+ message = check_string(message, "message")
14
+ @boxes.first.encrypt(message, **options)
15
+ end
16
+
17
+ def decrypt(ciphertext, **options)
18
+ ciphertext = check_string(ciphertext, "ciphertext")
19
+
20
+ # ensure binary
21
+ if ciphertext.encoding != Encoding::BINARY
22
+ # dup to prevent mutation
23
+ ciphertext = ciphertext.dup.force_encoding(Encoding::BINARY)
24
+ end
25
+
26
+ @boxes.each_with_index do |box, i|
27
+ begin
28
+ return box.decrypt(ciphertext, **options)
29
+ rescue => e
30
+ # returning DecryptionError instead of PaddingError
31
+ # is for end-user convenience, not for security
32
+ error_classes = [DecryptionError, PaddingError]
33
+ error_classes << RbNaCl::LengthError if defined?(RbNaCl::LengthError)
34
+ error_classes << RbNaCl::CryptoError if defined?(RbNaCl::CryptoError)
35
+ if error_classes.any? { |ec| e.is_a?(ec) }
36
+ raise DecryptionError, "Decryption failed" if i == @boxes.size - 1
37
+ else
38
+ raise e
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ def encrypt_io(io, **options)
45
+ new_io = Lockbox::IO.new(encrypt(io.read, **options))
46
+ copy_metadata(io, new_io)
47
+ new_io
48
+ end
49
+
50
+ def decrypt_io(io, **options)
51
+ new_io = Lockbox::IO.new(decrypt(io.read, **options))
52
+ copy_metadata(io, new_io)
53
+ new_io
54
+ end
55
+
56
+ def decrypt_str(ciphertext, **options)
57
+ message = decrypt(ciphertext, **options)
58
+ message.force_encoding(Encoding::UTF_8)
59
+ end
60
+
61
+ private
62
+
63
+ def check_string(str, name)
64
+ str = str.read if str.respond_to?(:read)
65
+ raise TypeError, "can't convert #{name} to string" unless str.respond_to?(:to_str)
66
+ str.to_str
67
+ end
68
+
69
+ def copy_metadata(source, target)
70
+ target.original_filename =
71
+ if source.respond_to?(:original_filename)
72
+ source.original_filename
73
+ elsif source.respond_to?(:path)
74
+ File.basename(source.path)
75
+ end
76
+ target.content_type = source.content_type if source.respond_to?(:content_type)
77
+ end
78
+
79
+ # legacy for attr_encrypted
3
80
  def self.encrypt(options)
4
81
  box(options).encrypt(options[:value])
5
82
  end
6
83
 
84
+ # legacy for attr_encrypted
7
85
  def self.decrypt(options)
8
86
  box(options).decrypt(options[:value])
9
87
  end
10
88
 
89
+ # legacy for attr_encrypted
11
90
  def self.box(options)
12
91
  options = options.slice(:key, :encryption_key, :decryption_key, :algorithm, :previous_versions)
13
92
  options[:algorithm] = "aes-gcm" if options[:algorithm] == "aes-256-gcm"
data/lib/lockbox/io.rb CHANGED
@@ -1,4 +1,4 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  class IO < StringIO
3
3
  attr_accessor :original_filename, :content_type
4
4
  end
@@ -1,4 +1,4 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  class KeyGenerator
3
3
  def initialize(master_key)
4
4
  @master_key = master_key
@@ -0,0 +1,58 @@
1
+ module Lockbox
2
+ class Migrator
3
+ def initialize(model)
4
+ @model = model
5
+ end
6
+
7
+ def migrate(restart:)
8
+ model = @model
9
+
10
+ # get fields
11
+ fields = model.lockbox_attributes.select { |k, v| v[:migrating] }
12
+
13
+ # get blind indexes
14
+ blind_indexes = model.respond_to?(:blind_indexes) ? model.blind_indexes.select { |k, v| v[:migrating] } : {}
15
+
16
+ # build relation
17
+ relation = model.unscoped
18
+
19
+ unless restart
20
+ attributes = fields.map { |_, v| v[:encrypted_attribute] }
21
+ attributes += blind_indexes.map { |_, v| v[:bidx_attribute] }
22
+
23
+ if defined?(ActiveRecord::Base) && model.is_a?(ActiveRecord::Base)
24
+ attributes.each_with_index do |attribute, i|
25
+ relation =
26
+ if i == 0
27
+ relation.where(attribute => nil)
28
+ else
29
+ relation.or(model.unscoped.where(attribute => nil))
30
+ end
31
+ end
32
+ end
33
+ end
34
+
35
+ if relation.respond_to?(:find_each)
36
+ relation.find_each do |record|
37
+ migrate_record(record, fields: fields, blind_indexes: blind_indexes, restart: restart)
38
+ end
39
+ else
40
+ relation.all.each do |record|
41
+ migrate_record(record, fields: fields, blind_indexes: blind_indexes, restart: restart)
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def migrate_record(record, fields:, blind_indexes:, restart:)
49
+ fields.each do |k, v|
50
+ record.send("#{v[:attribute]}=", record.send(k)) if restart || !record.send(v[:encrypted_attribute])
51
+ end
52
+ blind_indexes.each do |k, v|
53
+ record.send("compute_#{k}_bidx") if restart || !record.send(v[:bidx_attribute])
54
+ end
55
+ record.save(validate: false) if record.changed?
56
+ end
57
+ end
58
+ end
data/lib/lockbox/model.rb CHANGED
@@ -1,4 +1,4 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  module Model
3
3
  def attached_encrypted(attribute, **options)
4
4
  warn "[lockbox] DEPRECATION WARNING: Use encrypts_attached instead"
@@ -12,7 +12,7 @@ class Lockbox
12
12
  class_eval do
13
13
  @lockbox_attachments ||= {}
14
14
 
15
- unless respond_to?(:lockbox_attachments)
15
+ if @lockbox_attachments.empty?
16
16
  def self.lockbox_attachments
17
17
  parent_attachments =
18
18
  if superclass.respond_to?(:lockbox_attachments)
@@ -31,7 +31,7 @@ class Lockbox
31
31
  end
32
32
  end
33
33
 
34
- def encrypts(*attributes, encode: true, **options)
34
+ def encrypts(*attributes, **options)
35
35
  # support objects
36
36
  # case options[:type]
37
37
  # when Date
@@ -50,17 +50,11 @@ class Lockbox
50
50
  # options[:type] = :float
51
51
  # end
52
52
 
53
- raise ArgumentError, "Unknown type: #{options[:type]}" unless [nil, :string, :boolean, :date, :datetime, :time, :integer, :float, :binary, :json, :hash].include?(options[:type])
53
+ custom_type = options[:type].respond_to?(:serialize) && options[:type].respond_to?(:deserialize)
54
+ raise ArgumentError, "Unknown type: #{options[:type]}" unless custom_type || [nil, :string, :boolean, :date, :datetime, :time, :integer, :float, :binary, :json, :hash].include?(options[:type])
54
55
 
55
- attribute_type =
56
- case options[:type]
57
- when nil, :json, :hash
58
- :string
59
- when :integer
60
- ActiveModel::Type::Integer.new(limit: 8)
61
- else
62
- options[:type]
63
- end
56
+ activerecord = defined?(ActiveRecord::Base) && self < ActiveRecord::Base
57
+ raise ArgumentError, "Type not supported yet with Mongoid" if options[:type] && !activerecord
64
58
 
65
59
  attributes.each do |name|
66
60
  # add default options
@@ -76,18 +70,15 @@ class Lockbox
76
70
 
77
71
  options[:attribute] = name.to_s
78
72
  options[:encrypted_attribute] = encrypted_attribute
79
- class_method_name = "generate_#{encrypted_attribute}"
73
+ options[:encode] = true unless options.key?(:encode)
80
74
 
81
- class_eval do
82
- if options[:migrating]
83
- before_validation do
84
- send("#{name}=", send(original_name)) if send("#{original_name}_changed?")
85
- end
86
- end
75
+ encrypt_method_name = "generate_#{encrypted_attribute}"
76
+ decrypt_method_name = "decrypt_#{encrypted_attribute}"
87
77
 
78
+ class_eval do
88
79
  @lockbox_attributes ||= {}
89
80
 
90
- unless respond_to?(:lockbox_attributes)
81
+ if @lockbox_attributes.empty?
91
82
  def self.lockbox_attributes
92
83
  parent_attributes =
93
84
  if superclass.respond_to?(:lockbox_attributes)
@@ -98,12 +89,7 @@ class Lockbox
98
89
 
99
90
  parent_attributes.merge(@lockbox_attributes || {})
100
91
  end
101
- end
102
92
 
103
- raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name]
104
- @lockbox_attributes[original_name] = options.merge(encode: encode)
105
-
106
- if @lockbox_attributes.size == 1
107
93
  # use same approach as activerecord serialization
108
94
  def serializable_hash(options = nil)
109
95
  options = options.try(:dup) || {}
@@ -123,23 +109,20 @@ class Lockbox
123
109
  "#<#{self.class} #{inspection.join(", ")}>"
124
110
  end
125
111
 
126
- # needed for in-place modifications
127
- # assigned attributes are encrypted on assignment
128
- # and then again here
129
- before_save do
130
- self.class.lockbox_attributes.each do |_, lockbox_attribute|
131
- attribute = lockbox_attribute[:attribute]
112
+ if activerecord
113
+ # needed for in-place modifications
114
+ # assigned attributes are encrypted on assignment
115
+ # and then again here
116
+ before_save do
117
+ self.class.lockbox_attributes.each do |_, lockbox_attribute|
118
+ attribute = lockbox_attribute[:attribute]
132
119
 
133
- if changes.include?(attribute)
134
- type = (self.class.try(:attribute_types) || {})[attribute]
135
- if type && type.is_a?(ActiveRecord::Type::Serialized)
120
+ if attribute_changed_in_place?(attribute)
136
121
  send("#{attribute}=", send(attribute))
137
122
  end
138
123
  end
139
124
  end
140
- end
141
-
142
- if defined?(Mongoid::Document) && included_modules.include?(Mongoid::Document)
125
+ else
143
126
  def reload
144
127
  self.class.lockbox_attributes.each do |_, v|
145
128
  instance_variable_set("@#{v[:attribute]}", nil)
@@ -149,27 +132,56 @@ class Lockbox
149
132
  end
150
133
  end
151
134
 
152
- serialize name, JSON if options[:type] == :json
153
- serialize name, Hash if options[:type] == :hash
135
+ raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name]
136
+ @lockbox_attributes[original_name] = options
137
+
138
+ if activerecord
139
+ # preference:
140
+ # 1. type option
141
+ # 2. existing virtual attribute
142
+ # 3. default to string (which can later be overridden)
143
+ if options[:type]
144
+ attribute_type =
145
+ case options[:type]
146
+ when :json, :hash
147
+ :string
148
+ when :integer
149
+ ActiveModel::Type::Integer.new(limit: 8)
150
+ else
151
+ options[:type]
152
+ end
153
+
154
+ attribute name, attribute_type
155
+
156
+ serialize name, JSON if options[:type] == :json
157
+ serialize name, Hash if options[:type] == :hash
158
+ elsif !attributes_to_define_after_schema_loads.key?(name.to_s)
159
+ attribute name, :string
160
+ end
161
+
162
+ define_method("#{name}_was") do
163
+ send(name) # writes attribute when not already set
164
+ super()
165
+ end
154
166
 
155
- if respond_to?(:attribute)
156
- attribute name, attribute_type
167
+ # restore ciphertext as well
168
+ define_method("restore_#{name}!") do
169
+ super()
170
+ send("restore_#{encrypted_attribute}!")
171
+ end
157
172
 
158
- define_method("#{name}?") do
159
- send("#{encrypted_attribute}?")
173
+ if ActiveRecord::VERSION::STRING >= "5.1"
174
+ define_method("#{name}_in_database") do
175
+ send(name) # writes attribute when not already set
176
+ super()
177
+ end
160
178
  end
161
179
  else
180
+ # keep this module dead simple
181
+ # Mongoid uses changed_attributes to calculate keys to update
182
+ # so we shouldn't mess with it
162
183
  m = Module.new do
163
184
  define_method("#{name}=") do |val|
164
- prev_val = instance_variable_get("@#{name}")
165
-
166
- unless val == prev_val
167
- # custom attribute_will_change! method
168
- unless changed_attributes.key?(name.to_s)
169
- changed_attributes[name.to_s] = prev_val.__deep_copy__
170
- end
171
- end
172
-
173
185
  instance_variable_set("@#{name}", val)
174
186
  end
175
187
 
@@ -183,10 +195,32 @@ class Lockbox
183
195
  alias_method "#{name}_changed?", "#{encrypted_attribute}_changed?"
184
196
 
185
197
  define_method "#{name}_was" do
186
- attribute_was(name.to_s)
198
+ ciphertext = send("#{encrypted_attribute}_was")
199
+ self.class.send(decrypt_method_name, ciphertext, context: self)
200
+ end
201
+
202
+ define_method "#{name}_change" do
203
+ ciphertexts = send("#{encrypted_attribute}_change")
204
+ ciphertexts.map { |v| self.class.send(decrypt_method_name, v, context: self) } if ciphertexts
205
+ end
206
+
207
+ define_method "reset_#{name}!" do
208
+ instance_variable_set("@#{name}", nil)
209
+ send("reset_#{encrypted_attribute}!")
210
+ send(name)
211
+ end
212
+
213
+ define_method "reset_#{name}_to_default!" do
214
+ instance_variable_set("@#{name}", nil)
215
+ send("reset_#{encrypted_attribute}_to_default!")
216
+ send(name)
187
217
  end
188
218
  end
189
219
 
220
+ define_method("#{name}?") do
221
+ send("#{encrypted_attribute}?")
222
+ end
223
+
190
224
  define_method("#{name}=") do |message|
191
225
  original_message = message
192
226
 
@@ -199,7 +233,7 @@ class Lockbox
199
233
  end
200
234
 
201
235
  # set ciphertext
202
- ciphertext = self.class.send(class_method_name, message, context: self)
236
+ ciphertext = self.class.send(encrypt_method_name, message, context: self)
203
237
  send("#{encrypted_attribute}=", ciphertext)
204
238
 
205
239
  super(original_message)
@@ -210,53 +244,20 @@ class Lockbox
210
244
 
211
245
  unless message
212
246
  ciphertext = send(encrypted_attribute)
213
- message =
214
- if ciphertext.nil? || (ciphertext == "" && !options[:padding])
215
- ciphertext
216
- else
217
- ciphertext = Base64.decode64(ciphertext) if encode
218
- table = self.class.respond_to?(:table_name) ? self.class.table_name : self.class.collection_name.to_s
219
- Lockbox::Utils.build_box(self, options, table, encrypted_attribute).decrypt(ciphertext)
247
+ message = self.class.send(decrypt_method_name, ciphertext, context: self)
248
+
249
+ if activerecord
250
+ # set previous attribute on first decrypt
251
+ if @attributes[name.to_s]
252
+ @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message)
220
253
  end
221
254
 
222
- unless message.nil?
223
- case options[:type]
224
- when :boolean
225
- message = message == "t"
226
- when :date
227
- message = ActiveRecord::Type::Date.new.deserialize(message)
228
- when :datetime
229
- message = ActiveRecord::Type::DateTime.new.deserialize(message)
230
- when :time
231
- message = ActiveRecord::Type::Time.new.deserialize(message)
232
- when :integer
233
- message = ActiveRecord::Type::Integer.new(limit: 8).deserialize(message.unpack("q>").first)
234
- when :float
235
- message = ActiveRecord::Type::Float.new.deserialize(message.unpack("G").first)
236
- when :string
237
- message.force_encoding(Encoding::UTF_8)
238
- when :binary
239
- # do nothing
240
- # decrypt returns binary string
255
+ # cache
256
+ if respond_to?(:_write_attribute, true)
257
+ _write_attribute(name, message) if !@attributes.frozen?
241
258
  else
242
- type = (self.class.try(:attribute_types) || {})[name.to_s]
243
- if type && type.is_a?(ActiveRecord::Type::Serialized)
244
- message = type.deserialize(message)
245
- else
246
- # default to string if not serialized
247
- message.force_encoding(Encoding::UTF_8)
248
- end
259
+ raw_write_attribute(name, message) if !@attributes.frozen?
249
260
  end
250
- end
251
-
252
- # set previous attribute on first decrypt
253
- @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message) if @attributes[name.to_s]
254
-
255
- # cache
256
- if respond_to?(:_write_attribute, true)
257
- _write_attribute(name, message)
258
- elsif respond_to?(:raw_write_attribute)
259
- raw_write_attribute(name, message)
260
261
  else
261
262
  instance_variable_set("@#{name}", message)
262
263
  end
@@ -266,8 +267,8 @@ class Lockbox
266
267
  end
267
268
 
268
269
  # for fixtures
269
- define_singleton_method class_method_name do |message, **opts|
270
- table = respond_to?(:table_name) ? table_name : collection_name.to_s
270
+ define_singleton_method encrypt_method_name do |message, **opts|
271
+ table = activerecord ? table_name : collection_name.to_s
271
272
 
272
273
  unless message.nil?
273
274
  case options[:type]
@@ -302,9 +303,7 @@ class Lockbox
302
303
  # encrypt will convert to binary
303
304
  else
304
305
  type = (try(:attribute_types) || {})[name.to_s]
305
- if type && type.is_a?(ActiveRecord::Type::Serialized)
306
- message = type.serialize(message)
307
- end
306
+ message = type.serialize(message) if type
308
307
  end
309
308
  end
310
309
 
@@ -312,10 +311,55 @@ class Lockbox
312
311
  message
313
312
  else
314
313
  ciphertext = Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).encrypt(message)
315
- ciphertext = Base64.strict_encode64(ciphertext) if encode
314
+ ciphertext = Base64.strict_encode64(ciphertext) if options[:encode]
316
315
  ciphertext
317
316
  end
318
317
  end
318
+
319
+ define_singleton_method decrypt_method_name do |ciphertext, **opts|
320
+ message =
321
+ if ciphertext.nil? || (ciphertext == "" && !options[:padding])
322
+ ciphertext
323
+ else
324
+ ciphertext = Base64.decode64(ciphertext) if options[:encode]
325
+ table = activerecord ? table_name : collection_name.to_s
326
+ Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).decrypt(ciphertext)
327
+ end
328
+
329
+ unless message.nil?
330
+ case options[:type]
331
+ when :boolean
332
+ message = message == "t"
333
+ when :date
334
+ message = ActiveRecord::Type::Date.new.deserialize(message)
335
+ when :datetime
336
+ message = ActiveRecord::Type::DateTime.new.deserialize(message)
337
+ when :time
338
+ message = ActiveRecord::Type::Time.new.deserialize(message)
339
+ when :integer
340
+ message = ActiveRecord::Type::Integer.new(limit: 8).deserialize(message.unpack("q>").first)
341
+ when :float
342
+ message = ActiveRecord::Type::Float.new.deserialize(message.unpack("G").first)
343
+ when :string
344
+ message.force_encoding(Encoding::UTF_8)
345
+ when :binary
346
+ # do nothing
347
+ # decrypt returns binary string
348
+ else
349
+ type = (try(:attribute_types) || {})[name.to_s]
350
+ message = type.deserialize(message) if type
351
+ message.force_encoding(Encoding::UTF_8) if !type || type.is_a?(ActiveModel::Type::String)
352
+ end
353
+ end
354
+
355
+ message
356
+ end
357
+
358
+ if options[:migrating]
359
+ before_validation do
360
+ send("#{name}=", send(original_name)) if send("#{original_name}_changed?")
361
+ end
362
+ end
319
363
  end
320
364
  end
321
365
  end
@@ -0,0 +1,52 @@
1
+ module Lockbox
2
+ module Padding
3
+ PAD_FIRST_BYTE = "\x80".b
4
+ PAD_ZERO_BYTE = "\x00".b
5
+
6
+ # ISO/IEC 7816-4
7
+ # same as Libsodium
8
+ # https://libsodium.gitbook.io/doc/padding
9
+ # apply prior to encryption
10
+ # note: current implementation does not
11
+ # try to minimize side channels
12
+ def pad(str, size: 16)
13
+ raise ArgumentError, "Invalid size" if size < 1
14
+
15
+ str = str.dup.force_encoding(Encoding::BINARY)
16
+
17
+ pad_length = size - 1
18
+ pad_length -= str.bytesize % size
19
+
20
+ str << PAD_FIRST_BYTE
21
+ pad_length.times do
22
+ str << PAD_ZERO_BYTE
23
+ end
24
+
25
+ str
26
+ end
27
+
28
+ # note: current implementation does not
29
+ # try to minimize side channels
30
+ def unpad(str, size: 16)
31
+ raise ArgumentError, "Invalid size" if size < 1
32
+
33
+ if str.encoding != Encoding::BINARY
34
+ str = str.dup.force_encoding(Encoding::BINARY)
35
+ end
36
+
37
+ i = 1
38
+ while i <= size
39
+ case str[-i]
40
+ when PAD_ZERO_BYTE
41
+ i += 1
42
+ when PAD_FIRST_BYTE
43
+ return str[0..-(i + 1)]
44
+ else
45
+ break
46
+ end
47
+ end
48
+
49
+ raise Lockbox::PaddingError, "Invalid padding"
50
+ end
51
+ end
52
+ end
@@ -1,4 +1,4 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  class Railtie < Rails::Railtie
3
3
  initializer "lockbox" do |app|
4
4
  require "lockbox/carrier_wave_extensions" if defined?(CarrierWave)
data/lib/lockbox/utils.rb CHANGED
@@ -1,4 +1,4 @@
1
- class Lockbox
1
+ module Lockbox
2
2
  class Utils
3
3
  def self.build_box(context, options, table, attribute)
4
4
  options = options.except(:attribute, :encrypted_attribute, :migrating, :attached, :type, :encode)
@@ -21,10 +21,14 @@ class Lockbox
21
21
  record.class.respond_to?(:lockbox_attachments) ? record.class.lockbox_attachments[name.to_sym] : nil
22
22
  end
23
23
 
24
- def self.decode_key(key)
25
- if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{64,128}\z/i
24
+ def self.decode_key(key, size: 32)
25
+ if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{#{size * 2}}\z/i
26
26
  key = [key].pack("H*")
27
27
  end
28
+
29
+ raise Lockbox::Error, "Key must use binary encoding" if key.encoding != Encoding::BINARY
30
+ raise Lockbox::Error, "Key must be 32 bytes" if key.bytesize != size
31
+
28
32
  key
29
33
  end
30
34
 
@@ -37,24 +41,20 @@ class Lockbox
37
41
  box = build_box(record, options, record.class.table_name, name)
38
42
 
39
43
  case attachable
40
- when ActiveStorage::Blob
41
- raise NotImplementedError, "Not supported"
42
44
  when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
43
45
  attachable = {
44
- io: StringIO.new(box.encrypt(attachable.read)),
46
+ io: box.encrypt_io(attachable),
45
47
  filename: attachable.original_filename,
46
48
  content_type: attachable.content_type
47
49
  }
48
50
  when Hash
49
51
  attachable = {
50
- io: StringIO.new(box.encrypt(attachable[:io].read)),
52
+ io: box.encrypt_io(attachable[:io]),
51
53
  filename: attachable[:filename],
52
54
  content_type: attachable[:content_type]
53
55
  }
54
- when String
55
- raise NotImplementedError, "Not supported"
56
56
  else
57
- nil
57
+ raise NotImplementedError, "Not supported"
58
58
  end
59
59
 
60
60
  attachable
@@ -1,3 +1,3 @@
1
- class Lockbox
2
- VERSION = "0.2.5"
1
+ module Lockbox
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/lockbox.rb CHANGED
@@ -7,7 +7,9 @@ require "lockbox/box"
7
7
  require "lockbox/encryptor"
8
8
  require "lockbox/key_generator"
9
9
  require "lockbox/io"
10
+ require "lockbox/migrator"
10
11
  require "lockbox/model"
12
+ require "lockbox/padding"
11
13
  require "lockbox/utils"
12
14
  require "lockbox/version"
13
15
 
@@ -25,11 +27,13 @@ if defined?(ActiveSupport)
25
27
  end
26
28
  end
27
29
 
28
- class Lockbox
30
+ module Lockbox
29
31
  class Error < StandardError; end
30
32
  class DecryptionError < Error; end
31
33
  class PaddingError < Error; end
32
34
 
35
+ extend Padding
36
+
33
37
  class << self
34
38
  attr_accessor :default_options
35
39
  attr_writer :master_key
@@ -41,109 +45,7 @@ class Lockbox
41
45
  end
42
46
 
43
47
  def self.migrate(model, restart: false)
44
- # get fields
45
- fields = model.lockbox_attributes.select { |k, v| v[:migrating] }
46
-
47
- # get blind indexes
48
- blind_indexes = model.respond_to?(:blind_indexes) ? model.blind_indexes.select { |k, v| v[:migrating] } : {}
49
-
50
- # build relation
51
- relation = model.unscoped
52
-
53
- unless restart
54
- attributes = fields.map { |_, v| v[:encrypted_attribute] }
55
- attributes += blind_indexes.map { |_, v| v[:bidx_attribute] }
56
-
57
- if defined?(ActiveRecord::Base) && model.is_a?(ActiveRecord::Base)
58
- attributes.each_with_index do |attribute, i|
59
- relation =
60
- if i == 0
61
- relation.where(attribute => nil)
62
- else
63
- relation.or(model.unscoped.where(attribute => nil))
64
- end
65
- end
66
- end
67
- end
68
-
69
- if relation.respond_to?(:find_each)
70
- relation.find_each do |record|
71
- migrate_record(record, fields: fields, blind_indexes: blind_indexes, restart: restart)
72
- end
73
- else
74
- relation.all.each do |record|
75
- migrate_record(record, fields: fields, blind_indexes: blind_indexes, restart: restart)
76
- end
77
- end
78
- end
79
-
80
- # private
81
- def self.migrate_record(record, fields:, blind_indexes:, restart:)
82
- fields.each do |k, v|
83
- record.send("#{v[:attribute]}=", record.send(k)) if restart || !record.send(v[:encrypted_attribute])
84
- end
85
- blind_indexes.each do |k, v|
86
- record.send("compute_#{k}_bidx") if restart || !record.send(v[:bidx_attribute])
87
- end
88
- record.save(validate: false) if record.changed?
89
- end
90
-
91
- def initialize(**options)
92
- options = self.class.default_options.merge(options)
93
- previous_versions = options.delete(:previous_versions)
94
-
95
- @boxes =
96
- [Box.new(options)] +
97
- Array(previous_versions).map { |v| Box.new({key: options[:key]}.merge(v)) }
98
- end
99
-
100
- def encrypt(message, **options)
101
- message = check_string(message, "message")
102
- @boxes.first.encrypt(message, **options)
103
- end
104
-
105
- def decrypt(ciphertext, **options)
106
- ciphertext = check_string(ciphertext, "ciphertext")
107
-
108
- # ensure binary
109
- if ciphertext.encoding != Encoding::BINARY
110
- # dup to prevent mutation
111
- ciphertext = ciphertext.dup.force_encoding(Encoding::BINARY)
112
- end
113
-
114
- @boxes.each_with_index do |box, i|
115
- begin
116
- return box.decrypt(ciphertext, **options)
117
- rescue => e
118
- # returning DecryptionError instead of PaddingError
119
- # is for end-user convenience, not for security
120
- error_classes = [DecryptionError, PaddingError]
121
- error_classes << RbNaCl::LengthError if defined?(RbNaCl::LengthError)
122
- error_classes << RbNaCl::CryptoError if defined?(RbNaCl::CryptoError)
123
- if error_classes.any? { |ec| e.is_a?(ec) }
124
- raise DecryptionError, "Decryption failed" if i == @boxes.size - 1
125
- else
126
- raise e
127
- end
128
- end
129
- end
130
- end
131
-
132
- def encrypt_io(io, **options)
133
- new_io = Lockbox::IO.new(encrypt(io.read, **options))
134
- copy_metadata(io, new_io)
135
- new_io
136
- end
137
-
138
- def decrypt_io(io, **options)
139
- new_io = Lockbox::IO.new(decrypt(io.read, **options))
140
- copy_metadata(io, new_io)
141
- new_io
142
- end
143
-
144
- def decrypt_str(ciphertext, **options)
145
- message = decrypt(ciphertext, **options)
146
- message.force_encoding(Encoding::UTF_8)
48
+ Migrator.new(model).migrate(restart: restart)
147
49
  end
148
50
 
149
51
  def self.generate_key
@@ -177,70 +79,8 @@ class Lockbox
177
79
  str.unpack("H*").first
178
80
  end
179
81
 
180
- PAD_FIRST_BYTE = "\x80".b
181
- PAD_ZERO_BYTE = "\x00".b
182
-
183
- # ISO/IEC 7816-4
184
- # same as Libsodium
185
- # https://libsodium.gitbook.io/doc/padding
186
- # apply prior to encryption
187
- # note: current implementation does not
188
- # try to minimize side channels
189
- def self.pad(str, size: 16)
190
- raise ArgumentError, "Invalid size" if size < 1
191
-
192
- str = str.dup.force_encoding(Encoding::BINARY)
193
-
194
- pad_length = size - 1
195
- pad_length -= str.bytesize % size
196
-
197
- str << PAD_FIRST_BYTE
198
- pad_length.times do
199
- str << PAD_ZERO_BYTE
200
- end
201
-
202
- str
203
- end
204
-
205
- # note: current implementation does not
206
- # try to minimize side channels
207
- def self.unpad(str, size: 16)
208
- raise ArgumentError, "Invalid size" if size < 1
209
-
210
- if str.encoding != Encoding::BINARY
211
- str = str.dup.force_encoding(Encoding::BINARY)
212
- end
213
-
214
- i = 1
215
- while i <= size
216
- case str[-i]
217
- when PAD_ZERO_BYTE
218
- i += 1
219
- when PAD_FIRST_BYTE
220
- return str[0..-(i + 1)]
221
- else
222
- break
223
- end
224
- end
225
-
226
- raise Lockbox::PaddingError, "Invalid padding"
227
- end
228
-
229
- private
230
-
231
- def check_string(str, name)
232
- str = str.read if str.respond_to?(:read)
233
- raise TypeError, "can't convert #{name} to string" unless str.respond_to?(:to_str)
234
- str.to_str
235
- end
236
-
237
- def copy_metadata(source, target)
238
- target.original_filename =
239
- if source.respond_to?(:original_filename)
240
- source.original_filename
241
- elsif source.respond_to?(:path)
242
- File.basename(source.path)
243
- end
244
- target.content_type = source.content_type if source.respond_to?(:content_type)
82
+ # legacy
83
+ def self.new(**options)
84
+ Encryptor.new(**options)
245
85
  end
246
86
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: lockbox
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.5
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-12-14 00:00:00.000000000 Z
11
+ date: 2019-12-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -195,7 +195,9 @@ files:
195
195
  - lib/lockbox/encryptor.rb
196
196
  - lib/lockbox/io.rb
197
197
  - lib/lockbox/key_generator.rb
198
+ - lib/lockbox/migrator.rb
198
199
  - lib/lockbox/model.rb
200
+ - lib/lockbox/padding.rb
199
201
  - lib/lockbox/railtie.rb
200
202
  - lib/lockbox/utils.rb
201
203
  - lib/lockbox/version.rb