lockbox 0.3.1 → 0.3.2

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: 775a12c813fb58a8e3a7f799d85af74d8a461b8cb6578beb672919dd1a4030f2
4
- data.tar.gz: 41ab60ea29776ade74d0300ce4f933a0dc10e8f07c20bf48c2e604cab0aa4c13
3
+ metadata.gz: 17af286829bd927b3f3dcf156672a4dbced52dcc083d8920a24230cb89f5104a
4
+ data.tar.gz: 30ec2e0be697c31933b88930c02a43e5544708fe8f847b813a42bd05cd9354dc
5
5
  SHA512:
6
- metadata.gz: 1208cd3e38d59425f09db7d2830b7f69809d6ae601b2b59d232001325d1721fad3087683eb44690d8ad672cd2c9c8adc22cae283ed1f56c31d906d3ed4bcaee2
7
- data.tar.gz: 02e1faac0de9a9d8fcf168e01474792b5c9275fab7006f7e6fd4668dcae22a0b5e716bd983324104b66b4dd4ef55aede488f341f04bd7039a085ffb798f0b35d
6
+ metadata.gz: a2b0cfd5fff48b4907a29d1d67499e3bdb81d29f81c9843fae1f447ecf5dfad5d61bb383877324e6cb35f3aac9987f867f12a4106108fe5bc638f93b6fffe39a
7
+ data.tar.gz: be8abd51a5f0b8cea3d6cbd18d75bf1248333b9a575a5088b3d36c428fa1103f1645b5ca6b94bbf8dfcf2e9b1d3c31a14bd6f06fe9b9da8c26022bbb78e428c7
@@ -1,3 +1,11 @@
1
+ ## 0.3.2 (2020-02-14)
2
+
3
+ - Added `encode` option to `Lockbox::Encryptor`
4
+ - Added support for `master_key` in `previous_versions`
5
+ - Added `Lockbox.rotate` method
6
+ - Improved performance of `migrate` method
7
+ - Added generator for audits
8
+
1
9
  ## 0.3.1 (2019-12-26)
2
10
 
3
11
  - Fixed encoding for `encrypt_io` and `decrypt_io` in Ruby 2.7
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2018-2019 Andrew Kane
3
+ Copyright (c) 2018-2020 Andrew Kane
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -4,11 +4,11 @@
4
4
 
5
5
  - Uses state-of-the-art algorithms
6
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 (with the option to have more)
9
7
  - Makes migrating existing data and key rotation easy
