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 +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
|