lockbox 0.4.6 → 0.6.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +4 -4
- data/CHANGELOG.md +31 -0
- data/README.md +230 -37
- data/lib/lockbox.rb +4 -1
- data/lib/lockbox/active_storage_extensions.rb +33 -4
- data/lib/lockbox/aes_gcm.rb +7 -4
- data/lib/lockbox/box.rb +3 -4
- data/lib/lockbox/calculations.rb +2 -1
- data/lib/lockbox/carrier_wave_extensions.rb +23 -0
- data/lib/lockbox/migrator.rb +7 -0
- data/lib/lockbox/model.rb +67 -15
- data/lib/lockbox/utils.rb +26 -9
- data/lib/lockbox/version.rb +1 -1
- metadata +36 -8
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 77945cdf065bda9282a4a9cbffd77ebe518bcbe11c3e3ccaa91768bd1579f94c
|
4
|
+
data.tar.gz: ab052f812a91e1620dcc52ac578006b55e84d5aae1770ecb5c6c89c5119f9073
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 8223d89af7efa1e4192c48512a45177d877df2c1878da425b05bf4e2b066c1f3a413b57f8f0fbdc73a04f000db191bfde2ef8bb0319c4bce7eee6a1985a3771f
|
7
|
+
data.tar.gz: 7bcb4b647f4abdc8141c571441ce1c8e47b5864aee3e043cd7f620ca7f9abc208ddb1134ac6f35700c73a30f934c56169ff5bca235ba32faba4a0d2325bc926a
|
data/CHANGELOG.md
CHANGED
@@ -1,3 +1,34 @@
|
|
1
|
+
## 0.6.0 (2020-12-03)
|
2
|
+
|
3
|
+
- Added `encrypted` flag to Active Storage metadata
|
4
|
+
- Added encrypted columns to `filter_attributes`
|
5
|
+
- Improved `inspect` method
|
6
|
+
|
7
|
+
## 0.5.0 (2020-11-22)
|
8
|
+
|
9
|
+
- Improved error messages for hybrid cryptography
|
10
|
+
- Changed warning to error when no attributes specified
|
11
|
+
- Fixed issue with `pluck` when migrating
|
12
|
+
- Fixed error with `key_table` and `key_attribute` options with `previous_versions`
|
13
|
+
|
14
|
+
## 0.4.9 (2020-10-01)
|
15
|
+
|
16
|
+
- Added `key_table` and `key_attribute` options to `previous_versions`
|
17
|
+
- Added `encrypted_attribute` option
|
18
|
+
- Added support for encrypting empty string
|
19
|
+
- Improved `inspect` for models with encrypted attributes
|
20
|
+
|
21
|
+
## 0.4.8 (2020-08-30)
|
22
|
+
|
23
|
+
- Added `key_table` and `key_attribute` options
|
24
|
+
- Added warning when no attributes specified
|
25
|
+
- Fixed error when Active Support partially loaded
|
26
|
+
|
27
|
+
## 0.4.7 (2020-08-18)
|
28
|
+
|
29
|
+
- Added `lockbox_options` method to encrypted CarrierWave uploaders
|
30
|
+
- Improved attribute loading when no decryption key specified
|
31
|
+
|
1
32
|
## 0.4.6 (2020-07-02)
|
2
33
|
|
3
34
|
- Added support for `update_column` and `update_columns`
|
data/README.md
CHANGED
@@ -1,16 +1,15 @@
|
|
1
1
|
# Lockbox
|
2
2
|
|
3
|
-
:package: Modern encryption for Rails
|
3
|
+
:package: Modern encryption for Ruby and Rails
|
4
4
|
|
5
|
-
- Uses state-of-the-art algorithms
|
6
5
|
- Works with database fields, files, and strings
|
6
|
+
- Maximizes compatibility with existing code and libraries
|
7
7
|
- Makes migrating existing data and key rotation easy
|
8
|
-
|
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.
|
8
|
+
- Has zero dependencies and many integrations
|
10
9
|
|
11
10
|
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
11
|
|
13
|
-
[![Build Status](https://
|
12
|
+
[![Build Status](https://github.com/ankane/lockbox/workflows/build/badge.svg?branch=master)](https://github.com/ankane/lockbox/actions)
|
14
13
|
|
15
14
|
## Installation
|
16
15
|
|
@@ -89,6 +88,16 @@ User.create!(email: "hi@example.org")
|
|
89
88
|
|
90
89
|
If you need to query encrypted fields, check out [Blind Index](https://github.com/ankane/blind_index).
|
91
90
|
|
91
|
+
#### Multiple Fields
|
92
|
+
|
93
|
+
You can specify multiple fields in single line.
|
94
|
+
|
95
|
+
```ruby
|
96
|
+
class User < ApplicationRecord
|
97
|
+
encrypts :email, :phone, :city
|
98
|
+
end
|
99
|
+
```
|
100
|
+
|
92
101
|
#### Types
|
93
102
|
|
94
103
|
Fields are strings by default. Specify the type of a field with:
|
@@ -188,8 +197,46 @@ class User < ApplicationRecord
|
|
188
197
|
end
|
189
198
|
```
|
190
199
|
|
200
|
+
#### Model Changes
|
201
|
+
|
202
|
+
If tracking changes to model attributes, be sure to remove or redact encrypted attributes.
|
203
|
+
|
204
|
+
PaperTrail
|
205
|
+
|
206
|
+
```ruby
|
207
|
+
class User < ApplicationRecord
|
208
|
+
# for an encrypted history (still tracks ciphertext changes)
|
209
|
+
has_paper_trail skip: [:email]
|
210
|
+
|
211
|
+
# for no history (add blind indexes as well)
|
212
|
+
has_paper_trail skip: [:email, :email_ciphertext]
|
213
|
+
end
|
214
|
+
```
|
215
|
+
|
216
|
+
Audited
|
217
|
+
|
218
|
+
```ruby
|
219
|
+
class User < ApplicationRecord
|
220
|
+
# for an encrypted history (still tracks ciphertext changes)
|
221
|
+
audited except: [:email]
|
222
|
+
|
223
|
+
# for no history (add blind indexes as well)
|
224
|
+
audited except: [:email, :email_ciphertext]
|
225
|
+
end
|
226
|
+
```
|
227
|
+
|
228
|
+
#### Decryption
|
229
|
+
|
230
|
+
To decrypt data outside the model, use:
|
231
|
+
|
232
|
+
```ruby
|
233
|
+
User.decrypt_email_ciphertext(user.email_ciphertext)
|
234
|
+
```
|
235
|
+
|
191
236
|
## Action Text
|
192
237
|
|
238
|
+
**Note:** Action Text uses direct uploads for files, which cannot be encrypted with application-level encryption like Lockbox. This only encrypts the database field.
|
239
|
+
|
193
240
|
Create a migration with:
|
194
241
|
|
195
242
|
```ruby
|
@@ -220,6 +267,10 @@ Lockbox.encrypts_action_text_body
|
|
220
267
|
|
221
268
|
And drop the unencrypted column.
|
222
269
|
|
270
|
+
#### Options
|
271
|
+
|
272
|
+
You can pass any Lockbox options to the `encrypts_action_text_body` method.
|
273
|
+
|
223
274
|
## Mongoid
|
224
275
|
|
225
276
|
Add to your model:
|
@@ -264,8 +315,9 @@ end
|
|
264
315
|
|
265
316
|
There are a few limitations to be aware of:
|
266
317
|
|
267
|
-
-
|
268
|
-
-
|
318
|
+
- Variants and previews aren’t supported when encrypted
|
319
|
+
- Metadata like image width and height aren’t extracted when encrypted
|
320
|
+
- Direct uploads can’t be encrypted with application-level encryption like Lockbox, but can use server-side encryption
|
269
321
|
|
270
322
|
To serve encrypted files, use a controller action.
|
271
323
|
|
@@ -390,44 +442,58 @@ Finally, delete the unencrypted files and drop the column for the original uploa
|
|
390
442
|
|
391
443
|
## Shrine
|
392
444
|
|
393
|
-
|
445
|
+
#### Models
|
446
|
+
|
447
|
+
Include the attachment as normal:
|
394
448
|
|
395
449
|
```ruby
|
396
|
-
|
450
|
+
class User < ApplicationRecord
|
451
|
+
include LicenseUploader::Attachment(:license)
|
452
|
+
end
|
397
453
|
```
|
398
454
|
|
399
|
-
|
455
|
+
And encrypt in a controller (or background job, etc) with:
|
400
456
|
|
401
457
|
```ruby
|
402
|
-
|
458
|
+
license = params.require(:user).fetch(:license)
|
459
|
+
lockbox = Lockbox.new(key: Lockbox.attribute_key(table: "users", attribute: "license"))
|
460
|
+
user.license = lockbox.encrypt_io(license)
|
403
461
|
```
|
404
462
|
|
405
|
-
|
463
|
+
To serve encrypted files, use a controller action.
|
406
464
|
|
407
465
|
```ruby
|
408
|
-
|
466
|
+
def license
|
467
|
+
user = User.find(params[:id])
|
468
|
+
lockbox = Lockbox.new(key: Lockbox.attribute_key(table: "users", attribute: "license"))
|
469
|
+
send_data lockbox.decrypt(user.license.read), type: user.license.mime_type
|
470
|
+
end
|
409
471
|
```
|
410
472
|
|
411
|
-
|
473
|
+
#### Non-Models
|
474
|
+
|
475
|
+
Generate a key
|
412
476
|
|
413
477
|
```ruby
|
414
|
-
|
478
|
+
key = Lockbox.generate_key
|
415
479
|
```
|
416
480
|
|
417
|
-
|
481
|
+
Create a lockbox
|
418
482
|
|
419
483
|
```ruby
|
420
|
-
|
421
|
-
user.license = lockbox.encrypt_io(license)
|
484
|
+
lockbox = Lockbox.new(key: key)
|
422
485
|
```
|
423
486
|
|
424
|
-
|
487
|
+
Encrypt files before passing them to Shrine
|
425
488
|
|
426
489
|
```ruby
|
427
|
-
|
428
|
-
|
429
|
-
|
430
|
-
|
490
|
+
LicenseUploader.upload(lockbox.encrypt_io(file), :store)
|
491
|
+
```
|
492
|
+
|
493
|
+
And decrypt them after reading
|
494
|
+
|
495
|
+
```ruby
|
496
|
+
lockbox.decrypt(uploaded_file.read)
|
431
497
|
```
|
432
498
|
|
433
499
|
## Local Files
|
@@ -508,6 +574,24 @@ Lockbox.rotate(User, attributes: [:email])
|
|
508
574
|
|
509
575
|
Once all records are rotated, you can remove `previous_versions` from the model.
|
510
576
|
|
577
|
+
### Action Text
|
578
|
+
|
579
|
+
Update your initializer:
|
580
|
+
|
581
|
+
```ruby
|
582
|
+
Lockbox.encrypts_action_text_body(previous_versions: [{key: previous_key}])
|
583
|
+
```
|
584
|
+
|
585
|
+
Use `master_key` instead of `key` if passing the master key.
|
586
|
+
|
587
|
+
To rotate existing records, use:
|
588
|
+
|
589
|
+
```ruby
|
590
|
+
Lockbox.rotate(ActionText::RichText, attributes: [:body])
|
591
|
+
```
|
592
|
+
|
593
|
+
Once all records are rotated, you can remove `previous_versions` from the initializer.
|
594
|
+
|
511
595
|
### Active Storage
|
512
596
|
|
513
597
|
Update your model:
|
@@ -550,6 +634,14 @@ User.find_each do |user|
|
|
550
634
|
end
|
551
635
|
```
|
552
636
|
|
637
|
+
For multiple files, use:
|
638
|
+
|
639
|
+
```ruby
|
640
|
+
User.find_each do |user|
|
641
|
+
user.licenses.map(&:rotate_encryption!)
|
642
|
+
end
|
643
|
+
```
|
644
|
+
|
553
645
|
Once all files are rotated, you can remove `previous_versions` from the model.
|
554
646
|
|
555
647
|
### Local Files & Strings
|
@@ -606,6 +698,8 @@ This is the default algorithm. It’s:
|
|
606
698
|
- an IETF standard
|
607
699
|
- fast thanks to a [dedicated instruction set](https://en.wikipedia.org/wiki/AES_instruction_set)
|
608
700
|
|
701
|
+
Lockbox uses 256-bit keys.
|
702
|
+
|
609
703
|
**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.
|
610
704
|
|
611
705
|
### XSalsa20
|
@@ -659,6 +753,22 @@ For Ubuntu 16.04, use:
|
|
659
753
|
sudo apt-get install libsodium18
|
660
754
|
```
|
661
755
|
|
756
|
+
##### GitHub Actions
|
757
|
+
|
758
|
+
For Ubuntu 20.04 and 18.04, use:
|
759
|
+
|
760
|
+
```yml
|
761
|
+
- name: Install Libsodium
|
762
|
+
run: sudo apt-get update && sudo apt-get install libsodium23
|
763
|
+
```
|
764
|
+
|
765
|
+
For Ubuntu 16.04, use:
|
766
|
+
|
767
|
+
```yml
|
768
|
+
- name: Install Libsodium
|
769
|
+
run: sudo apt-get update && sudo apt-get install libsodium18
|
770
|
+
```
|
771
|
+
|
662
772
|
##### Travis CI
|
663
773
|
|
664
774
|
On Bionic, add to `.travis.yml`:
|
@@ -686,8 +796,7 @@ Add a step to `.circleci/config.yml`:
|
|
686
796
|
```yml
|
687
797
|
- run:
|
688
798
|
name: install Libsodium
|
689
|
-
command:
|
690
|
-
sudo apt-get install -y libsodium18
|
799
|
+
command: sudo apt-get install -y libsodium18
|
691
800
|
```
|
692
801
|
|
693
802
|
## Hybrid Cryptography
|
@@ -714,15 +823,43 @@ Make sure `decryption_key` is `nil` on servers that shouldn’t decrypt.
|
|
714
823
|
|
715
824
|
This uses X25519 for key exchange and XSalsa20 for encryption.
|
716
825
|
|
717
|
-
## Key
|
826
|
+
## Key Configuration
|
827
|
+
|
828
|
+
Lockbox supports a few different ways to set keys for database fields and files.
|
829
|
+
|
830
|
+
1. Master key
|
831
|
+
2. Per field/uploader
|
832
|
+
3. Per record
|
718
833
|
|
719
|
-
|
834
|
+
### Master Key
|
835
|
+
|
836
|
+
By default, the master key is used to generate unique keys for each field/uploader. This technique comes from [CipherSweet](https://ciphersweet.paragonie.com/internals/key-hierarchy). The table name and column/uploader name are both used in this process.
|
837
|
+
|
838
|
+
You can get an individual key with:
|
720
839
|
|
721
840
|
```ruby
|
722
841
|
Lockbox.attribute_key(table: "users", attribute: "email_ciphertext")
|
723
842
|
```
|
724
843
|
|
725
|
-
|
844
|
+
To rename a table with encrypted columns/uploaders, use:
|
845
|
+
|
846
|
+
```ruby
|
847
|
+
class User < ApplicationRecord
|
848
|
+
encrypts :email, key_table: "original_table"
|
849
|
+
end
|
850
|
+
```
|
851
|
+
|
852
|
+
To rename an encrypted column itself, use:
|
853
|
+
|
854
|
+
```ruby
|
855
|
+
class User < ApplicationRecord
|
856
|
+
encrypts :email, key_attribute: "original_column"
|
857
|
+
end
|
858
|
+
```
|
859
|
+
|
860
|
+
### Per Field/Uploader
|
861
|
+
|
862
|
+
To set a key for an individual field/uploader, use a string:
|
726
863
|
|
727
864
|
```ruby
|
728
865
|
class User < ApplicationRecord
|
@@ -730,16 +867,62 @@ class User < ApplicationRecord
|
|
730
867
|
end
|
731
868
|
```
|
732
869
|
|
870
|
+
Or a proc:
|
871
|
+
|
872
|
+
```ruby
|
873
|
+
class User < ApplicationRecord
|
874
|
+
encrypts :email, key: -> { code }
|
875
|
+
end
|
876
|
+
```
|
877
|
+
|
878
|
+
### Per Record
|
879
|
+
|
880
|
+
To use a different key for each record, use a symbol:
|
881
|
+
|
882
|
+
```ruby
|
883
|
+
class User < ApplicationRecord
|
884
|
+
encrypts :email, key: :some_method
|
885
|
+
end
|
886
|
+
```
|
887
|
+
|
888
|
+
Or a proc:
|
889
|
+
|
890
|
+
```ruby
|
891
|
+
class User < ApplicationRecord
|
892
|
+
encrypts :email, key: -> { some_method }
|
893
|
+
end
|
894
|
+
```
|
895
|
+
|
733
896
|
## Key Management
|
734
897
|
|
735
898
|
You can use a key management service to manage your keys with [KMS Encrypted](https://github.com/ankane/kms_encrypted).
|
736
899
|
|
900
|
+
For Active Record and Mongoid, use:
|
901
|
+
|
737
902
|
```ruby
|
738
903
|
class User < ApplicationRecord
|
739
904
|
encrypts :email, key: :kms_key
|
740
905
|
end
|
741
906
|
```
|
742
907
|
|
908
|
+
For Action Text, use:
|
909
|
+
|
910
|
+
```ruby
|
911
|
+
ActiveSupport.on_load(:action_text_rich_text) do
|
912
|
+
ActionText::RichText.has_kms_key
|
913
|
+
end
|
914
|
+
|
915
|
+
Lockbox.encrypts_action_text_body(key: :kms_key)
|
916
|
+
```
|
917
|
+
|
918
|
+
For Active Storage, use:
|
919
|
+
|
920
|
+
```ruby
|
921
|
+
class User < ApplicationRecord
|
922
|
+
encrypts_attached :license, key: :kms_key
|
923
|
+
end
|
924
|
+
```
|
925
|
+
|
743
926
|
For CarrierWave, use:
|
744
927
|
|
745
928
|
```ruby
|
@@ -772,7 +955,7 @@ lockbox.encrypt("clear").bytesize # 44
|
|
772
955
|
lockbox.encrypt("consider").bytesize # 44
|
773
956
|
```
|
774
957
|
|
775
|
-
The block size for padding is 16 bytes by default.
|
958
|
+
The block size for padding is 16 bytes by default. Lockbox uses [ISO/IEC 7816-4](https://en.wikipedia.org/wiki/Padding_(cryptography)#ISO/IEC_7816-4) padding, which uses at least one byte, so if we have a status larger than 15 bytes, it will have a different length than the others.
|
776
959
|
|
777
960
|
```ruby
|
778
961
|
box.encrypt("length15status!").bytesize # 44
|
@@ -785,9 +968,25 @@ Change the block size with:
|
|
785
968
|
Lockbox.new(padding: 32) # bytes
|
786
969
|
```
|
787
970
|
|
971
|
+
## Associated Data
|
972
|
+
|
973
|
+
You can pass extra context during encryption to make sure encrypted data isn’t moved to a different context.
|
974
|
+
|
975
|
+
```ruby
|
976
|
+
lockbox = Lockbox.new(key: key)
|
977
|
+
ciphertext = lockbox.encrypt(message, associated_data: "somecontext")
|
978
|
+
```
|
979
|
+
|
980
|
+
Without the same context, decryption will fail.
|
981
|
+
|
982
|
+
```ruby
|
983
|
+
lockbox.decrypt(ciphertext, associated_data: "somecontext") # success
|
984
|
+
lockbox.decrypt(ciphertext, associated_data: "othercontext") # fails
|
985
|
+
```
|
986
|
+
|
788
987
|
## Binary Columns
|
789
988
|
|
790
|
-
You can use `binary` columns for the ciphertext instead of `text` columns
|
989
|
+
You can use `binary` columns for the ciphertext instead of `text` columns.
|
791
990
|
|
792
991
|
```ruby
|
793
992
|
class AddEmailCiphertextToUsers < ActiveRecord::Migration[6.0]
|
@@ -797,7 +996,7 @@ class AddEmailCiphertextToUsers < ActiveRecord::Migration[6.0]
|
|
797
996
|
end
|
798
997
|
```
|
799
998
|
|
800
|
-
|
999
|
+
Disable Base64 encoding to save space.
|
801
1000
|
|
802
1001
|
```ruby
|
803
1002
|
class User < ApplicationRecord
|
@@ -805,12 +1004,6 @@ class User < ApplicationRecord
|
|
805
1004
|
end
|
806
1005
|
```
|
807
1006
|
|
808
|
-
or set it globally:
|
809
|
-
|
810
|
-
```ruby
|
811
|
-
Lockbox.default_options = {encode: false}
|
812
|
-
```
|
813
|
-
|
814
1007
|
## Compatibility
|
815
1008
|
|
816
1009
|
It’s easy to read encrypted data in another language if needed.
|
data/lib/lockbox.rb
CHANGED
@@ -4,6 +4,7 @@ require "openssl"
|
|
4
4
|
require "securerandom"
|
5
5
|
|
6
6
|
# modules
|
7
|
+
require "lockbox/aes_gcm"
|
7
8
|
require "lockbox/box"
|
8
9
|
require "lockbox/calculations"
|
9
10
|
require "lockbox/encryptor"
|
@@ -19,10 +20,12 @@ require "lockbox/version"
|
|
19
20
|
require "lockbox/carrier_wave_extensions" if defined?(CarrierWave)
|
20
21
|
require "lockbox/railtie" if defined?(Rails)
|
21
22
|
|
22
|
-
if defined?(ActiveSupport)
|
23
|
+
if defined?(ActiveSupport::LogSubscriber)
|
23
24
|
require "lockbox/log_subscriber"
|
24
25
|
Lockbox::LogSubscriber.attach_to :lockbox
|
26
|
+
end
|
25
27
|
|
28
|
+
if defined?(ActiveSupport.on_load)
|
26
29
|
ActiveSupport.on_load(:active_record) do
|
27
30
|
extend Lockbox::Model
|
28
31
|
extend Lockbox::Model::Attached
|
@@ -1,7 +1,22 @@
|
|
1
|
-
#
|
2
|
-
#
|
3
|
-
#
|
4
|
-
#
|
1
|
+
# Ideally encryption and decryption would happen at the blob/service level.
|
2
|
+
# However, Active Storage < 6.1 only supports a single service (per environment).
|
3
|
+
# This means all attachments need to be encrypted or none of them,
|
4
|
+
# which is often not practical.
|
5
|
+
#
|
6
|
+
# Active Storage 6.1 adds support for multiple services, which changes this.
|
7
|
+
# We could have a Lockbox service:
|
8
|
+
#
|
9
|
+
# lockbox:
|
10
|
+
# service: Lockbox
|
11
|
+
# backend: local # delegate to another service, like mirror service
|
12
|
+
# key: ... # Lockbox options
|
13
|
+
#
|
14
|
+
# However, the checksum is computed *and stored on the blob*
|
15
|
+
# before the file is passed to the service.
|
16
|
+
# We don't want the MD5 checksum of the plaintext stored in the database.
|
17
|
+
#
|
18
|
+
# Instead, we encrypt and decrypt at the attachment level,
|
19
|
+
# and we define encryption settings at the model level.
|
5
20
|
module Lockbox
|
6
21
|
module ActiveStorageExtensions
|
7
22
|
module Attached
|
@@ -74,6 +89,10 @@ module Lockbox
|
|
74
89
|
module CreateOne
|
75
90
|
def initialize(name, record, attachable)
|
76
91
|
# this won't encrypt existing blobs
|
92
|
+
# ideally we'd check metadata for the encrypted flag
|
93
|
+
# and disallow unencrypted blobs
|
94
|
+
# since they'll raise an error on decryption
|
95
|
+
# but earlier versions of Lockbox won't have it
|
77
96
|
attachable = Lockbox::Utils.encrypt_attachable(record, name, attachable) if Lockbox::Utils.encrypted?(record, name) && !attachable.is_a?(ActiveStorage::Blob)
|
78
97
|
super(name, record, attachable)
|
79
98
|
end
|
@@ -95,6 +114,16 @@ module Lockbox
|
|
95
114
|
result
|
96
115
|
end
|
97
116
|
|
117
|
+
def variant(*args)
|
118
|
+
raise Lockbox::Error, "Variant not supported for encrypted files" if Utils.encrypted_options(record, name)
|
119
|
+
super
|
120
|
+
end
|
121
|
+
|
122
|
+
def preview(*args)
|
123
|
+
raise Lockbox::Error, "Preview not supported for encrypted files" if Utils.encrypted_options(record, name)
|
124
|
+
super
|
125
|
+
end
|
126
|
+
|
98
127
|
if ActiveStorage::VERSION::MAJOR >= 6
|
99
128
|
def open(**options)
|
100
129
|
blob.open(**options) do |file|
|
data/lib/lockbox/aes_gcm.rb
CHANGED
@@ -18,9 +18,10 @@ module Lockbox
|
|
18
18
|
# In encryption mode, it must be set after calling #encrypt and setting #key= and #iv=
|
19
19
|
cipher.auth_data = associated_data || ""
|
20
20
|
|
21
|
-
ciphertext =
|
21
|
+
ciphertext = String.new
|
22
|
+
ciphertext << cipher.update(message) unless message.empty?
|
23
|
+
ciphertext << cipher.final
|
22
24
|
ciphertext << cipher.auth_tag
|
23
|
-
|
24
25
|
ciphertext
|
25
26
|
end
|
26
27
|
|
@@ -29,7 +30,6 @@ module Lockbox
|
|
29
30
|
|
30
31
|
fail_decryption if nonce.to_s.bytesize != nonce_bytes
|
31
32
|
fail_decryption if auth_tag.to_s.bytesize != auth_tag_bytes
|
32
|
-
fail_decryption if ciphertext.to_s.bytesize == 0
|
33
33
|
|
34
34
|
cipher = OpenSSL::Cipher.new("aes-256-gcm")
|
35
35
|
# do not change order of operations
|
@@ -43,7 +43,10 @@ module Lockbox
|
|
43
43
|
cipher.auth_data = associated_data || ""
|
44
44
|
|
45
45
|
begin
|
46
|
-
|
46
|
+
message = String.new
|
47
|
+
message << cipher.update(ciphertext) unless ciphertext.to_s.empty?
|
48
|
+
message << cipher.final
|
49
|
+
message
|
47
50
|
rescue OpenSSL::Cipher::CipherError
|
48
51
|
fail_decryption
|
49
52
|
end
|
data/lib/lockbox/box.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
module Lockbox
|
2
2
|
class Box
|
3
3
|
def initialize(key: nil, algorithm: nil, encryption_key: nil, decryption_key: nil, padding: false)
|
4
|
-
raise ArgumentError, "Cannot pass both key and
|
4
|
+
raise ArgumentError, "Cannot pass both key and encryption/decryption key" if key && (encryption_key || decryption_key)
|
5
5
|
|
6
6
|
key = Lockbox::Utils.decode_key(key) if key
|
7
7
|
encryption_key = Lockbox::Utils.decode_key(encryption_key, size: 64) if encryption_key
|
@@ -12,7 +12,6 @@ module Lockbox
|
|
12
12
|
case algorithm
|
13
13
|
when "aes-gcm"
|
14
14
|
raise ArgumentError, "Missing key" unless key
|
15
|
-
require "lockbox/aes_gcm"
|
16
15
|
@box = AES_GCM.new(key)
|
17
16
|
when "xchacha20"
|
18
17
|
raise ArgumentError, "Missing key" unless key
|
@@ -39,7 +38,7 @@ module Lockbox
|
|
39
38
|
message = Lockbox.pad(message, size: @padding) if @padding
|
40
39
|
case @algorithm
|
41
40
|
when "hybrid"
|
42
|
-
raise ArgumentError, "No
|
41
|
+
raise ArgumentError, "No encryption key set" unless defined?(@encryption_box)
|
43
42
|
raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
|
44
43
|
nonce = generate_nonce(@encryption_box)
|
45
44
|
ciphertext = @encryption_box.encrypt(nonce, message)
|
@@ -58,7 +57,7 @@ module Lockbox
|
|
58
57
|
message =
|
59
58
|
case @algorithm
|
60
59
|
when "hybrid"
|
61
|
-
raise ArgumentError, "No
|
60
|
+
raise ArgumentError, "No decryption key set" unless defined?(@decryption_box)
|
62
61
|
raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
|
63
62
|
nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
|
64
63
|
@decryption_box.decrypt(nonce, ciphertext)
|
data/lib/lockbox/calculations.rb
CHANGED
@@ -3,7 +3,8 @@ module Lockbox
|
|
3
3
|
def pluck(*column_names)
|
4
4
|
return super unless model.respond_to?(:lockbox_attributes)
|
5
5
|
|
6
|
-
lockbox_columns = column_names.map.with_index { |c, i| [model.lockbox_attributes[c.to_sym], i] }.select
|
6
|
+
lockbox_columns = column_names.map.with_index { |c, i| [model.lockbox_attributes[c.to_sym], i] }.select { |la, _i| la && !la[:migrating] }
|
7
|
+
|
7
8
|
return super unless lockbox_columns.any?
|
8
9
|
|
9
10
|
# replace column with ciphertext column
|
@@ -2,9 +2,22 @@ module Lockbox
|
|
2
2
|
module CarrierWaveExtensions
|
3
3
|
def encrypt(**options)
|
4
4
|
class_eval do
|
5
|
+
# uses same hook as process (before cache)
|
6
|
+
# processing can be disabled, so better to keep separate
|
5
7
|
before :cache, :encrypt
|
6
8
|
|
9
|
+
define_singleton_method :lockbox_options do
|
10
|
+
options
|
11
|
+
end
|
12
|
+
|
7
13
|
def encrypt(file)
|
14
|
+
# safety check
|
15
|
+
# see CarrierWave::Uploader::Cache#cache!
|
16
|
+
raise Lockbox::Error, "Expected files to be equal. Please report an issue." unless file && @file && file == @file
|
17
|
+
|
18
|
+
# processors in CarrierWave move updated file to current_path
|
19
|
+
# however, this causes versions to use the processed file
|
20
|
+
# we only want to change the file for the current version
|
8
21
|
@file = CarrierWave::SanitizedFile.new(lockbox_notify("encrypt_file") { lockbox.encrypt_io(file) })
|
9
22
|
end
|
10
23
|
|
@@ -14,6 +27,7 @@ module Lockbox
|
|
14
27
|
lockbox_notify("decrypt_file") { lockbox.decrypt(r) } if r
|
15
28
|
end
|
16
29
|
|
30
|
+
# use size of plaintext since read and content type use plaintext
|
17
31
|
def size
|
18
32
|
read.bytesize
|
19
33
|
end
|
@@ -23,6 +37,7 @@ module Lockbox
|
|
23
37
|
MimeMagic.by_magic(read).try(:type) || "invalid/invalid"
|
24
38
|
end
|
25
39
|
|
40
|
+
# disable processing since already processed
|
26
41
|
def rotate_encryption!
|
27
42
|
io = Lockbox::IO.new(read)
|
28
43
|
io.original_filename = file.filename
|
@@ -46,6 +61,8 @@ module Lockbox
|
|
46
61
|
end
|
47
62
|
end
|
48
63
|
|
64
|
+
# for mounted uploaders, use mounted name
|
65
|
+
# for others, use uploader name
|
49
66
|
def lockbox_name
|
50
67
|
if mounted_as
|
51
68
|
mounted_as.to_s
|
@@ -58,6 +75,8 @@ module Lockbox
|
|
58
75
|
end
|
59
76
|
end
|
60
77
|
|
78
|
+
# Active Support notifications so it's easier
|
79
|
+
# to see when files are encrypted and decrypted
|
61
80
|
def lockbox_notify(type)
|
62
81
|
if defined?(ActiveSupport::Notifications)
|
63
82
|
name = lockbox_name
|
@@ -78,4 +97,8 @@ module Lockbox
|
|
78
97
|
end
|
79
98
|
end
|
80
99
|
|
100
|
+
if CarrierWave::VERSION.to_i > 2
|
101
|
+
raise "CarrierWave version not supported in this version of Lockbox: #{CarrierWave::VERSION}"
|
102
|
+
end
|
103
|
+
|
81
104
|
CarrierWave::Uploader::Base.extend(Lockbox::CarrierWaveExtensions)
|
data/lib/lockbox/migrator.rb
CHANGED
@@ -116,6 +116,13 @@ module Lockbox
|
|
116
116
|
end
|
117
117
|
end
|
118
118
|
|
119
|
+
# there's a small chance for this process to read data,
|
120
|
+
# another process to update the data, and
|
121
|
+
# this process to write the now stale data
|
122
|
+
# this time window can be reduced with smaller batch sizes
|
123
|
+
# locking individual records could eliminate this
|
124
|
+
# one option is: relation.in_batches { |batch| batch.lock }
|
125
|
+
# which runs SELECT ... FOR UPDATE in Postgres
|
119
126
|
def migrate_records(records, fields:, blind_indexes:, restart:, rotate:)
|
120
127
|
# do computation outside of transaction
|
121
128
|
# especially expensive blind index computation
|
data/lib/lockbox/model.rb
CHANGED
@@ -27,11 +27,19 @@ module Lockbox
|
|
27
27
|
activerecord = defined?(ActiveRecord::Base) && self < ActiveRecord::Base
|
28
28
|
raise ArgumentError, "Type not supported yet with Mongoid" if options[:type] && !activerecord
|
29
29
|
|
30
|
+
raise ArgumentError, "No attributes specified" if attributes.empty?
|
31
|
+
|
32
|
+
raise ArgumentError, "Cannot use key_attribute with multiple attributes" if options[:key_attribute] && attributes.size > 1
|
33
|
+
|
34
|
+
original_options = options.dup
|
35
|
+
|
30
36
|
attributes.each do |name|
|
31
|
-
#
|
32
|
-
|
37
|
+
# per attribute options
|
38
|
+
# TODO use a different name
|
39
|
+
options = original_options.dup
|
33
40
|
|
34
|
-
|
41
|
+
# add default options
|
42
|
+
encrypted_attribute = options.delete(:encrypted_attribute) || "#{name}_ciphertext"
|
35
43
|
|
36
44
|
# migrating
|
37
45
|
original_name = name.to_sym
|
@@ -47,6 +55,15 @@ module Lockbox
|
|
47
55
|
decrypt_method_name = "decrypt_#{encrypted_attribute}"
|
48
56
|
|
49
57
|
class_eval do
|
58
|
+
# Lockbox uses custom inspect
|
59
|
+
# but this could be useful for other gems
|
60
|
+
if activerecord && ActiveRecord::VERSION::MAJOR >= 6
|
61
|
+
# only add virtual attribute
|
62
|
+
# need to use regexp since strings do partial matching
|
63
|
+
# also, need to use += instead of <<
|
64
|
+
self.filter_attributes += [/\A#{Regexp.escape(options[:attribute])}\z/]
|
65
|
+
end
|
66
|
+
|
50
67
|
@lockbox_attributes ||= {}
|
51
68
|
|
52
69
|
if @lockbox_attributes.empty?
|
@@ -71,12 +88,42 @@ module Lockbox
|
|
71
88
|
super(options)
|
72
89
|
end
|
73
90
|
|
74
|
-
#
|
91
|
+
# maintain order
|
92
|
+
# replace ciphertext attributes w/ virtual attributes (filtered)
|
75
93
|
def inspect
|
76
|
-
|
77
|
-
|
78
|
-
|
94
|
+
lockbox_attributes = {}
|
95
|
+
lockbox_encrypted_attributes = {}
|
96
|
+
self.class.lockbox_attributes.each do |_, lockbox_attribute|
|
97
|
+
lockbox_attributes[lockbox_attribute[:attribute]] = true
|
98
|
+
lockbox_encrypted_attributes[lockbox_attribute[:encrypted_attribute]] = lockbox_attribute[:attribute]
|
99
|
+
end
|
100
|
+
|
101
|
+
inspection = []
|
102
|
+
# use serializable_hash like Devise
|
103
|
+
values = serializable_hash
|
104
|
+
self.class.attribute_names.each do |k|
|
105
|
+
next if !has_attribute?(k) || lockbox_attributes[k]
|
106
|
+
|
107
|
+
# check for lockbox attribute
|
108
|
+
if lockbox_encrypted_attributes[k]
|
109
|
+
# check if ciphertext attribute nil to avoid loading attribute
|
110
|
+
v = send(k).nil? ? "nil" : "[FILTERED]"
|
111
|
+
k = lockbox_encrypted_attributes[k]
|
112
|
+
elsif values.key?(k)
|
113
|
+
v = respond_to?(:attribute_for_inspect) ? attribute_for_inspect(k) : values[k].inspect
|
114
|
+
|
115
|
+
# fix for https://github.com/rails/rails/issues/40725
|
116
|
+
# TODO only apply to Active Record 6.0
|
117
|
+
if respond_to?(:inspection_filter, true) && v != "nil"
|
118
|
+
v = inspection_filter.filter_param(k, v)
|
119
|
+
end
|
120
|
+
else
|
121
|
+
next
|
79
122
|
end
|
123
|
+
|
124
|
+
inspection << "#{k}: #{v}"
|
125
|
+
end
|
126
|
+
|
80
127
|
"#<#{self.class} #{inspection.join(", ")}>"
|
81
128
|
end
|
82
129
|
|
@@ -87,6 +134,9 @@ module Lockbox
|
|
87
134
|
# essentially a no-op if already loaded
|
88
135
|
# an exception is thrown if decryption fails
|
89
136
|
self.class.lockbox_attributes.each do |_, lockbox_attribute|
|
137
|
+
# don't try to decrypt if no decryption key given
|
138
|
+
next if lockbox_attribute[:algorithm] == "hybrid" && lockbox_attribute[:decryption_key].nil?
|
139
|
+
|
90
140
|
# it is possible that the encrypted attribute is not loaded, eg.
|
91
141
|
# if the record was fetched partially (`User.select(:id).first`).
|
92
142
|
# accessing a not loaded attribute raises an `ActiveModel::MissingAttributeError`.
|
@@ -161,6 +211,7 @@ module Lockbox
|
|
161
211
|
end
|
162
212
|
|
163
213
|
raise "Duplicate encrypted attribute: #{original_name}" if lockbox_attributes[original_name]
|
214
|
+
raise "Multiple encrypted attributes use the same column: #{encrypted_attribute}" if lockbox_attributes.any? { |_, v| v[:encrypted_attribute] == encrypted_attribute }
|
164
215
|
@lockbox_attributes[original_name] = options
|
165
216
|
|
166
217
|
if activerecord
|
@@ -263,12 +314,13 @@ module Lockbox
|
|
263
314
|
define_method("#{name}=") do |message|
|
264
315
|
# decrypt first for dirty tracking
|
265
316
|
# don't raise error if can't decrypt previous
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
317
|
+
# don't try to decrypt if no decryption key given
|
318
|
+
unless options[:algorithm] == "hybrid" && options[:decryption_key].nil?
|
319
|
+
begin
|
320
|
+
send(name)
|
321
|
+
rescue Lockbox::DecryptionError
|
322
|
+
warn "[lockbox] Decrypting previous value failed"
|
323
|
+
end
|
272
324
|
end
|
273
325
|
|
274
326
|
send("lockbox_direct_#{name}=", message)
|
@@ -334,7 +386,7 @@ module Lockbox
|
|
334
386
|
table = activerecord ? table_name : collection_name.to_s
|
335
387
|
|
336
388
|
unless message.nil?
|
337
|
-
# TODO use attribute type class in 0.
|
389
|
+
# TODO use attribute type class in 0.7.0
|
338
390
|
case options[:type]
|
339
391
|
when :boolean
|
340
392
|
message = ActiveRecord::Type::Boolean.new.serialize(message)
|
@@ -389,7 +441,7 @@ module Lockbox
|
|
389
441
|
end
|
390
442
|
|
391
443
|
unless message.nil?
|
392
|
-
# TODO use attribute type class in 0.
|
444
|
+
# TODO use attribute type class in 0.7.0
|
393
445
|
case options[:type]
|
394
446
|
when :boolean
|
395
447
|
message = message == "t"
|
data/lib/lockbox/utils.rb
CHANGED
@@ -1,6 +1,7 @@
|
|
1
1
|
module Lockbox
|
2
2
|
class Utils
|
3
3
|
def self.build_box(context, options, table, attribute)
|
4
|
+
# dup options (with except) since keys are sometimes changed or deleted
|
4
5
|
options = options.except(:attribute, :encrypted_attribute, :migrating, :attached, :type)
|
5
6
|
options[:encode] = false unless options.key?(:encode)
|
6
7
|
options.each do |k, v|
|
@@ -16,14 +17,32 @@ module Lockbox
|
|
16
17
|
end
|
17
18
|
|
18
19
|
unless options[:key] || options[:encryption_key] || options[:decryption_key]
|
19
|
-
options[:key] =
|
20
|
+
options[:key] =
|
21
|
+
Lockbox.attribute_key(
|
22
|
+
table: options.delete(:key_table) || table,
|
23
|
+
attribute: options.delete(:key_attribute) || attribute,
|
24
|
+
master_key: options.delete(:master_key),
|
25
|
+
encode: false
|
26
|
+
)
|
20
27
|
end
|
21
28
|
|
22
29
|
if options[:previous_versions].is_a?(Array)
|
23
|
-
|
30
|
+
# dup previous versions array (with map) since elements are updated
|
31
|
+
# dup each version (with dup) since keys are sometimes deleted
|
32
|
+
options[:previous_versions] = options[:previous_versions].map(&:dup)
|
24
33
|
options[:previous_versions].each_with_index do |version, i|
|
25
|
-
if !(version[:key] || version[:encryption_key] || version[:decryption_key]) && version[:master_key]
|
26
|
-
|
34
|
+
if !(version[:key] || version[:encryption_key] || version[:decryption_key]) && (version[:master_key] || version[:key_table] || version[:key_attribute])
|
35
|
+
# could also use key_table and key_attribute from options
|
36
|
+
# when specified, but keep simple for now
|
37
|
+
# also, this change isn't backward compatible
|
38
|
+
key =
|
39
|
+
Lockbox.attribute_key(
|
40
|
+
table: version.delete(:key_table) || table,
|
41
|
+
attribute: version.delete(:key_attribute) || attribute,
|
42
|
+
master_key: version.delete(:master_key),
|
43
|
+
encode: false
|
44
|
+
)
|
45
|
+
options[:previous_versions][i] = version.merge(key: key)
|
27
46
|
end
|
28
47
|
end
|
29
48
|
end
|
@@ -40,7 +59,7 @@ module Lockbox
|
|
40
59
|
key = [key].pack("H*")
|
41
60
|
end
|
42
61
|
|
43
|
-
raise Lockbox::Error, "#{name} must be
|
62
|
+
raise Lockbox::Error, "#{name} must be #{size} bytes (#{size * 2} hex digits)" if key.bytesize != size
|
44
63
|
raise Lockbox::Error, "#{name} must use binary encoding" if key.encoding != Encoding::BINARY
|
45
64
|
|
46
65
|
key
|
@@ -70,13 +89,11 @@ module Lockbox
|
|
70
89
|
attachable = attachable.dup
|
71
90
|
attachable[:io] = box.encrypt_io(io)
|
72
91
|
else
|
73
|
-
|
74
|
-
raise NotImplementedError, "Could not find or build blob: expected attachable, got #{attachable.inspect}"
|
92
|
+
raise ArgumentError, "Could not find or build blob: expected attachable, got #{attachable.inspect}"
|
75
93
|
end
|
76
94
|
|
77
95
|
# don't analyze encrypted data
|
78
|
-
metadata = {"analyzed" => true}
|
79
|
-
metadata["encrypted"] = true if options[:migrating]
|
96
|
+
metadata = {"analyzed" => true, "encrypted" => true}
|
80
97
|
attachable[:metadata] = (attachable[:metadata] || {}).merge(metadata)
|
81
98
|
end
|
82
99
|
|
data/lib/lockbox/version.rb
CHANGED
metadata
CHANGED
@@ -1,14 +1,14 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: lockbox
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 0.
|
4
|
+
version: 0.6.0
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Andrew Kane
|
8
|
-
autorequire:
|
8
|
+
autorequire:
|
9
9
|
bindir: bin
|
10
10
|
cert_chain: []
|
11
|
-
date: 2020-
|
11
|
+
date: 2020-12-03 00:00:00.000000000 Z
|
12
12
|
dependencies:
|
13
13
|
- !ruby/object:Gem::Dependency
|
14
14
|
name: bundler
|
@@ -150,6 +150,34 @@ dependencies:
|
|
150
150
|
- - ">="
|
151
151
|
- !ruby/object:Gem::Version
|
152
152
|
version: '0'
|
153
|
+
- !ruby/object:Gem::Dependency
|
154
|
+
name: shrine
|
155
|
+
requirement: !ruby/object:Gem::Requirement
|
156
|
+
requirements:
|
157
|
+
- - ">="
|
158
|
+
- !ruby/object:Gem::Version
|
159
|
+
version: '0'
|
160
|
+
type: :development
|
161
|
+
prerelease: false
|
162
|
+
version_requirements: !ruby/object:Gem::Requirement
|
163
|
+
requirements:
|
164
|
+
- - ">="
|
165
|
+
- !ruby/object:Gem::Version
|
166
|
+
version: '0'
|
167
|
+
- !ruby/object:Gem::Dependency
|
168
|
+
name: shrine-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'
|
153
181
|
- !ruby/object:Gem::Dependency
|
154
182
|
name: benchmark-ips
|
155
183
|
requirement: !ruby/object:Gem::Requirement
|
@@ -164,7 +192,7 @@ dependencies:
|
|
164
192
|
- - ">="
|
165
193
|
- !ruby/object:Gem::Version
|
166
194
|
version: '0'
|
167
|
-
description:
|
195
|
+
description:
|
168
196
|
email: andrew@chartkick.com
|
169
197
|
executables: []
|
170
198
|
extensions: []
|
@@ -197,7 +225,7 @@ homepage: https://github.com/ankane/lockbox
|
|
197
225
|
licenses:
|
198
226
|
- MIT
|
199
227
|
metadata: {}
|
200
|
-
post_install_message:
|
228
|
+
post_install_message:
|
201
229
|
rdoc_options: []
|
202
230
|
require_paths:
|
203
231
|
- lib
|
@@ -212,8 +240,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
212
240
|
- !ruby/object:Gem::Version
|
213
241
|
version: '0'
|
214
242
|
requirements: []
|
215
|
-
rubygems_version: 3.1.
|
216
|
-
signing_key:
|
243
|
+
rubygems_version: 3.1.4
|
244
|
+
signing_key:
|
217
245
|
specification_version: 4
|
218
|
-
summary: Modern encryption for Rails
|
246
|
+
summary: Modern encryption for Ruby and Rails
|
219
247
|
test_files: []
|