lockbox 0.1.1 → 0.2.0

Sign up to get free protection for your applications and to get access to all the features.
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: []