lockbox 0.2.5 → 0.3.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.
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