lockbox 0.1.1 → 0.2.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: e4dcdb1c1115e0712d5d65dd8302c3d575a74a5456dd245692970d080d221fa0
4
- data.tar.gz: 2278b9fe032f0159525a48a9a9c694c28a80bcbac371039a97729e6ec61087d7
3
+ metadata.gz: b4e87cf5f769166cd6cf534608a6877e5cd9f11931441307d9eca11a3d2c4d18
4
+ data.tar.gz: c52a2bded1142c80918f79de4317ec948c576c1168fd699dac0f69c7d2ca9b47
5
5
  SHA512:
6
- metadata.gz: 8afe130b6c4231667ea26f1ece4a25e4a577a535ec4930a5f85ac2b53b7b508fe77ec9e4b1720f1aab3f1fe513b03576646d8b24cf27bee3a6c5626c9484c35e
7
- data.tar.gz: 6662d25470d89b327b2cddf45d9467cd7d2bd8e3e8664140a71c7ea4cb10f13a156d21c01c17b26f0c2dca928f6f3f0010403ba96e77349442ab185def552d10
6
+ metadata.gz: 9a92f516956fe3d4feb26cc338bf25a5048fc46916bc4c4722a12028b73697e467ceae118733f1dce778a1585e3a4e97fbf9073c3c80fa7d5340d1e0676cb02c
7
+ data.tar.gz: 60684645d7645b1b66a66a9b5393db04eb690095fc8bfbe16de50e8770225afe46e2236045cf4c0985a9e7b0e86aa7da16294804e0e6cebe2325193761d9965e
@@ -1,3 +1,10 @@
1
+ ## 0.2.0
2
+
3
+ - Added `encrypts` method for database fields
4
+ - Added `encrypts_attached` method
5
+ - Added `generate_key` method
6
+ - Added support for XSalsa20
7
+
1
8
  ## 0.1.1
2
9
 
3
10
  - Added support for hybrid cryptography
data/README.md CHANGED
@@ -1,12 +1,14 @@
1
1
  # Lockbox
2
2
 
3
- :lock: File encryption for Ruby and Rails
3
+ :lock: Modern encryption for Rails
4
4
 