10
8
 
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)
9
+ Lockbox aims to make encryption as friendly and intuitive as possible. Encrypted fields and files behave just like unencrypted ones for maximum compatibility with 3rd party libraries and existing code.
10
+
11
+ Learn [the principles behind it](https://ankane.org/modern-encryption-rails), [how to secure emails with Devise](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
  [![Build Status](https://travis-ci.org/ankane/lockbox.svg?branch=master)](https://travis-ci.org/ankane/lockbox)
14
14
 
@@ -22,7 +22,7 @@ gem 'lockbox'
22
22
 
23
23
  ## Key Generation
24
24
 
25
- Generate an encryption key
25
+ Generate a key
26
26
 
27
27
  ```ruby
28
28
  Lockbox.generate_key
@@ -42,29 +42,25 @@ or create `config/initializers/lockbox.rb` with something like
42
42
  Lockbox.master_key = Rails.application.credentials.lockbox_master_key
43
43
  ```
44
44
 
45
- Alternatively, you can use a [key management service](#key-management) to manage your keys.
46
-
47
- ## Instructions
45
+ Then follow the instructions below for the data you want to encrypt.
48
46
 
49
- Database fields
47
+ #### Database Fields
50
48
 
51
49
  - [Active Record](#active-record)
52
50
  - [Mongoid](#mongoid)
53
51
 
54
- Files
52
+ #### Files
55
53
 
56
54
  - [Active Storage](#active-storage)
57
55
  - [CarrierWave](#carrierwave)
58
56
  - [Shrine](#shrine)
59
57
  - [Local Files](#local-files)
60
58
 
61
- Other
59
+ #### Other
62
60
 
63
61
  - [Strings](#strings)
64
62
 
65
- ## Database Fields
66
-
67
- ### Active Record
63
+ ## Active Record
68
64
 
69
65
  Create a migration with:
70
66
 
@@ -94,7 +90,7 @@ If you need to query encrypted fields, check out [Blind Index](https://github.co
94
90
 
95
91
  #### Types
96
92
 
97
- Specify the type of a field with:
93
+ Fields are strings by default. Specify the type of a field with:
98
94
 
99
95
  ```ruby
100
96
  class User < ApplicationRecord
@@ -110,7 +106,7 @@ class User < ApplicationRecord
110
106
  end
111
107
  ```
112
108
 
113
- **Note:** Always use a `text` or `binary` column for the ciphertext in migrations, regardless of the type
109
+ **Note:** Use a `text` column for the ciphertext in migrations, regardless of the type
114
110
 
115
111
  Lockbox automatically works with serialized fields for maximum compatibility with existing code and libraries.
116
112
 
@@ -140,7 +136,55 @@ end
140
136
 
141
137
  Validations work as expected with the exception of uniqueness. Uniqueness validations require a [blind index](https://github.com/ankane/blind_index).
142
138
 
143
- ### Mongoid
139
+ #### Fixtures
140
+
141
+ You can use encrypted attributes in fixtures with:
142
+
143
+ ```yml
144
+ test_user:
145
+ email_ciphertext: <%= User.generate_email_ciphertext("secret").inspect %>
146
+ ```
147
+
148
+ Be sure to include the `inspect` at the end or it won’t be encoded properly in YAML.
149
+
150
+ #### Migrating Existing Data
151
+
152
+ Lockbox makes it easy to encrypt an existing column. Add a new column for the ciphertext, then add to your model:
153
+
154
+ ```ruby
155
+ class User < ApplicationRecord
156
+ encrypts :email, migrating: true
157
+ end
158
+ ```
159
+
160
+ Backfill the data in the Rails console:
161
+
162
+ ```ruby
163
+ Lockbox.migrate(User)
164
+ ```
165
+
166
+ Then update the model to the desired state:
167
+
168
+ ```ruby
169
+ class User < ApplicationRecord
170
+ encrypts :email
171
+
172
+ # remove this line after dropping email column
173
+ self.ignored_columns = ["email"]
174
+ end
175
+ ```
176
+
177
+ Finally, drop the unencrypted column.
178
+
179
+ If adding blind indexes, Lockbox can migrate them at the same time.
180
+
181
+ ```ruby
182
+ class User < ApplicationRecord
183
+ blind_index :email, migrating: true
184
+ end
185
+ ```
186
+
187
+ ## Mongoid
144
188
 
145
189
  Add to your model:
146
190
 
@@ -160,9 +204,7 @@ User.create!(email: "hi@example.org")
160
204
 
161
205
  If you need to query encrypted fields, check out [Blind Index](https://github.com/ankane/blind_index).
162
206
 
163
- ## Files
164
-
165
- ### Active Storage
207
+ ## Active Storage
166
208
 
167
209
  Add to your model:
168
210
 
@@ -191,11 +233,12 @@ To serve encrypted files, use a controller action.
191
233
 
192
234
  ```ruby
193
235
  def license
194
- send_data @user.license.download, type: @user.license.content_type
236
+ user = User.find(params[:id])
237
+ send_data user.license.download, type: user.license.content_type
195
238
  end
196
239
  ```
197
240
 
198
- ### CarrierWave
241
+ ## CarrierWave
199
242
 
200
243
  Add to your uploader:
201
244
 
@@ -207,50 +250,76 @@ end
207
250
 
208
251
  Encryption is applied to all versions after processing.
209
252
 
253
+ You can mount the uploader [as normal](https://github.com/carrierwaveuploader/carrierwave#activerecord). With Active Record, this involves creating a migration:
254
+
255
+ ```ruby
256
+ class AddLicenseToUsers < ActiveRecord::Migration[6.0]
257
+ def change
258
+ add_column :users, :license, :string
259
+ end
260
+ end
261
+ ```
262
+
263
+ And updating the model:
264
+
265
+ ```ruby
266
+ class User < ApplicationRecord
267
+ mount_uploader :license, LicenseUploader
268
+ end
269
+ ```
270
+
210
271
  To serve encrypted files, use a controller action.
211
272
 
212
273
  ```ruby
213
274
  def license
214
- send_data @user.license.read, type: @user.license.content_type
275
+ user = User.find(params[:id])
276
+ send_data user.license.read, type: user.license.content_type
215
277
  end
216
278
  ```
217
279
 
218
- ### Shrine
280
+ ## Shrine
219
281
 
220
- Create a box
282
+ Generate a key
221
283
 
222
284
  ```ruby
223
- box = Lockbox.new(key: key)
285
+ key = Lockbox.generate_key
286
+ ```
287
+
288
+ Create a lockbox
289
+
290
+ ```ruby
291
+ lockbox = Lockbox.new(key: key)
224
292
  ```
225
293
 
226
294
  Encrypt files before passing them to Shrine
227
295
 
228
296
  ```ruby
229
- LicenseUploader.upload(box.encrypt_io(file), :store)
297
+ LicenseUploader.upload(lockbox.encrypt_io(file), :store)
230
298
  ```
231
299
 
232
300
  And decrypt them after reading
233
301
 
234
302
  ```ruby
235
- box.decrypt(uploaded_file.read)
303
+ lockbox.decrypt(uploaded_file.read)
236
304
  ```
237
305
 
238
306
  For models, encrypt with:
239
307
 
240
308
  ```ruby
241
309
  license = params.require(:user).fetch(:license)
242
- @user.license = box.encrypt_io(license)
310
+ user.license = lockbox.encrypt_io(license)
243
311
  ```
244
312
 
245
313
  To serve encrypted files, use a controller action.
246
314
 
247
315
  ```ruby
248
316
  def license
249
- send_data box.decrypt(@user.license.read), type: @user.license.mime_type
317
+ user = User.find(params[:id])
318
+ send_data box.decrypt(user.license.read), type: user.license.mime_type
250
319
  end
251
320
  ```
252
321
 
253
- ### Local Files
322
+ ## Local Files
254
323
 
255
324
  Read the file as a binary string
256
325
 
@@ -262,58 +331,31 @@ Then follow the instructions for encrypting a string below.
262
331
 
263
332
  ## Strings
264
333
 
265
- Create a box
266
-
267
- ```ruby
268
- box = Lockbox.new(key: key)
269
- ```
270
-
271
- Encrypt
272
-
273
- ```ruby
274
- ciphertext = box.encrypt(message)
275
- ```
276
-
277
- Decrypt
334
+ Generate a key
278
335
 
279
336
  ```ruby
280
- box.decrypt(ciphertext)
337
+ key = Lockbox.generate_key
281
338
  ```
282
339
 
283
- Decrypt and return UTF-8 instead of binary
340
+ Create a lockbox
284
341
 
285
342
  ```ruby
286
- box.decrypt_str(ciphertext)
343
+ lockbox = Lockbox.new(key: key, encode: true)
287
344
  ```
288
345
 
289
- ## Migrating Existing Data
290
-
291
- Lockbox makes it easy to encrypt an existing column. Add a new column for the ciphertext, then add to your model:
346
+ Encrypt
292
347
 
293
348
  ```ruby
294
- class User < ApplicationRecord
295
- encrypts :email, migrating: true
296
- end
349
+ ciphertext = lockbox.encrypt("hello")
297
350
  ```
298
351
 
299
- Backfill the data in the Rails console:
352
+ Decrypt
300
353
 
301
354
  ```ruby
302
- Lockbox.migrate(User)
355
+ lockbox.decrypt(ciphertext)
303
356
  ```
304
357
 
305
- Then update the model to the desired state:
306
-
307
- ```ruby
308
- class User < ApplicationRecord
309
- encrypts :email
310
-
311
- # remove this line after dropping email column
312
- self.ignored_columns = ["email"]
313
- end
314
- ```
315
-
316
- Finally, drop the unencrypted column.
358
+ Use `decrypt_str` get the value as UTF-8
317
359
 
318
360
  ## Key Rotation
319
361
 
@@ -321,7 +363,7 @@ To make key rotation easy, you can pass previous versions of keys that can decry
321
363
 
322
364
  ### Active Record
323
365
 
324
- For Active Record, use:
366
+ Update your model:
325
367
 
326
368
  ```ruby
327
369
  class User < ApplicationRecord
@@ -329,15 +371,19 @@ class User < ApplicationRecord
329
371
  end
330
372
  ```
331
373
 
332
- To rotate, use:
374
+ Use `master_key` instead of `key` if passing the master key.
375
+
376
+ To rotate existing records, use:
333
377
 
334
378
  ```ruby
335
- user.update!(email: user.email)
379
+ Lockbox.rotate(User, attributes: [:email])
336
380
  ```
337
381
 
382
+ Once all records are rotated, you can remove `previous_versions` from the model.
383
+
338
384
  ### Mongoid
339
385
 
340
- For Mongoid, use:
386
+ Update your model:
341
387
 
342
388
  ```ruby
343
389
  class User
@@ -345,15 +391,19 @@ class User
345
391
  end
346
392
  ```
347
393
 
348
- To rotate, use:
394
+ Use `master_key` instead of `key` if passing the master key.
395
+
396
+ To rotate existing records, use:
349
397
 
350
398
  ```ruby
351
- user.update!(email: user.email)
399
+ Lockbox.rotate(User, attributes: [:email])
352
400
  ```
353
401
 
402
+ Once all records are rotated, you can remove `previous_versions` from the model.
403
+
354
404
  ### Active Storage
355
405
 
356
- For Active Storage use:
406
+ Update your model:
357
407
 
358
408
  ```ruby
359
409
  class User < ApplicationRecord
@@ -361,15 +411,21 @@ class User < ApplicationRecord
361
411
  end
362
412
  ```
363
413
 
414
+ Use `master_key` instead of `key` if passing the master key.
415
+
364
416
  To rotate existing files, use:
365
417
 
366
418
  ```ruby
367
- user.license.rotate_encryption!
419
+ User.find_each do |user|
420
+ user.license.rotate_encryption!
421
+ end
368
422
  ```
369
423
 
424
+ Once all files are rotated, you can remove `previous_versions` from the model.
425
+
370
426
  ### CarrierWave
371
427
 
372
- For CarrierWave, use:
428
+ Update your model:
373
429
 
374
430
  ```ruby
375
431
  class LicenseUploader < CarrierWave::Uploader::Base
@@ -377,12 +433,18 @@ class LicenseUploader < CarrierWave::Uploader::Base
377
433
  end
378
434
  ```
379
435
 
436
+ Use `master_key` instead of `key` if passing the master key.
437
+
380
438
  To rotate existing files, use:
381
439
 
382
440
  ```ruby
383
- user.license.rotate_encryption!
441
+ User.find_each do |user|
442
+ user.license.rotate_encryption!
443
+ end
384
444
  ```
385
445
 
446
+ Once all files are rotated, you can remove `previous_versions` from the model.
447
+
386
448
  ### Strings
387
449
 
388
450
  For strings, use:
@@ -391,22 +453,53 @@ For strings, use:
391
453
  Lockbox.new(key: key, previous_versions: [{key: previous_key}])
392
454
  ```
393
455
 
394
- ## Fixtures
456
+ ## Auditing
395
457
 
396
- You can use encrypted attributes in fixtures with:
458
+ It’s a good idea to track user and employee access to sensitive data. Lockbox provides a convenient way to do this with Active Record, but you can use a similar pattern to write audits to any location.
397
459
 
398
- ```yml
399
- test_user:
400
- email_ciphertext: <%= User.generate_email_ciphertext("secret").inspect %>
460
+ ```sh
461
+ rails generate lockbox:audits
462
+ rails db:migrate
401
463
  ```
402
464
 
403
- Be sure to include the `inspect` at the end or it won’t be encoded properly in YAML.
465
+ Then create an audit wherever a user can view data:
466
+
467
+ ```ruby
468
+ class UsersController < ApplicationController
469
+ def show
470
+ @user = User.find(params[:id])
471
+
472
+ LockboxAudit.create!(
473
+ subject: @user,
474
+ viewer: current_user,
475
+ data: ["email", "dob"],
476
+ context: "#{controller_name}##{action_name}",
477
+ ip: request.remote_ip
478
+ )
479
+ end
480
+ end
481
+ ```
482
+
483
+ Query audits with:
484
+
485
+ ```ruby
486
+ LockboxAudit.last(100)
487
+ ```
488
+
489
+ **Note:** This approach is not intended to be used in the event of a breach or insider attack, as it’s trivial for someone with access to your infrastructure to bypass.
404
490
 
405
491
  ## Algorithms
406
492
 
407
493
  ### AES-GCM
408
494
 
409
- 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.
495
+ This is the default algorithm. It’s:
496
+
497
+ - well-studied
498
+ - NIST recommended
499
+ - an IETF standard
500
+ - fast thanks to a [dedicated instruction set](https://en.wikipedia.org/wiki/AES_instruction_set)
501
+
502
+ **For users who do a lot of encryptions:** You should rotate an individual key after 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. Each database field and file uploader use a different key (derived from the master key) to extend this window.
410
503
 
411
504
  ### XSalsa20
412
505
 
@@ -550,29 +643,54 @@ end
550
643
 
551
644
  **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.
552
645
 
553
- ## Padding
646
+ ## Data Leakage
647
+
648
+ While encryption hides the content of a message, an attacker can still get the length of the message (since the length of the ciphertext is the length of the message plus a constant number of bytes).
649
+
650
+ Let’s say you want to encrypt the status of a candidate’s background check. Valid statuses are `clear`, `consider`, and `fail`. Even with the data encrypted, it’s trivial to map the ciphertext to a status.
651
+
652
+ ```ruby
653
+ lockbox = Lockbox.new(key: key)
654
+ lockbox.encrypt("fail").bytesize # 32
655
+ lockbox.encrypt("clear").bytesize # 33
656
+ lockbox.encrypt("consider").bytesize # 36
657
+ ```
554
658
 
555
659
  Add padding to conceal the exact length of messages.
556
660
 
557
661
  ```ruby
558
- Lockbox.new(padding: true)
662
+ lockbox = Lockbox.new(key: key, padding: true)
663
+ lockbox.encrypt("fail").bytesize # 44
664
+ lockbox.encrypt("clear").bytesize # 44
665
+ lockbox.encrypt("consider").bytesize # 44
666
+ ```
667
+
668
+ The block size for padding is 16 bytes by default. If we have a status larger than 15 bytes, it will have a different length than the others.
669
+
670
+ ```ruby
671
+ box.encrypt("length15status!").bytesize # 44
672
+ box.encrypt("length16status!!").bytesize # 60
559
673
  ```
560
674
 
561
- The block size for padding is 16 bytes by default. Change this with:
675
+ Change the block size with:
562
676
 
563
677
  ```ruby
564
678
  Lockbox.new(padding: 32) # bytes
565
679
  ```
566
680
 
567
- ## Reference
681
+ ## Binary Columns
568
682
 
569
- Set default options in an initializer with:
683
+ You can use `binary` columns for the ciphertext instead of `text` columns to save space.
570
684
 
571
685
  ```ruby
572
- Lockbox.default_options = {algorithm: "xsalsa20"}
686
+ class AddEmailCiphertextToUsers < ActiveRecord::Migration[6.0]
687
+ def change
688
+ add_column :users, :email_ciphertext, :binary
689
+ end
690
+ end
573
691
  ```
574
692
 
575
- For database fields, encrypted data is encoded in Base64. If you use `binary` columns instead of `text` columns, set:
693
+ You should disable Base64 encoding if you do this.
576
694
 
577
695
  ```ruby
578
696
  class User < ApplicationRecord
@@ -580,6 +698,12 @@ class User < ApplicationRecord
580
698
  end
581
699
  ```
582
700
 
701
+ or set it globally:
702
+
703
+ ```ruby
704
+ Lockbox.default_options = {encode: false}
705
+ ```
706
+
583
707
  ## Compatibility
584
708
 
585
709
  It’s easy to read encrypted data in another language if needed.
@@ -0,0 +1,32 @@
1
+ require "rails/generators/active_record"
2
+
3
+ module Lockbox
4
+ module Generators
5
+ class AuditsGenerator < Rails::Generators::Base
6
+ include ActiveRecord::Generators::Migration
7
+ source_root File.join(__dir__, "templates")
8
+
9
+ def copy_migration
10
+ migration_template "migration.rb", "db/migrate/create_lockbox_audits.rb", migration_version: migration_version
11
+ template "model.rb", "app/models/lockbox_audit.rb"
12
+ end
13
+
14
+ def migration_version
15
+ "[#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}]"
16
+ end
17
+
18
+ def data_type
19
+ # use connection_config instead of connection.adapter
20
+ # so database connection isn't needed
21
+ case ActiveRecord::Base.connection_config[:adapter].to_s
22
+ when /postg/i # postgres, postgis
23
+ "jsonb"
24
+ when /mysql/i
25
+ "json"
26
+ else
27
+ "text"
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,12 @@
1
+ class <%= migration_class_name %> < ActiveRecord::Migration<%= migration_version %>
2
+ def change
3
+ create_table :lockbox_audits do |t|
4
+ t.references :subject, polymorphic: true
5
+ t.references :viewer, polymorphic: true
6
+ t.<%= data_type %> :data
7
+ t.string :context
8
+ t.string :ip
9
+ t.datetime :created_at
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,6 @@
1
+ class LockboxAudit < ApplicationRecord
2
+ belongs_to :subject, polymorphic: true
3
+ belongs_to :viewer, polymorphic: true<% if data_type == "text" %>
4
+
5
+ serialize :data, JSON<% end %>
6
+ end
@@ -1,4 +1,5 @@
1
- # dependencies
1
+ # stdlib
2
+ require "base64"
2
3
  require "openssl"
3
4
  require "securerandom"
4
5
 
@@ -35,6 +36,8 @@ module Lockbox
35
36
  class DecryptionError < Error; end
36
37
  class PaddingError < Error; end
37
38
 
39
+ autoload :Audit, "lockbox/audit"
40
+
38
41
  extend Padding
39
42
 
40
43
  class << self
@@ -47,8 +50,12 @@ module Lockbox
47
50
  @master_key ||= ENV["LOCKBOX_MASTER_KEY"]
48
51
  end
49
52
 
50
- def self.migrate(model, restart: false)
51
- Migrator.new(model).migrate(restart: restart)
53
+ def self.migrate(relation, batch_size: 1000, restart: false)
54
+ Migrator.new(relation, batch_size: batch_size).migrate(restart: restart)
55
+ end
56
+
57
+ def self.rotate(relation, batch_size: 1000, attributes:)
58
+ Migrator.new(relation, batch_size: batch_size).rotate(attributes: attributes)
52
59
  end
53
60
 
54
61
  def self.generate_key
@@ -2,6 +2,7 @@ module Lockbox
2
2
  class Encryptor
3
3
  def initialize(**options)
4
4
  options = Lockbox.default_options.merge(options)
5
+ @encode = options.delete(:encode)
5
6
  previous_versions = options.delete(:previous_versions)
6
7
 
7
8
  @boxes =
@@ -11,10 +12,13 @@ module Lockbox
11
12
 
12
13
  def encrypt(message, **options)
13
14
  message = check_string(message, "message")
14
- @boxes.first.encrypt(message, **options)
15
+ ciphertext = @boxes.first.encrypt(message, **options)
16
+ ciphertext = Base64.strict_encode64(ciphertext) if @encode
17
+ ciphertext
15
18
  end
16
19
 
17
20
  def decrypt(ciphertext, **options)
21
+ ciphertext = Base64.decode64(ciphertext) if @encode
18
22
  ciphertext = check_string(ciphertext, "ciphertext")
19
23
 
20
24
  # ensure binary
@@ -1,58 +1,126 @@
1
1
  module Lockbox
2
2
  class Migrator
3
- def initialize(model)
4
- @model = model
3
+ def initialize(relation, batch_size:)
4
+ @relation = relation
5
+ @transaction = @relation.respond_to?(:transaction)
6
+ @batch_size = batch_size
5
7
  end
6
8
 
7
- def migrate(restart:)
8
- model = @model
9
+ def model
10
+ @model ||= @relation
11
+ end
12
+
13
+ def rotate(attributes:)
14
+ fields = {}
15
+ attributes.each do |a|
16
+ # use key instad of v[:attribute] to make it more intuitive when migrating: true
17
+ field = model.lockbox_attributes[a]
18
+ raise ArgumentError, "Bad attribute: #{a}" unless field
19
+ fields[a] = field
20
+ end
9
21
 
10
- # get fields
22
+ perform(fields: fields)
23
+ end
24
+
25
+ # TODO add attributes option
26
+ def migrate(restart:)
11
27
  fields = model.lockbox_attributes.select { |k, v| v[:migrating] }
12
28
 
13
- # get blind indexes
14
29
  blind_indexes = model.respond_to?(:blind_indexes) ? model.blind_indexes.select { |k, v| v[:migrating] } : {}
15
30
 
16
- # build relation
17
- relation = model.unscoped
31
+ perform(fields: fields, blind_indexes: blind_indexes, restart: restart)
32
+ end
33
+
34
+ private
35
+
36
+ def perform(fields:, blind_indexes: [], restart: true)
37
+ relation = @relation
38
+
39
+ # remove true condition in 0.4.0
40
+ if true || (defined?(ActiveRecord::Base) && base_relation.is_a?(ActiveRecord::Base))
41
+ relation = relation.unscoped
42
+ end
43
+
44
+ # convert from possible class to ActiveRecord::Relation or Mongoid::Criteria
45
+ relation = relation.all
18
46
 
19
47
  unless restart
20
48
  attributes = fields.map { |_, v| v[:encrypted_attribute] }
21
49
  attributes += blind_indexes.map { |_, v| v[:bidx_attribute] }
22
50
 
23
- if defined?(ActiveRecord::Base) && model.is_a?(ActiveRecord::Base)
51
+ if defined?(ActiveRecord::Relation) && relation.is_a?(ActiveRecord::Relation)
52
+ base_relation = relation.unscoped
53
+ or_relation = relation.unscoped
54
+
24
55
  attributes.each_with_index do |attribute, i|
25
- relation =
56
+ or_relation =
26
57
  if i == 0
27
- relation.where(attribute => nil)
58
+ base_relation.where(attribute => nil)
28
59
  else
29
- relation.or(model.unscoped.where(attribute => nil))
60
+ or_relation.or(base_relation.where(attribute => nil))
30
61
  end
31
62
  end
63
+
64
+ relation = relation.merge(or_relation)
65
+ else
66
+ relation = relation.or(attributes.map { |a| {a => nil} })
32
67
  end
33
68
  end
34
69
 
35
- if relation.respond_to?(:find_each)
36
- relation.find_each do |record|
37
- migrate_record(record, fields: fields, blind_indexes: blind_indexes, restart: restart)
70
+ each_batch(relation) do |records|
71
+ migrate_records(records, fields: fields, blind_indexes: blind_indexes, restart: restart)
72
+ end
73
+ end
74
+
75
+ def each_batch(relation)
76
+ if relation.respond_to?(:find_in_batches)
77
+ relation.find_in_batches(batch_size: @batch_size) do |records|
78
+ yield records
38
79
  end
39
80
  else
81
+ # https://github.com/karmi/tire/blob/master/lib/tire/model/import.rb
82
+ # use cursor for Mongoid
83
+ records = []
40
84
  relation.all.each do |record|
41
- migrate_record(record, fields: fields, blind_indexes: blind_indexes, restart: restart)
85
+ records << record
86
+ if records.length == @batch_size
87
+ yield records
88
+ records = []
89
+ end
42
90
  end
91
+ yield records if records.any?
43
92
  end
44
93
  end
45
94
 
46
- private
95
+ def migrate_records(records, fields:, blind_indexes:, restart:)
96
+ # do computation outside of transaction
97
+ # especially expensive blind index computation
98
+ records.each do |record|
99
+ fields.each do |k, v|
100
+ record.send("#{v[:attribute]}=", record.send(k)) if restart || !record.send(v[:encrypted_attribute])
101
+ end
102
+ blind_indexes.each do |k, v|
103
+ record.send("compute_#{k}_bidx") if restart || !record.send(v[:bidx_attribute])
104
+ end
105
+ end
106
+
107
+ records.select! { |r| r.changed? }
47
108
 
48
- def migrate_record(record, fields:, blind_indexes:, restart:)
49
- fields.each do |k, v|
50
- record.send("#{v[:attribute]}=", record.send(k)) if restart || !record.send(v[:encrypted_attribute])
109
+ with_transaction do
110
+ records.each do |record|
111
+ record.save!(validate: false)
112
+ end
51
113
  end
52
- blind_indexes.each do |k, v|
53
- record.send("compute_#{k}_bidx") if restart || !record.send(v[:bidx_attribute])
114
+ end
115
+
116
+ def with_transaction
117
+ if @transaction
118
+ @relation.transaction do
119
+ yield
120
+ end
121
+ else
122
+ yield
54
123
  end
55
- record.save(validate: false) if record.changed?
56
124
  end
57
125
  end
58
126
  end
@@ -280,9 +280,7 @@ module Lockbox
280
280
  if message.nil? || (message == "" && !options[:padding])
281
281
  message
282
282
  else
283
- ciphertext = Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).encrypt(message)
284
- ciphertext = Base64.strict_encode64(ciphertext) if options[:encode]
285
- ciphertext
283
+ Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).encrypt(message)
286
284
  end
287
285
  end
288
286
 
@@ -291,7 +289,6 @@ module Lockbox
291
289
  if ciphertext.nil? || (ciphertext == "" && !options[:padding])
292
290
  ciphertext
293
291
  else
294
- ciphertext = Base64.decode64(ciphertext) if options[:encode]
295
292
  table = activerecord ? table_name : collection_name.to_s
296
293
  Lockbox::Utils.build_box(opts[:context], options, table, encrypted_attribute).decrypt(ciphertext)
297
294
  end
@@ -1,7 +1,7 @@
1
1
  module Lockbox
2
2
  class Utils
3
3
  def self.build_box(context, options, table, attribute)
4
- options = options.except(:attribute, :encrypted_attribute, :migrating, :attached, :type, :encode)
4
+ options = options.except(:attribute, :encrypted_attribute, :migrating, :attached, :type)
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)
@@ -14,6 +14,15 @@ module Lockbox
14
14
  options[:key] = Lockbox.attribute_key(table: table, attribute: attribute, master_key: options.delete(:master_key))
15
15
  end
16
16
 
17
+ if options[:previous_versions].is_a?(Array)
18
+ options[:previous_versions] = options[:previous_versions].dup
19
+ options[:previous_versions].each_with_index do |version, i|
20
+ if !(version[:key] || version[:encryption_key] || version[:decryption_key]) && version[:master_key]
21
+ options[:previous_versions][i] = version.merge(key: Lockbox.attribute_key(table: table, attribute: attribute, master_key: version.delete(:master_key)))
22
+ end
23
+ end
24
+ end
25
+
17
26
  Lockbox.new(**options)
18
27
  end
19
28
 
@@ -1,3 +1,3 @@
1
1
  module Lockbox
2
- VERSION = "0.3.1"
2
+ VERSION = "0.3.2"
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.3.1
4
+ version: 0.3.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andrew Kane
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2019-12-27 00:00:00.000000000 Z
11
+ date: 2020-02-14 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: bundler
@@ -164,20 +164,6 @@ dependencies:
164
164
  - - ">="
165
165
  - !ruby/object:Gem::Version
166
166
  version: '0'
167
- - !ruby/object:Gem::Dependency
168
- name: mongoid
169
- requirement: !ruby/object:Gem::Requirement
170
- requirements:
171
- - - ">="
172
- - !ruby/object:Gem::Version
173
- version: '0'
174
- type: :development
175
- prerelease: false
176
- version_requirements: !ruby/object:Gem::Requirement
177
- requirements:
178
- - - ">="
179
- - !ruby/object:Gem::Version
180
- version: '0'
181
167
  description:
182
168
  email: andrew@chartkick.com
183
169
  executables: []
@@ -187,6 +173,9 @@ files:
187
173
  - CHANGELOG.md
188
174
  - LICENSE.txt
189
175
  - README.md
176
+ - lib/generators/lockbox/audits_generator.rb
177
+ - lib/generators/lockbox/templates/migration.rb.tt
178
+ - lib/generators/lockbox/templates/model.rb.tt
190
179
  - lib/lockbox.rb
191
180
  - lib/lockbox/active_storage_extensions.rb
192
181
  - lib/lockbox/aes_gcm.rb