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 +4 -4
- data/CHANGELOG.md +10 -0
- data/README.md +40 -4
- data/lib/lockbox/active_storage_extensions.rb +1 -1
- data/lib/lockbox/aes_gcm.rb +1 -1
- data/lib/lockbox/box.rb +5 -3
- data/lib/lockbox/carrier_wave_extensions.rb +1 -1
- data/lib/lockbox/encryptor.rb +80 -1
- data/lib/lockbox/io.rb +1 -1
- data/lib/lockbox/key_generator.rb +1 -1
- data/lib/lockbox/migrator.rb +58 -0
- data/lib/lockbox/model.rb +148 -104
- data/lib/lockbox/padding.rb +52 -0
- data/lib/lockbox/railtie.rb +1 -1
- data/lib/lockbox/utils.rb +10 -10
- data/lib/lockbox/version.rb +2 -2
- data/lib/lockbox.rb +9 -169
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 6ba8d72d01b0dd6f8562bfb0980f9131e3670378299de01762cfefb6dd808857
|
4
|
+
data.tar.gz: ace013580f48bb9c0950f2393726ff8a05923e626babdc9102a89bc6bd5c5043
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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
|
-
|
5
|
+
module Lockbox
|
6
6
|
module ActiveStorageExtensions
|
7
7
|
module Attached
|
8
8
|
protected
|
data/lib/lockbox/aes_gcm.rb
CHANGED
data/lib/lockbox/box.rb
CHANGED
@@ -1,11 +1,11 @@
|
|
1
|
-
|
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
|
data/lib/lockbox/encryptor.rb
CHANGED
@@ -1,13 +1,92 @@
|
|
1
|
-
|
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
@@ -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
|
-
|
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
|
-
|
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,
|
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
|
-
|
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
|
-
|
56
|
-
|
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
|
-
|
73
|
+
options[:encode] = true unless options.key?(:encode)
|
80
74
|
|
81
|
-
|
82
|
-
|
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
|
-
|
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
|
-
|
127
|
-
|
128
|
-
|
129
|
-
|
130
|
-
|
131
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
153
|
-
|
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
|
-
|
156
|
-
|
167
|
+
# restore ciphertext as well
|
168
|
+
define_method("restore_#{name}!") do
|
169
|
+
super()
|
170
|
+
send("restore_#{encrypted_attribute}!")
|
171
|
+
end
|
157
172
|
|
158
|
-
|
159
|
-
|
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
|
-
|
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(
|
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
|
-
|
215
|
-
|
216
|
-
|
217
|
-
|
218
|
-
|
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
|
-
|
223
|
-
|
224
|
-
|
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
|
-
|
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
|
270
|
-
table =
|
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
|
-
|
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
|
data/lib/lockbox/railtie.rb
CHANGED
data/lib/lockbox/utils.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
|
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]{
|
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:
|
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:
|
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
|
-
|
57
|
+
raise NotImplementedError, "Not supported"
|
58
58
|
end
|
59
59
|
|
60
60
|
attachable
|
data/lib/lockbox/version.rb
CHANGED
@@ -1,3 +1,3 @@
|
|
1
|
-
|
2
|
-
VERSION = "0.
|
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
|
-
|
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
|
-
|
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
|
-
|
181
|
-
|
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.
|
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-
|
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
|