5
- - Supports Active Storage and CarrierWave
6
- - Uses AES-GCM by default for [authenticated encryption](https://tonyarcieri.com/all-the-crypto-code-youve-ever-written-is-probably-broken)
7
- - Makes key rotation easy
5
+ - Uses state-of-the-art algorithms
6
+ - Works with database fields, files, and strings
7
+ - Stores encrypted data in a single field
8
+ - Requires you to only manage a single encryption key
9
+ - Makes migrating existing data and key rotation easy
8
10
 
9
- Check out [this post](https://ankane.org/sensitive-data-rails) for more info on securing sensitive data with Rails
11
+ Check out [this post](https://ankane.org/modern-encryption-rails) for more info on its design, and [this post](https://ankane.org/sensitive-data-rails) for more info on securing sensitive data with Rails
10
12
 
11
13
  [![Build Status](https://travis-ci.org/ankane/lockbox.svg?branch=master)](https://travis-ci.org/ankane/lockbox)
12
14
 
@@ -23,41 +25,63 @@ gem 'lockbox'
23
25
  Generate an encryption key
24
26
 
25
27
  ```ruby
26
- SecureRandom.hex(32)
28
+ Lockbox.generate_key
27
29
  ```
28
30
 
29
31
  Store the key with your other secrets. This is typically Rails credentials or an environment variable ([dotenv](https://github.com/bkeepers/dotenv) is great for this). Be sure to use different keys in development and production. Keys don’t need to be hex-encoded, but it’s often easier to store them this way.
30
32
 
33
+ Set the following environment variable with your key (you can use this one in development)
34
+
35
+ ```sh
36
+ LOCKBOX_MASTER_KEY=0000000000000000000000000000000000000000000000000000000000000000
37
+ ```
38
+
39
+ or create `config/initializers/lockbox.rb` with something like
40
+
41
+ ```ruby
42
+ Lockbox.master_key = Rails.application.credentials.lockbox_master_key
43
+ ```
44
+
31
45
  Alternatively, you can use a [key management service](#key-management) to manage your keys.
32
46
 
33
- ## Files
47
+ ## Database Fields
34
48
 
35
- Create a box
49
+ Create a migration with:
36
50
 
37
51
  ```ruby
38
- box = Lockbox.new(key: key)
52
+ class AddEmailCiphertextToUsers < ActiveRecord::Migration[5.2]
53
+ def change
54
+ add_column :users, :email_ciphertext, :text
55
+ end
56
+ end
39
57
  ```
40
58
 
41
- Encrypt
59
+ Add to your model:
42
60
 
43
61
  ```ruby
44
- ciphertext = box.encrypt(File.binread("license.jpg"))
62
+ class User < ApplicationRecord
63
+ encrypts :email
64
+ end
45
65
  ```
46
66
 
47
- Decrypt
67
+ You can use `email` just like any other attribute.
48
68
 
49
69
  ```ruby
50
- box.decrypt(ciphertext)
70
+ User.create!(email: "hi@example.org")
51
71
  ```
52
72
 
53
- ## Active Storage
73
+ If you need to query encrypted fields, check out [Blind Index](https://github.com/ankane/blind_index).
74
+
75
+ ## Files
76
+
77
+ ### Active Storage
54
78
 
55
79
  Add to your model:
56
80
 
57
81
  ```ruby
58
82
  class User < ApplicationRecord
59
83
  has_one_attached :license
60
- attached_encrypted :license, key: key
84
+ encrypts_attached :license
61
85
  end
62
86
  ```
63
87
 
@@ -66,7 +90,7 @@ Works with multiple attachments as well.
66
90
  ```ruby
67
91
  class User < ApplicationRecord
68
92
  has_many_attached :documents
69
- attached_encrypted :documents, key: key
93
+ encrypts_attached :documents
70
94
  end
71
95
  ```
72
96
 
@@ -75,43 +99,130 @@ There are a few limitations to be aware of:
75
99
  - Metadata like image width and height are not extracted when encrypted
76
100
  - Direct uploads cannot be encrypted
77
101
 
78
- ## CarrierWave
102
+ To serve encrypted files, use a controller action.
103
+
104
+ ```ruby
105
+ def license
106
+ send_data @user.license.download, type: @user.license.content_type
107
+ end
108
+ ```
109
+
110
+ **Note:** With Rails 6, attachments are not encrypted with:
111
+
112
+ ```ruby
113
+ User.create!(avatar: params[:avatar])
114
+ ```
115
+
116
+ Until this is addressed, use:
117
+
118
+ ```ruby
119
+ user = User.new
120
+ user.attach(params[:avatar])
121
+ user.save!
122
+ ```
123
+
124
+ ### CarrierWave
79
125
 
80
126
  Add to your uploader:
81
127
 
82
128
  ```ruby
83
129
  class LicenseUploader < CarrierWave::Uploader::Base
84
- encrypt key: key
130
+ encrypt
85
131
  end
86
132
  ```
87
133
 
88
134
  Encryption is applied to all versions after processing.
89
135
 
90
- ## Serving Files
91
-
92
136
  To serve encrypted files, use a controller action.
93
137
 
94
138
  ```ruby
95
139
  def license
96
- send_data @user.license.download, type: @user.license.content_type
140
+ send_data @user.license.read, type: @user.license.content_type
141
+ end
142
+ ```
143
+
144
+ ### Local Files
145
+
146
+ Read the file as a binary string
147
+
148
+ ```ruby
149
+ message = File.binread("file.txt")
150
+ ```
151
+
152
+ Then follow the instructions for encrypting a string below.
153
+
154
+ ## Strings
155
+
156
+ Create a box
157
+
158
+ ```ruby
159
+ box = Lockbox.new(key: key)
160
+ ```
161
+
162
+ Encrypt
163
+
164
+ ```ruby
165
+ ciphertext = box.encrypt(message)
166
+ ```
167
+
168
+ Decrypt
169
+
170
+ ```ruby
171
+ box.decrypt(ciphertext)
172
+ ```
173
+
174
+ ## Migrating Existing Data
175
+
176
+ Lockbox makes it easy to encrypt an existing column. Add a new column for the ciphertext, then add to your model:
177
+
178
+ ```ruby
179
+ class User < ApplicationRecord
180
+ encrypts :email, migrating: true
181
+ end
182
+ ```
183
+
184
+ Backfill the data in the Rails console:
185
+
186
+ ```ruby
187
+ Lockbox.migrate(User)
188
+ ```
189
+
190
+ Then update the model to the desired state:
191
+
192
+ ```ruby
193
+ class User < ApplicationRecord
194
+ encrypts :email
195
+
196
+ # remove this line after dropping email column
197
+ self.ignored_columns = ["email"]
97
198
  end
98
199
  ```
99
200
 
100
- Use `read` instead of `download` for CarrierWave.
201
+ Finally, drop the unencrypted column.
101
202
 
102
203
  ## Key Rotation
103
204
 
104
205
  To make key rotation easy, you can pass previous versions of keys that can decrypt.
105
206
 
207
+ For Active Record, use:
208
+
106
209
  ```ruby
107
- Lockbox.new(key: key, previous_versions: [{key: previous_key}])
210
+ class User < ApplicationRecord
211
+ encrypts :email, previous_versions: [{key: previous_key}]
212
+ end
213
+ ```
214
+
215
+ To rotate, use:
216
+
217
+ ```ruby
218
+ user.update!(email: user.email)
108
219
  ```
109
220
 
110
221
  For Active Storage use:
111
222
 
112
223
  ```ruby
113
224
  class User < ApplicationRecord
114
- attached_encrypted :license, key: key, previous_versions: [{key: previous_key}]
225
+ encrypts_attached :license, previous_versions: [{key: previous_key}]
115
226
  end
116
227
  ```
117
228
 
@@ -125,7 +236,7 @@ For CarrierWave, use:
125
236
 
126
237
  ```ruby
127
238
  class LicenseUploader < CarrierWave::Uploader::Base
128
- encrypt key: key, previous_versions: [{key: previous_key}]
239
+ encrypt previous_versions: [{key: previous_key}]
129
240
  end
130
241
  ```
131
242
 
@@ -135,55 +246,108 @@ To rotate existing files, use:
135
246
  user.license.rotate_encryption!
136
247
  ```
137
248
 
249
+ For strings, use:
250
+
251
+ ```ruby
252
+ Lockbox.new(key: key, previous_versions: [{key: previous_key}])
253
+ ```
254
+
255
+ ## Fixtures
256
+
257
+ You can use encrypted attributes in fixtures with:
258
+
259
+ ```yml
260
+ test_user:
261
+ email_ciphertext: <%= User.generate_email_ciphertext("secret").inspect %>
262
+ ```
263
+
264
+ Be sure to include the `inspect` at the end or it won’t be encoded properly in YAML.
265
+
138
266
  ## Algorithms
139
267
 
140
268
  ### AES-GCM
141
269
 
142
- The default algorithm is AES-GCM with a 256-bit key. Rotate the key every 2 billion files to minimize the chance of a [nonce collision](https://www.cryptologie.net/article/402/is-symmetric-security-solved/), which will leak the key.
270
+ This is the default algorithm. Rotate the key every 2 billion encryptions to minimize the chance of a [nonce collision](https://www.cryptologie.net/article/402/is-symmetric-security-solved/), which will expose the key.
271
+
272
+ ### XSalsa20
273
+
274
+ You can also use XSalsa20, which uses an extended nonce so you don’t have to worry about nonce collisions. First, [install Libsodium](https://github.com/crypto-rb/rbnacl/wiki/Installing-libsodium). For Homebrew, use:
143
275
 
144
- ### XChaCha20
276
+ ```sh
277
+ brew install libsodium
278
+ ```
145
279
 
146
- [Install Libsodium](https://github.com/crypto-rb/rbnacl/wiki/Installing-libsodium) >= 1.0.12 and add [rbnacl](https://github.com/crypto-rb/rbnacl) to your application’s Gemfile:
280
+ And add to your Gemfile:
147
281
 
148
282
  ```ruby
149
283
  gem 'rbnacl'
150
284
  ```
151
285
 
152
- Then pass the `algorithm` option:
286
+ Then add to your model:
153
287
 
154
- ```ruby
155
- # files
156
- box = Lockbox.new(key: key, algorithm: "xchacha20")
157
288
 
158
- # Active Storage
289
+ ```ruby
159
290
  class User < ApplicationRecord
160
- attached_encrypted :license, key: key, algorithm: "xchacha20"
161
- end
162
-
163
- # CarrierWave
164
- class LicenseUploader < CarrierWave::Uploader::Base
165
- encrypt key: key, algorithm: "xchacha20"
291
+ encrypts :email, algorithm: "xsalsa20"
166
292
  end
167
293
  ```
168
294
 
169
295
  Make it the default with:
170
296
 
171
297
  ```ruby
172
- Lockbox.default_options = {algorithm: "xchacha20"}
298
+ Lockbox.default_options = {algorithm: "xsalsa20"}
173
299
  ```
174
300
 
175
301
  You can also pass an algorithm to `previous_versions` for key rotation.
176
302
 
177
- ## Hybrid Cryptography
303
+ #### XSalsa20 Deployment
178
304
 
179
- [Hybrid cryptography](https://en.wikipedia.org/wiki/Hybrid_cryptosystem) allows servers to encrypt data without being able to decrypt it.
305
+ ##### Heroku
180
306
 
181
- [Install Libsodium](https://github.com/crypto-rb/rbnacl/wiki/Installing-libsodium) and add [rbnacl](https://github.com/crypto-rb/rbnacl) to your application’s Gemfile:
307
+ Heroku [comes with libsodium](https://devcenter.heroku.com/articles/stack-packages) preinstalled.
182
308
 
183
- ```ruby
184
- gem 'rbnacl'
309
+ ##### Ubuntu
310
+
311
+ For Ubuntu 16.04, use:
312
+
313
+ ```sh
314
+ sudo apt-get install libsodium18
315
+ ```
316
+
317
+ For Ubuntu 18.04, use:
318
+
319
+ ```sh
320
+ sudo apt-get install libsodium23
185
321
  ```
186
322
 
323
+ ##### Travis CI
324
+
325
+ On Xenial, add to `.travis.yml`:
326
+
327
+ ```yml
328
+ addons:
329
+ apt:
330
+ packages:
331
+ - libsodium18
332
+ ```
333
+
334
+ ##### CircleCI
335
+
336
+ Add a step to `.circleci/config.yml`:
337
+
338
+ ```yml
339
+ - run:
340
+ name: install Libsodium
341
+ command: |
342
+ sudo apt-get install -y libsodium18
343
+ ```
344
+
345
+ ## Hybrid Cryptography
346
+
347
+ [Hybrid cryptography](https://en.wikipedia.org/wiki/Hybrid_cryptosystem) allows servers to encrypt data without being able to decrypt it.
348
+
349
+ Follow the instructions above for installing Libsodium and including `rbnacl` in your Gemfile.
350
+
187
351
  Generate a key pair with:
188
352
 
189
353
  ```ruby
@@ -193,17 +357,8 @@ Lockbox.generate_key_pair
193
357
  Store the keys with your other secrets. Then use:
194
358
 
195
359
  ```ruby
196
- # files
197
- box = Lockbox.new(algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key)
198
-
199
- # Active Storage
200
360
  class User < ApplicationRecord
201
- attached_encrypted :license, algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
202
- end
203
-
204
- # CarrierWave
205
- class LicenseUploader < CarrierWave::Uploader::Base
206
- encrypt algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
361
+ encrypts :email, algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
207
362
  end
208
363
  ```
209
364
 
@@ -211,15 +366,29 @@ Make sure `decryption_key` is `nil` on servers that shouldn’t decrypt.
211
366
 
212
367
  This uses X25519 for key exchange and XSalsa20-Poly1305 for encryption.
213
368
 
369
+ ## Key Separation
370
+
371
+ The master key is used to generate unique keys for each column. This technique comes from [CipherSweet](https://ciphersweet.paragonie.com/internals/key-hierarchy). The table name and column name are both used in this process. If you need to rename a table with encrypted columns, or an encrypted column itself, get the key:
372
+
373
+ ```ruby
374
+ Lockbox.attribute_key(table: "users", attribute: "email_ciphertext")
375
+ ```
376
+
377
+ And set it directly before renaming:
378
+
379
+ ```ruby
380
+ class User < ApplicationRecord
381
+ encrypts :email, key: ENV["USER_EMAIL_ENCRYPTION_KEY"]
382
+ end
383
+ ```
384
+
214
385
  ## Key Management
215
386
 
216
387
  You can use a key management service to manage your keys with [KMS Encrypted](https://github.com/ankane/kms_encrypted).
217
388
 
218
- For Active Storage, use:
219
-
220
389
  ```ruby
221
390
  class User < ApplicationRecord
222
- attached_encrypted :license, key: :kms_key
391
+ encrypts :email, key: :kms_key
223
392
  end
224
393
  ```
225
394
 
@@ -235,70 +404,121 @@ end
235
404
 
236
405
  ## Compatibility
237
406
 
238
- It’s easy to read encrypted files in another language if needed.
407
+ It’s easy to read encrypted data in another language if needed.
239
408
 
240
- Here are [some examples](docs/Compatibility.md).
241
-
242
- The format for AES-GCM is:
409
+ For AES-GCM, the format is:
243
410
 
244
411
  - nonce (IV) - 12 bytes
245
412
  - ciphertext - variable length
246
413
  - authentication tag - 16 bytes
247
414
 
248
- For XChaCha20, use the appropriate [Libsodium library](https://libsodium.gitbook.io/doc/bindings_for_other_languages).
415
+ Here are [some examples](docs/Compatibility.md).
249
416
 
250
- ## Database Fields
417
+ For XSalsa20, use the appropriate [Libsodium library](https://libsodium.gitbook.io/doc/bindings_for_other_languages).
251
418
 
252
- Lockbox can also be used with [attr_encrypted](https://github.com/attr-encrypted/attr_encrypted) for database fields. This gives you:
419
+ ## Migrating from Another Library
253
420
 
254
- 1. Easy key rotation
255
- 2. XChaCha20
256
- 3. Hybrid cryptography
257
- 4. No need for separate IV columns
421
+ Lockbox makes it easy to migrate from another library. The example below uses `attr_encrypted` but the same approach should work for any library.
258
422
 
259
- Add to your Gemfile:
423
+ Let’s suppose your model looks like this:
260
424
 
261
425
  ```ruby
262
- gem 'attr_encrypted'
426
+ class User < ApplicationRecord
427
+ attr_encrypted :name, key: key
428
+ attr_encrypted :email, key: key
429
+ end
263
430
  ```
264
431
 
265
- Create a migration to add a new column for the encrypted data. We don’t need a separate IV column, as this will be included in the encrypted data.
432
+ Create a migration with:
266
433
 
267
434
  ```ruby
268
- class AddEncryptedPhoneToUsers < ActiveRecord::Migration[5.2]
435
+ class MigrateToLockbox < ActiveRecord::Migration[5.2]
269
436
  def change
270
- add_column :users, :encrypted_phone, :string
437
+ add_column :users, :name_ciphertext, :text
438
+ add_column :users, :email_ciphertext, :text
271
439
  end
272
440
  end
273
441
  ```
274
442
 
275
- All Lockbox options are supported.
443
+ And add `encrypts` to your model with the `migrating` option:
276
444
 
277
445
  ```ruby
278
446
  class User < ApplicationRecord
279
- attr_encrypted :phone, encryptor: Lockbox::Encryptor, key: key, algorithm: "xchacha20", previous_versions: [{key: previous_key}]
280
-
281
- attribute :encrypted_phone_iv # prevent attr_encrypted error
447
+ encrypts :name, :email, migrating: true
282
448
  end
283
449
  ```
284
450
 
285
- For hybrid cryptography, use:
451
+ Then run:
452
+
453
+ ```ruby
454
+ Lockbox.migrate(User)
455
+ ```
456
+
457
+ Once all records are migrated, remove the `migrating` option and the previous model code (the `attr_encrypted` methods in this example).
286
458
 
287
459
  ```ruby
288
460
  class User < ApplicationRecord
289
- attr_encrypted :phone, encryptor: Lockbox::Encryptor, algorithm: "hybrid", encryption_key: encryption_key, decryption_key: decryption_key
461
+ encrypts :name, :email
462
+ end
463
+ ```
464
+
465
+ Then remove the previous gem from your Gemfile and drop its columns.
466
+
467
+ ```ruby
468
+ class RemovePreviousEncryptedColumns < ActiveRecord::Migration[5.2]
469
+ def change
470
+ remove_column :users, :encrypted_name, :text
471
+ remove_column :users, :encrypted_name_iv, :text
472
+ remove_column :users, :encrypted_email, :text
473
+ remove_column :users, :encrypted_email_iv, :text
474
+ end
475
+ end
476
+ ```
477
+
478
+ ## Upgrading
479
+
480
+ ### 0.2.0
481
+
482
+ 0.2.0 brings a number of improvements. Here are a few to be aware of:
483
+
484
+ - Added `encrypts` method for database fields
485
+ - Added support for XSalsa20
486
+ - `attached_encrypted` is deprecated in favor of `encrypts_attached`.
487
+
488
+ #### Optional
489
+
490
+ To switch to a master key, generate a key:
491
+
492
+ ```ruby
493
+ Lockbox.generate_key
494
+ ```
290
495
 
291
- attribute :encrypted_phone_iv # prevent attr_encrypted error
496
+ And set `ENV["LOCKBOX_MASTER_KEY"]` or `Lockbox.master_key`.
497
+
498
+ Update your model:
499
+
500
+ ```ruby
501
+ class User < ApplicationRecord
502
+ encrypts_attached :license, previous_versions: [{key: key}]
292
503
  end
293
504
  ```
294
505
 
295
- ## Reference
506
+ New uploads will be encrypted with the new key.
296
507
 
297
- Pass associated data to encryption and decryption
508
+ You can rotate existing records with:
298
509
 
299
510
  ```ruby
300
- box.encrypt(message, associated_data: "bingo")
301
- box.decrypt(ciphertext, associated_data: "bingo")
511
+ User.unscoped.find_each do |user|
512
+ user.license.rotate_encryption!
513
+ end
514
+ ```
515
+
516
+ Once that’s complete, update your model:
517
+
518
+ ```ruby
519
+ class User < ApplicationRecord
520
+ encrypts_attached :license
521
+ end
302
522
  ```
303
523
 
304
524
  ## History
@@ -1,6 +1,10 @@
1
+ # dependencies
2
+ require "securerandom"
3
+
1
4
  # modules
2
5
  require "lockbox/box"
3
6
  require "lockbox/encryptor"
7
+ require "lockbox/key_generator"
4
8
  require "lockbox/utils"
5
9
  require "lockbox/version"
6
10
 
@@ -8,14 +12,62 @@ require "lockbox/version"
8
12
  require "lockbox/carrier_wave_extensions" if defined?(CarrierWave)
9
13
  require "lockbox/railtie" if defined?(Rails)
10
14
 
15
+ if defined?(ActiveSupport)
16
+ ActiveSupport.on_load(:active_record) do
17
+ require "lockbox/model"
18
+ extend Lockbox::Model
19
+ end
20
+ end
21
+
11
22
  class Lockbox
12
23
  class Error < StandardError; end
13
24
  class DecryptionError < Error; end
14
25
 
15
26
  class << self
16
27
  attr_accessor :default_options
28
+ attr_writer :master_key
29
+ end
30
+ self.default_options = {}
31
+
32
+ def self.master_key
33
+ @master_key ||= ENV["LOCKBOX_MASTER_KEY"]
34
+ end
35
+
36
+ def self.migrate(model, restart: false)
37
+ # get fields
38
+ fields = model.lockbox_attributes.select { |k, v| v[:migrating] }
39
+
40
+ # get blind indexes
41
+ blind_indexes = model.respond_to?(:blind_indexes) ? model.blind_indexes.select { |k, v| v[:migrating] } : {}
42
+
43
+ # build relation
44
+ relation = model.unscoped
45
+
46
+ unless restart
47
+ attributes = fields.map { |_, v| v[:encrypted_attribute] }
48
+ attributes += blind_indexes.map { |_, v| v[:bidx_attribute] }
49
+
50
+ attributes.each_with_index do |attribute, i|
51
+ relation =
52
+ if i == 0
53
+ relation.where(attribute => nil)
54
+ else
55
+ relation.or(model.where(attribute => nil))
56
+ end
57
+ end
58
+ end
59
+
60
+ # migrate
61
+ relation.find_each do |record|
62
+ fields.each do |k, v|
63
+ record.send("#{v[:attribute]}=", record.send(k)) if restart || !record.send(v[:encrypted_attribute])
64
+ end
65
+ blind_indexes.each do |k, v|
66
+ record.send("compute_#{k}_bidx") if restart || !record.send(v[:bidx_attribute])
67
+ end
68
+ record.save(validate: false) if record.changed?
69
+ end
17
70
  end
18
- self.default_options = {algorithm: "aes-gcm"}
19
71
 
20
72
  def initialize(**options)
21
73
  options = self.class.default_options.merge(options)
@@ -56,6 +108,10 @@ class Lockbox
56
108
  end
57
109
  end
58
110
 
111
+ def self.generate_key
112
+ SecureRandom.hex(32)
113
+ end
114
+
59
115
  def self.generate_key_pair
60
116
  require "rbnacl"
61
117
  # encryption and decryption servers exchange public keys
@@ -65,11 +121,24 @@ class Lockbox
65
121
  # alice is sending message to bob
66
122
  # use bob first in both cases to prevent keys being swappable
67
123
  {
68
- encryption_key: (bob.public_key.to_bytes + alice.to_bytes).unpack("H*").first,
69
- decryption_key: (bob.to_bytes + alice.public_key.to_bytes).unpack("H*").first
124
+ encryption_key: to_hex(bob.public_key.to_bytes + alice.to_bytes),
125
+ decryption_key: to_hex(bob.to_bytes + alice.public_key.to_bytes)
70
126
  }
71
127
  end
72
128
 
129
+ def self.attribute_key(table:, attribute:, master_key: nil, encode: true)
130
+ master_key ||= Lockbox.master_key
131
+ raise ArgumentError, "Missing master key" unless master_key
132
+
133
+ key = Lockbox::KeyGenerator.new(master_key).attribute_key(table: table, attribute: attribute)
134
+ key = to_hex(key) if encode
135
+ key
136
+ end
137
+
138
+ def self.to_hex(str)
139
+ str.unpack("H*").first
140
+ end
141
+
73
142
  private
74
143
 
75
144
  def check_string(str, name)
@@ -10,16 +10,16 @@ class Lockbox
10
10
  def encrypted?
11
11
  # could use record_type directly
12
12
  # but record should already be loaded most of the time
13
- Utils.encrypted_options(record, name).present?
13
+ !Utils.encrypted_options(record, name).nil?
14
14
  end
15
15
 
16
16
  def encrypt_attachable(attachable)
17
17
  options = Utils.encrypted_options(record, name)
18
- box = Utils.build_box(record, options)
18
+ box = Utils.build_box(record, options, record.class.table_name, name)
19
19
 
20
20
  case attachable
21
21
  when ActiveStorage::Blob
22
- raise NotImplemented, "Not supported"
22
+ raise NotImplementedError, "Not supported"
23
23
  when ActionDispatch::Http::UploadedFile, Rack::Test::UploadedFile
24
24
  attachable = {
25
25
  io: StringIO.new(box.encrypt(attachable.read)),
@@ -33,7 +33,7 @@ class Lockbox
33
33
  content_type: attachable[:content_type]
34
34
  }
35
35
  when String
36
- raise NotImplemented, "Not supported"
36
+ raise NotImplementedError, "Not supported"
37
37
  else
38
38
  nil
39
39
  end
@@ -107,7 +107,7 @@ class Lockbox
107
107
 
108
108
  options = Utils.encrypted_options(record, name)
109
109
  if options
110
- result = Utils.build_box(record, options).decrypt(result)
110
+ result = Utils.build_box(record, options, record.class.table_name, name).decrypt(result)
111
111
  end
112
112
 
113
113
  result
@@ -123,30 +123,5 @@ class Lockbox
123
123
  after_save :mark_analyzed
124
124
  end
125
125
  end
126
-
127
- module Model
128
- def attached_encrypted(name, **options)
129
- class_eval do
130
- @encrypted_attachments ||= {}
131
-
132
- unless respond_to?(:encrypted_attachments)
133
- def self.encrypted_attachments
134
- parent_attachments =
135
- if superclass.respond_to?(:encrypted_attachments)
136
- superclass.encrypted_attachments
137
- else
138
- {}
139
- end
140
-
141
- parent_attachments.merge(@encrypted_attachments || {})
142
- end
143
- end
144
-
145
- raise ArgumentError, "Duplicate encrypted attachment: #{name}" if encrypted_attachments[name]
146
-
147
- @encrypted_attachments[name] = options
148
- end
149
- end
150
- end
151
126
  end
152
127
  end
@@ -5,9 +5,9 @@ class Lockbox
5
5
  def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil)
6
6
  raise ArgumentError, "Cannot pass both key and public/private key" if key && (encryption_key || decryption_key)
7
7
 
8
- key = decode_key(key) if key
9
- encryption_key = decode_key(encryption_key) if encryption_key
10
- decryption_key = decode_key(decryption_key) if decryption_key
8
+ key = Lockbox::Utils.decode_key(key) if key
9
+ encryption_key = Lockbox::Utils.decode_key(encryption_key) if encryption_key
10
+ decryption_key = Lockbox::Utils.decode_key(decryption_key) if decryption_key
11
11
 
12
12
  algorithm ||= "aes-gcm"
13
13
 
@@ -20,6 +20,10 @@ class Lockbox
20
20
  raise ArgumentError, "Missing key" unless key
21
21
  require "rbnacl"
22
22
  @box = RbNaCl::AEAD::XChaCha20Poly1305IETF.new(key)
23
+ when "xsalsa20"
24
+ raise ArgumentError, "Missing key" unless key
25
+ require "rbnacl"
26
+ @box = RbNaCl::SecretBoxes::XSalsa20Poly1305.new(key)
23
27
  when "hybrid"
24
28
  raise ArgumentError, "Missing key" unless encryption_key || decryption_key
25
29
  require "rbnacl"
@@ -33,11 +37,15 @@ class Lockbox
33
37
  end
34
38
 
35
39
  def encrypt(message, associated_data: nil)
36
- if @algorithm == "hybrid"
40
+ case @algorithm
41
+ when "hybrid"
37
42
  raise ArgumentError, "No public key set" unless @encryption_box
38
43
  raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
39
44
  nonce = generate_nonce(@encryption_box)
40
45
  ciphertext = @encryption_box.encrypt(nonce, message)
46
+ when "xsalsa20"
47
+ nonce = generate_nonce(@box)
48
+ ciphertext = @box.encrypt(nonce, message)
41
49
  else
42
50
  nonce = generate_nonce(@box)
43
51
  ciphertext = @box.encrypt(nonce, message, associated_data)
@@ -46,11 +54,15 @@ class Lockbox
46
54
  end
47
55
 
48
56
  def decrypt(ciphertext, associated_data: nil)
49
- if @algorithm == "hybrid"
57
+ case @algorithm
58
+ when "hybrid"
50
59
  raise ArgumentError, "No private key set" unless @decryption_box
51
60
  raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
52
61
  nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
53
62
  @decryption_box.decrypt(nonce, ciphertext)
63
+ when "xsalsa20"
64
+ nonce, ciphertext = extract_nonce(@box, ciphertext)
65
+ @box.decrypt(nonce, ciphertext)
54
66
  else
55
67
  nonce, ciphertext = extract_nonce(@box, ciphertext)
56
68
  @box.decrypt(nonce, ciphertext, associated_data)
@@ -73,13 +85,5 @@ class Lockbox
73
85
  nonce = bytes.slice(0, nonce_bytes)
74
86
  [nonce, bytes.slice(nonce_bytes..-1)]
75
87
  end
76
-
77
- # decode hex key
78
- def decode_key(key)
79
- if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{64,128}\z/i
80
- key = [key].pack("H*")
81
- end
82
- key
83
- end
84
88
  end
85
89
  end
@@ -36,7 +36,21 @@ class Lockbox
36
36
  private
37
37
 
38
38
  define_method :lockbox do
39
- @lockbox ||= Utils.build_box(self, options)
39
+ @lockbox ||= begin
40
+ table = model ? model.class.table_name : "_uploader"
41
+ attribute =
42
+ if mounted_as
43
+ mounted_as.to_s
44
+ else
45
+ uploader = self
46
+ while uploader.parent_version
47
+ uploader = uploader.parent_version
48
+ end
49
+ uploader.class.name.sub(/Uploader\z/, "").underscore
50
+ end
51
+
52
+ Utils.build_box(self, options, table, attribute)
53
+ end
40
54
  end
41
55
  end
42
56
  end
@@ -0,0 +1,43 @@
1
+ class Lockbox
2
+ class KeyGenerator
3
+ def initialize(master_key)
4
+ @master_key = master_key
5
+ end
6
+
7
+ # pattern ported from CipherSweet
8
+ # https://ciphersweet.paragonie.com/internals/key-hierarchy
9
+ def attribute_key(table:, attribute:)
10
+ raise ArgumentError, "Missing table for key generation" if table.to_s.empty?
11
+ raise ArgumentError, "Missing attribute for key generation" if attribute.to_s.empty?
12
+
13
+ c = "\xB4"*32
14
+ hkdf(Lockbox::Utils.decode_key(@master_key), salt: table.to_s, info: "#{c}#{attribute}", length: 32, hash: "sha384")
15
+ end
16
+
17
+ private
18
+
19
+ def hash_hmac(hash, ikm, salt)
20
+ OpenSSL::HMAC.digest(hash, salt, ikm)
21
+ end
22
+
23
+ def hkdf(ikm, salt:, info:, length:, hash:)
24
+ if OpenSSL::KDF.respond_to?(:hkdf)
25
+ return OpenSSL::KDF.hkdf(ikm, salt: salt, info: info, length: length, hash: hash)
26
+ end
27
+
28
+ prk = hash_hmac(hash, ikm, salt)
29
+
30
+ # empty binary string
31
+ t = String.new
32
+ last_block = String.new
33
+ block_index = 1
34
+ while t.bytesize < length
35
+ last_block = hash_hmac(hash, last_block + info + [block_index].pack("C"), prk)
36
+ t << last_block
37
+ block_index += 1
38
+ end
39
+
40
+ t[0, length]
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,149 @@
1
+ class Lockbox
2
+ module Model
3
+ def attached_encrypted(attribute, **options)
4
+ warn "[lockbox] DEPRECATION WARNING: Use encrypts_attached instead"
5
+ encrypts_attached(attribute, **options)
6
+ end
7
+
8
+ def encrypts_attached(*attributes, **options)
9
+ attributes.each do |name|
10
+ name = name.to_sym
11
+
12
+ class_eval do
13
+ @lockbox_attachments ||= {}
14
+
15
+ unless respond_to?(:lockbox_attachments)
16
+ def self.lockbox_attachments
17
+ parent_attachments =
18
+ if superclass.respond_to?(:lockbox_attachments)
19
+ superclass.lockbox_attachments
20
+ else
21
+ {}
22
+ end
23
+
24
+ parent_attachments.merge(@lockbox_attachments || {})
25
+ end
26
+ end
27
+
28
+ raise "Duplicate encrypted attachment: #{name}" if lockbox_attachments[name]
29
+ @lockbox_attachments[name] = options
30
+ end
31
+ end
32
+ end
33
+
34
+ def encrypts(*attributes, **options)
35
+ attributes.each do |name|
36
+ # add default options
37
+ encrypted_attribute = "#{name}_ciphertext"
38
+
39
+ options = options.dup
40
+
41
+ # migrating
42
+ original_name = name.to_sym
43
+ name = "migrated_#{name}" if options[:migrating]
44
+
45
+ name = name.to_sym
46
+
47
+ options[:attribute] = name.to_s
48
+ options[:encrypted_attribute] = encrypted_attribute
49
+ class_method_name = "generate_#{encrypted_attribute}"
50
+
51
+ class_eval do
52
+ if options[:migrating]
53
+ before_validation do
54
+ send("#{name}=", send(original_name)) if send("#{original_name}_changed?")
55
+ end
56
+ end
57
+
58
+ @lockbox_attributes ||= {}
59
+
60
+ unless respond_to?(:lockbox_attributes)
61
+ def self.lockbox_attributes
62
+ parent_attributes =
63
+ if superclass.respond_to?(:lockbox_attributes)
64
+ superclass.lockbox_attributes
65
+ else
66
+ {}
67
+ end
68
+
69
+ parent_attributes.merge(@lockbox_attributes || {})
70
+ end
71
+ end
72
+
73
+ raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name]
74
+ @lockbox_attributes[original_name] = options
75
+
76
+ if @lockbox_attributes.size == 1
77
+ def serializable_hash(options = nil)
78
+ options = options.try(:dup) || {}
79
+ options[:except] = Array(options[:except])
80
+ options[:except] += self.class.lockbox_attributes.values.reject { |v| v[:attached] }.flat_map { |v| [v[:attribute], v[:encrypted_attribute]] }
81
+ super(options)
82
+ end
83
+
84
+ # use same approach as devise
85
+ def inspect
86
+ inspection =
87
+ serializable_hash.map do |k,v|
88
+ "#{k}: #{respond_to?(:attribute_for_inspect) ? attribute_for_inspect(k) : v.inspect}"
89
+ end
90
+ "#<#{self.class} #{inspection.join(", ")}>"
91
+ end
92
+ end
93
+
94
+ attribute name, :string
95
+
96
+ define_method("#{name}=") do |message|
97
+ # decrypt first for dirty tracking
98
+ # don't raise error if can't decrypt previous
99
+ begin
100
+ send(name)
101
+ rescue Lockbox::DecryptionError
102
+ nil
103
+ end
104
+
105
+ ciphertext =
106
+ if message.nil? || message == ""
107
+ message
108
+ else
109
+ self.class.send(class_method_name, message, context: self)
110
+ end
111
+ send("#{encrypted_attribute}=", ciphertext)
112
+
113
+ super(message)
114
+ end
115
+
116
+ define_method(name) do
117
+ message = super()
118
+ unless message
119
+ ciphertext = send(encrypted_attribute)
120
+ message =
121
+ if ciphertext.nil? || ciphertext == ""
122
+ ciphertext
123
+ else
124
+ decoded = Base64.decode64(ciphertext)
125
+ Lockbox::Utils.build_box(self, options, self.class.table_name, encrypted_attribute).decrypt(decoded)
126
+ end
127
+
128
+ # set previous attribute on first decrypt
129
+ @attributes[name.to_s].instance_variable_set("@value_before_type_cast", message)
130
+
131
+ # cache
132
+ if respond_to?(:_write_attribute, true)
133
+ _write_attribute(name, message)
134
+ else
135
+ raw_write_attribute(name, message)
136
+ end
137
+ end
138
+ message
139
+ end
140
+
141
+ # for fixtures
142
+ define_singleton_method class_method_name do |message, **opts|
143
+ Base64.strict_encode64(Lockbox::Utils.build_box(opts[:context], options, table_name, encrypted_attribute).encrypt(message))
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
149
+ end
@@ -8,7 +8,6 @@ class Lockbox
8
8
  ActiveStorage::Attached.prepend(Lockbox::ActiveStorageExtensions::Attached)
9
9
  ActiveStorage::Attached::One.prepend(Lockbox::ActiveStorageExtensions::AttachedOne)
10
10
  ActiveStorage::Attached::Many.prepend(Lockbox::ActiveStorageExtensions::AttachedMany)
11
- ActiveRecord::Base.extend(Lockbox::ActiveStorageExtensions::Model) if defined?(ActiveRecord)
12
11
  end
13
12
 
14
13
  app.config.to_prepare do
@@ -1,7 +1,7 @@
1
1
  class Lockbox
2
2
  class Utils
3
- def self.build_box(context, options)
4
- options = options.dup
3
+ def self.build_box(context, options, table, attribute)
4
+ options = options.except(:attribute, :encrypted_attribute, :migrating, :attached)
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)
@@ -10,11 +10,22 @@ class Lockbox
10
10
  end
11
11
  end
12
12
 
13
+ unless options[:key] || options[:encryption_key] || options[:decryption_key]
14
+ options[:key] = Lockbox.attribute_key(table: table, attribute: attribute, master_key: options.delete(:master_key))
15
+ end
16
+
13
17
  Lockbox.new(options)
14
18
  end
15
19
 
16
20
  def self.encrypted_options(record, name)
17
- record.class.respond_to?(:encrypted_attachments) && record.class.encrypted_attachments[name.to_sym]
21
+ record.class.respond_to?(:lockbox_attachments) && record.class.lockbox_attachments[name.to_sym]
22
+ end
23
+
24
+ def self.decode_key(key)
25
+ if key.encoding != Encoding::BINARY && key =~ /\A[0-9a-f]{64,128}\z/i
26
+ key = [key].pack("H*")
27
+ end
28
+ key
18
29
  end
19
30
  end
20
31
  end
@@ -1,3 +1,3 @@
1
1
  class Lockbox
2
- VERSION = "0.1.1"
2
+ VERSION = "0.2.0"
3
3
  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.1.1
4
+ version: 0.2.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-03-01 00:00:00.000000000 Z
11
+ date: 2019-07-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -25,7 +25,7 @@ dependencies:
25
25
  - !ruby/object:Gem::Version
26
26
  version: '0'
27
27
  - !ruby/object:Gem::Dependency
28
- name: rake
28
+ name: carrierwave
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - ">="
@@ -39,7 +39,7 @@ dependencies:
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
41
  - !ruby/object:Gem::Dependency
42
- name: minitest
42
+ name: combustion
43
43
  requirement: !ruby/object:Gem::Requirement
44
44
  requirements:
45
45
  - - ">="
@@ -53,7 +53,7 @@ dependencies:
53
53
  - !ruby/object:Gem::Version
54
54
  version: '0'
55
55
  - !ruby/object:Gem::Dependency
56
- name: carrierwave
56
+ name: rails
57
57
  requirement: !ruby/object:Gem::Requirement
58
58
  requirements:
59
59
  - - ">="
@@ -67,21 +67,21 @@ dependencies:
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
69
  - !ruby/object:Gem::Dependency
70
- name: activestorage
70
+ name: minitest
71
71
  requirement: !ruby/object:Gem::Requirement
72
72
  requirements:
73
73
  - - ">="
74
74
  - !ruby/object:Gem::Version
75
- version: '0'
75
+ version: '5'
76
76
  type: :development
77
77
  prerelease: false
78
78
  version_requirements: !ruby/object:Gem::Requirement
79
79
  requirements:
80
80
  - - ">="
81
81
  - !ruby/object:Gem::Version
82
- version: '0'
82
+ version: '5'
83
83
  - !ruby/object:Gem::Dependency
84
- name: activejob
84
+ name: rake
85
85
  requirement: !ruby/object:Gem::Requirement
86
86
  requirements:
87
87
  - - ">="
@@ -95,35 +95,21 @@ dependencies:
95
95
  - !ruby/object:Gem::Version
96
96
  version: '0'
97
97
  - !ruby/object:Gem::Dependency
98
- name: combustion
98
+ name: rbnacl
99
99
  requirement: !ruby/object:Gem::Requirement
100
100
  requirements:
101
101
  - - ">="
102
102
  - !ruby/object:Gem::Version
103
- version: '0'
103
+ version: '6'
104
104
  type: :development
105
105
  prerelease: false
106
106
  version_requirements: !ruby/object:Gem::Requirement
107
107
  requirements:
108
108
  - - ">="
109
109
  - !ruby/object:Gem::Version
110
- version: '0'
110
+ version: '6'
111
111
  - !ruby/object:Gem::Dependency
112
112
  name: sqlite3
113
- requirement: !ruby/object:Gem::Requirement
114
- requirements:
115
- - - "~>"
116
- - !ruby/object:Gem::Version
117
- version: 1.3.0
118
- type: :development
119
- prerelease: false
120
- version_requirements: !ruby/object:Gem::Requirement
121
- requirements:
122
- - - "~>"
123
- - !ruby/object:Gem::Version
124
- version: 1.3.0
125
- - !ruby/object:Gem::Dependency
126
- name: rbnacl
127
113
  requirement: !ruby/object:Gem::Requirement
128
114
  requirements:
129
115
  - - ">="
@@ -137,7 +123,7 @@ dependencies:
137
123
  - !ruby/object:Gem::Version
138
124
  version: '0'
139
125
  - !ruby/object:Gem::Dependency
140
- name: attr_encrypted
126
+ name: benchmark-ips
141
127
  requirement: !ruby/object:Gem::Requirement
142
128
  requirements:
143
129
  - - ">="
@@ -165,6 +151,8 @@ files:
165
151
  - lib/lockbox/box.rb
166
152
  - lib/lockbox/carrier_wave_extensions.rb
167
153
  - lib/lockbox/encryptor.rb
154
+ - lib/lockbox/key_generator.rb
155
+ - lib/lockbox/model.rb
168
156
  - lib/lockbox/railtie.rb
169
157
  - lib/lockbox/utils.rb
170
158
  - lib/lockbox/version.rb
@@ -180,16 +168,15 @@ required_ruby_version: !ruby/object:Gem::Requirement
180
168
  requirements:
181
169
  - - ">="
182
170
  - !ruby/object:Gem::Version
183
- version: '2.2'
171
+ version: '2.4'
184
172
  required_rubygems_version: !ruby/object:Gem::Requirement
185
173
  requirements:
186
174
  - - ">="
187
175
  - !ruby/object:Gem::Version
188
176
  version: '0'
189
177
  requirements: []
190
- rubyforge_project:
191
- rubygems_version: 2.7.6
178
+ rubygems_version: 3.0.4
192
179
  signing_key:
193
180
  specification_version: 4
194
- summary: File encryption for Ruby and Rails. Supports Active Storage and CarrierWave.
181
+ summary: Modern encryption for Rails
195
182
  test_files: []