lockbox 0.2.0 → 0.2.1
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 +7 -0
- data/README.md +65 -3
- data/lib/lockbox.rb +50 -0
- data/lib/lockbox/box.rb +19 -14
- data/lib/lockbox/model.rb +120 -9
- data/lib/lockbox/utils.rb +1 -1
- data/lib/lockbox/version.rb +1 -1
- metadata +16 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: af9a23ecd7c1ea6ea696daf88d7830e06dbb9972caa202fc23b214942a84e189
|
4
|
+
data.tar.gz: 5636aeb7e1f37a1b55055bd8bf50d5c0270a4abe9f2d13e62971f1257c9fe9f5
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 9d1bc707fbbe82a147920f8fc0c1f716620e9ae15fdf3d2583240b1bf32141395b401203d9851da009b7e43a896c25c93dff45ead2149289d58c89233685df37
|
7
|
+
data.tar.gz: cff72e261e485a1fbacd270cb7b5d71044d63ac7982cd1b364801578e7b8a51cdf7650ef841780bc3337ff5663b2632ccf878f6b5b00879a6768072416185bc7
|
data/CHANGELOG.md
CHANGED
data/README.md
CHANGED
@@ -1,6 +1,6 @@
|
|
1
1
|
# Lockbox
|
2
2
|
|
3
|
-
:
|
3
|
+
:package: Modern encryption for Rails
|
4
4
|
|
5
5
|
- Uses state-of-the-art algorithms
|
6
6
|
- Works with database fields, files, and strings
|
@@ -8,7 +8,7 @@
|
|
8
8
|
- Requires you to only manage a single encryption key
|
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)
|
12
12
|
|
13
13
|
[](https://travis-ci.org/ankane/lockbox)
|
14
14
|
|
@@ -72,6 +72,38 @@ User.create!(email: "hi@example.org")
|
|
72
72
|
|
73
73
|
If you need to query encrypted fields, check out [Blind Index](https://github.com/ankane/blind_index).
|
74
74
|
|
75
|
+
### Types
|
76
|
+
|
77
|
+
Specify the type of a field with:
|
78
|
+
|
79
|
+
```ruby
|
80
|
+
class User < ApplicationRecord
|
81
|
+
encrypts :born_on, type: :date
|
82
|
+
encrypts :signed_at, type: :datetime
|
83
|
+
encrypts :active, type: :boolean
|
84
|
+
encrypts :salary, type: :integer
|
85
|
+
encrypts :latitude, type: :float
|
86
|
+
encrypts :video, type: :binary
|
87
|
+
encrypts :properties, type: :json
|
88
|
+
encrypts :settings, type: :hash
|
89
|
+
end
|
90
|
+
```
|
91
|
+
|
92
|
+
**Note:** Always use a `text` or `binary` column in migrations, regardless of the type
|
93
|
+
|
94
|
+
Lockbox automatically works with serialized fields for maximum compatibility with existing code and libraries.
|
95
|
+
|
96
|
+
```ruby
|
97
|
+
class User < ApplicationRecord
|
98
|
+
serialize :properties, JSON
|
99
|
+
encrypts :properties
|
100
|
+
end
|
101
|
+
```
|
102
|
+
|
103
|
+
### Validations
|
104
|
+
|
105
|
+
Validations work as expected with the exception of uniqueness. Uniqueness validations require a [blind index](https://github.com/ankane/blind_index).
|
106
|
+
|
75
107
|
## Files
|
76
108
|
|
77
109
|
### Active Storage
|
@@ -364,7 +396,7 @@ end
|
|
364
396
|
|
365
397
|
Make sure `decryption_key` is `nil` on servers that shouldn’t decrypt.
|
366
398
|
|
367
|
-
This uses X25519 for key exchange and XSalsa20
|
399
|
+
This uses X25519 for key exchange and XSalsa20 for encryption.
|
368
400
|
|
369
401
|
## Key Separation
|
370
402
|
|
@@ -402,6 +434,36 @@ end
|
|
402
434
|
|
403
435
|
**Note:** KMS Encrypted’s key rotation does not know to rotate encrypted files, so avoid calling `record.rotate_kms_key!` on models with file uploads for now.
|
404
436
|
|
437
|
+
## Padding
|
438
|
+
|
439
|
+
Add padding to conceal the exact length of messages.
|
440
|
+
|
441
|
+
```ruby
|
442
|
+
Lockbox.new(padding: true)
|
443
|
+
```
|
444
|
+
|
445
|
+
The block size for padding is 16 bytes by default. Change this with:
|
446
|
+
|
447
|
+
```ruby
|
448
|
+
Lockbox.new(padding: 32) # bytes
|
449
|
+
```
|
450
|
+
|
451
|
+
## Reference
|
452
|
+
|
453
|
+
Set default options in an initializer with:
|
454
|
+
|
455
|
+
```ruby
|
456
|
+
Lockbox.default_options = {algorithm: "xsalsa20"}
|
457
|
+
```
|
458
|
+
|
459
|
+
For database fields, encrypted data is encoded in Base64. If you use `binary` columns instead of `text` columns, set:
|
460
|
+
|
461
|
+
```ruby
|
462
|
+
class User < ApplicationRecord
|
463
|
+
encrypts :email, encode: false
|
464
|
+
end
|
465
|
+
```
|
466
|
+
|
405
467
|
## Compatibility
|
406
468
|
|
407
469
|
It’s easy to read encrypted data in another language if needed.
|
data/lib/lockbox.rb
CHANGED
@@ -22,6 +22,7 @@ end
|
|
22
22
|
class Lockbox
|
23
23
|
class Error < StandardError; end
|
24
24
|
class DecryptionError < Error; end
|
25
|
+
class PaddingError < Error; end
|
25
26
|
|
26
27
|
class << self
|
27
28
|
attr_accessor :default_options
|
@@ -139,6 +140,55 @@ class Lockbox
|
|
139
140
|
str.unpack("H*").first
|
140
141
|
end
|
141
142
|
|
143
|
+
PAD_FIRST_BYTE = "\x80".b
|
144
|
+
PAD_ZERO_BYTE = "\x00".b
|
145
|
+
|
146
|
+
# ISO/IEC 7816-4
|
147
|
+
# same as Libsodium
|
148
|
+
# https://libsodium.gitbook.io/doc/padding
|
149
|
+
# apply prior to encryption
|
150
|
+
# note: current implementation does not
|
151
|
+
# try to minimize side channels
|
152
|
+
def self.pad(str, size: 16)
|
153
|
+
raise ArgumentError, "Invalid size" if size < 1
|
154
|
+
|
155
|
+
str = str.dup.force_encoding(Encoding::BINARY)
|
156
|
+
|
157
|
+
pad_length = size - 1
|
158
|
+
pad_length -= str.bytesize % size
|
159
|
+
|
160
|
+
str << PAD_FIRST_BYTE
|
161
|
+
pad_length.times do
|
162
|
+
str << PAD_ZERO_BYTE
|
163
|
+
end
|
164
|
+
|
165
|
+
str
|
166
|
+
end
|
167
|
+
|
168
|
+
# note: current implementation does not
|
169
|
+
# try to minimize side channels
|
170
|
+
def self.unpad(str, size: 16)
|
171
|
+
raise ArgumentError, "Invalid size" if size < 1
|
172
|
+
|
173
|
+
if str.encoding != Encoding::BINARY
|
174
|
+
str = str.dup.force_encoding(Encoding::BINARY)
|
175
|
+
end
|
176
|
+
|
177
|
+
i = 1
|
178
|
+
while i <= size
|
179
|
+
case str[-i]
|
180
|
+
when PAD_ZERO_BYTE
|
181
|
+
i += 1
|
182
|
+
when PAD_FIRST_BYTE
|
183
|
+
return str[0..-(i + 1)]
|
184
|
+
else
|
185
|
+
break
|
186
|
+
end
|
187
|
+
end
|
188
|
+
|
189
|
+
raise Lockbox::PaddingError, "Invalid padding"
|
190
|
+
end
|
191
|
+
|
142
192
|
private
|
143
193
|
|
144
194
|
def check_string(str, name)
|
data/lib/lockbox/box.rb
CHANGED
@@ -2,7 +2,7 @@ require "securerandom"
|
|
2
2
|
|
3
3
|
class Lockbox
|
4
4
|
class Box
|
5
|
-
def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil)
|
5
|
+
def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil, padding: false)
|
6
6
|
raise ArgumentError, "Cannot pass both key and public/private key" if key && (encryption_key || decryption_key)
|
7
7
|
|
8
8
|
key = Lockbox::Utils.decode_key(key) if key
|
@@ -34,9 +34,11 @@ class Lockbox
|
|
34
34
|
end
|
35
35
|
|
36
36
|
@algorithm = algorithm
|
37
|
+
@padding = padding == true ? 16 : padding
|
37
38
|
end
|
38
39
|
|
39
40
|
def encrypt(message, associated_data: nil)
|
41
|
+
message = Lockbox.pad(message, size: @padding) if @padding
|
40
42
|
case @algorithm
|
41
43
|
when "hybrid"
|
42
44
|
raise ArgumentError, "No public key set" unless @encryption_box
|
@@ -54,19 +56,22 @@ class Lockbox
|
|
54
56
|
end
|
55
57
|
|
56
58
|
def decrypt(ciphertext, associated_data: nil)
|
57
|
-
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
59
|
+
message =
|
60
|
+
case @algorithm
|
61
|
+
when "hybrid"
|
62
|
+
raise ArgumentError, "No private key set" unless @decryption_box
|
63
|
+
raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
|
64
|
+
nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
|
65
|
+
@decryption_box.decrypt(nonce, ciphertext)
|
66
|
+
when "xsalsa20"
|
67
|
+
nonce, ciphertext = extract_nonce(@box, ciphertext)
|
68
|
+
@box.decrypt(nonce, ciphertext)
|
69
|
+
else
|
70
|
+
nonce, ciphertext = extract_nonce(@box, ciphertext)
|
71
|
+
@box.decrypt(nonce, ciphertext, associated_data)
|
72
|
+
end
|
73
|
+
message = Lockbox.unpad(message, size: @padding) if @padding
|
74
|
+
message
|
70
75
|
end
|
71
76
|
|
72
77
|
# protect key for xchacha20 and hybrid
|
data/lib/lockbox/model.rb
CHANGED
@@ -31,7 +31,37 @@ class Lockbox
|
|
31
31
|
end
|
32
32
|
end
|
33
33
|
|
34
|
-
def encrypts(*attributes, **options)
|
34
|
+
def encrypts(*attributes, encode: true, **options)
|
35
|
+
# support objects
|
36
|
+
# case options[:type]
|
37
|
+
# when Date
|
38
|
+
# options[:type] = :date
|
39
|
+
# when Time
|
40
|
+
# options[:type] = :datetime
|
41
|
+
# when JSON
|
42
|
+
# options[:type] = :json
|
43
|
+
# when Hash
|
44
|
+
# options[:type] = :hash
|
45
|
+
# when String
|
46
|
+
# options[:type] = :string
|
47
|
+
# when Integer
|
48
|
+
# options[:type] = :integer
|
49
|
+
# when Float
|
50
|
+
# options[:type] = :float
|
51
|
+
# end
|
52
|
+
|
53
|
+
raise ArgumentError, "Unknown type: #{options[:type]}" unless [nil, :string, :boolean, :date, :datetime, :integer, :float, :binary, :json, :hash].include?(options[:type])
|
54
|
+
|
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
|
64
|
+
|
35
65
|
attributes.each do |name|
|
36
66
|
# add default options
|
37
67
|
encrypted_attribute = "#{name}_ciphertext"
|
@@ -71,7 +101,7 @@ class Lockbox
|
|
71
101
|
end
|
72
102
|
|
73
103
|
raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name]
|
74
|
-
@lockbox_attributes[original_name] = options
|
104
|
+
@lockbox_attributes[original_name] = options.merge(encode: encode)
|
75
105
|
|
76
106
|
if @lockbox_attributes.size == 1
|
77
107
|
def serializable_hash(options = nil)
|
@@ -89,11 +119,63 @@ class Lockbox
|
|
89
119
|
end
|
90
120
|
"#<#{self.class} #{inspection.join(", ")}>"
|
91
121
|
end
|
122
|
+
|
123
|
+
# needed for in-place modifications
|
124
|
+
# assigned attributes are encrypted on assignment
|
125
|
+
# and then again here
|
126
|
+
before_save do
|
127
|
+
self.class.lockbox_attributes.each do |_, lockbox_attribute|
|
128
|
+
attribute = lockbox_attribute[:attribute]
|
129
|
+
|
130
|
+
if changes.include?(attribute) && self.class.attribute_types[attribute].is_a?(ActiveRecord::Type::Serialized)
|
131
|
+
send("#{attribute}=", send(attribute))
|
132
|
+
end
|
133
|
+
end
|
134
|
+
end
|
92
135
|
end
|
93
136
|
|
94
|
-
|
137
|
+
serialize name, JSON if options[:type] == :json
|
138
|
+
serialize name, Hash if options[:type] == :hash
|
139
|
+
|
140
|
+
attribute name, attribute_type
|
95
141
|
|
96
142
|
define_method("#{name}=") do |message|
|
143
|
+
original_message = message
|
144
|
+
|
145
|
+
unless message.nil?
|
146
|
+
case options[:type]
|
147
|
+
when :boolean
|
148
|
+
message = ActiveRecord::Type::Boolean.new.serialize(message)
|
149
|
+
message = nil if message == "" # for Active Record < 5.2
|
150
|
+
message = message ? "t" : "f" unless message.nil?
|
151
|
+
when :date
|
152
|
+
message = ActiveRecord::Type::Date.new.serialize(message)
|
153
|
+
# strftime should be more stable than to_s(:db)
|
154
|
+
message = message.strftime("%Y-%m-%d") unless message.nil?
|
155
|
+
when :datetime
|
156
|
+
message = ActiveRecord::Type::DateTime.new.serialize(message)
|
157
|
+
message = nil unless message.respond_to?(:iso8601) # for Active Record < 5.2
|
158
|
+
message = message.iso8601(9) unless message.nil?
|
159
|
+
when :integer
|
160
|
+
message = ActiveRecord::Type::Integer.new(limit: 8).serialize(message)
|
161
|
+
message = 0 if message.nil?
|
162
|
+
# signed 64-bit integer, big endian
|
163
|
+
message = [message].pack("q>")
|
164
|
+
when :float
|
165
|
+
message = ActiveRecord::Type::Float.new.serialize(message)
|
166
|
+
# double precision, big endian
|
167
|
+
message = [message].pack("G") unless message.nil?
|
168
|
+
when :string, :binary
|
169
|
+
# do nothing
|
170
|
+
# encrypt will convert to binary
|
171
|
+
else
|
172
|
+
type = self.class.attribute_types[name.to_s]
|
173
|
+
if type.is_a?(ActiveRecord::Type::Serialized)
|
174
|
+
message = type.serialize(message)
|
175
|
+
end
|
176
|
+
end
|
177
|
+
end
|
178
|
+
|
97
179
|
# decrypt first for dirty tracking
|
98
180
|
# don't raise error if can't decrypt previous
|
99
181
|
begin
|
@@ -103,28 +185,54 @@ class Lockbox
|
|
103
185
|
end
|
104
186
|
|
105
187
|
ciphertext =
|
106
|
-
if message.nil? || message == ""
|
188
|
+
if message.nil? || (message == "" && !options[:padding])
|
107
189
|
message
|
108
190
|
else
|
109
191
|
self.class.send(class_method_name, message, context: self)
|
110
192
|
end
|
111
193
|
send("#{encrypted_attribute}=", ciphertext)
|
112
194
|
|
113
|
-
super(
|
195
|
+
super(original_message)
|
114
196
|
end
|
115
197
|
|
116
198
|
define_method(name) do
|
117
199
|
message = super()
|
200
|
+
|
118
201
|
unless message
|
119
202
|
ciphertext = send(encrypted_attribute)
|
120
203
|
message =
|
121
|
-
if ciphertext.nil? || ciphertext == ""
|
204
|
+
if ciphertext.nil? || (ciphertext == "" && !options[:padding])
|
122
205
|
ciphertext
|
123
206
|
else
|
124
|
-
|
125
|
-
Lockbox::Utils.build_box(self, options, self.class.table_name, encrypted_attribute).decrypt(
|
207
|
+
ciphertext = Base64.decode64(ciphertext) if encode
|
208
|
+
Lockbox::Utils.build_box(self, options, self.class.table_name, encrypted_attribute).decrypt(ciphertext)
|
126
209
|
end
|
127
210
|
|
211
|
+
unless message.nil?
|
212
|
+
case options[:type]
|
213
|
+
when :boolean
|
214
|
+
message = message == "t"
|
215
|
+
when :date
|
216
|
+
message = ActiveRecord::Type::Date.new.deserialize(message)
|
217
|
+
when :datetime
|
218
|
+
message = ActiveRecord::Type::DateTime.new.deserialize(message)
|
219
|
+
when :integer
|
220
|
+
message = ActiveRecord::Type::Integer.new(limit: 8).deserialize(message.unpack("q>").first)
|
221
|
+
when :float
|
222
|
+
message = ActiveRecord::Type::Float.new.deserialize(message.unpack("G").first)
|
223
|
+
when :string
|
224
|
+
message = message.encode(Encoding::UTF_8)
|
225
|
+
when :binary
|
226
|
+
# do nothing
|
227
|
+
# decrypt returns binary string
|
228
|
+
else
|
229
|
+
type = self.class.attribute_types[name.to_s]
|
230
|
+
if type.is_a?(ActiveRecord::Type::Serialized)
|
231
|
+
message = type.deserialize(message)
|
232
|
+
end
|
233
|
+
end
|
234
|
+
end
|
235
|
+
|
128
236
|
# set previous attribute on first decrypt
|
129
237
|
@attributes[name.to_s].instance_variable_set("@value_before_type_cast", message)
|
130
238
|
|
@@ -135,12 +243,15 @@ class Lockbox
|
|
135
243
|
raw_write_attribute(name, message)
|
136
244
|
end
|
137
245
|
end
|
246
|
+
|
138
247
|
message
|
139
248
|
end
|
140
249
|
|
141
250
|
# for fixtures
|
142
251
|
define_singleton_method class_method_name do |message, **opts|
|
143
|
-
|
252
|
+
ciphertext = Lockbox::Utils.build_box(opts[:context], options, table_name, encrypted_attribute).encrypt(message)
|
253
|
+
ciphertext = Base64.strict_encode64(ciphertext) if encode
|
254
|
+
ciphertext
|
144
255
|
end
|
145
256
|
end
|
146
257
|
end
|
data/lib/lockbox/utils.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
class Lockbox
|
2
2
|
class Utils
|
3
3
|
def self.build_box(context, options, table, attribute)
|
4
|
-
options = options.except(:attribute, :encrypted_attribute, :migrating, :attached)
|
4
|
+
options = options.except(:attribute, :encrypted_attribute, :migrating, :attached, :type, :encode)
|
5
5
|
options.each do |k, v|
|
6
6
|
if v.is_a?(Proc)
|
7
7
|
options[k] = context.instance_exec(&v) if v.respond_to?(:call)
|
data/lib/lockbox/version.rb
CHANGED
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.
|
4
|
+
version: 0.2.1
|
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-07-
|
11
|
+
date: 2019-07-23 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -122,6 +122,20 @@ dependencies:
|
|
122
122
|
- - ">="
|
123
123
|
- !ruby/object:Gem::Version
|
124
124
|
version: '0'
|
125
|
+
- !ruby/object:Gem::Dependency
|
126
|
+
name: pg
|
127
|
+
requirement: !ruby/object:Gem::Requirement
|
128
|
+
requirements:
|
129
|
+
- - ">="
|
130
|
+
- !ruby/object:Gem::Version
|
131
|
+
version: '0'
|
132
|
+
type: :development
|
133
|
+
prerelease: false
|
134
|
+
version_requirements: !ruby/object:Gem::Requirement
|
135
|
+
requirements:
|
136
|
+
- - ">="
|
137
|
+
- !ruby/object:Gem::Version
|
138
|
+
version: '0'
|
125
139
|
- !ruby/object:Gem::Dependency
|
126
140
|
name: benchmark-ips
|
127
141
|
requirement: !ruby/object:Gem::Requirement
|