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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2fb82c6baf0c56ae7f051c8363f7cb52554ebab8180b891d4aa25caf7505cec3
4
- data.tar.gz: 64b6bda4259cc3fddd7e5635333b0b7c4a6680c4ff2da31bba735603ea50b010
3
+ metadata.gz: 77945cdf065bda9282a4a9cbffd77ebe518bcbe11c3e3ccaa91768bd1579f94c
4
+ data.tar.gz: ab052f812a91e1620dcc52ac578006b55e84d5aae1770ecb5c6c89c5119f9073
5
5
  SHA512:
6
- metadata.gz: e1953d9159c2cb1f1ec55436d925738777ea0b78afedcb32864280f7772d1d9e12a48f65c69385fd6bcdab166668049b3889f1e17e3b316c696705bcb98650dd
7
- data.tar.gz: a3559c399a385949526137eed03b73c38baae424bed3b597c89f287222e6e18fd2c5665b2cbba6b20dad71bf680f0840ee2753a68befd3af40fdb522e45e4088
6
+ metadata.gz: 8223d89af7efa1e4192c48512a45177d877df2c1878da425b05bf4e2b066c1f3a413b57f8f0fbdc73a04f000db191bfde2ef8bb0319c4bce7eee6a1985a3771f
7
+ data.tar.gz: 7bcb4b647f4abdc8141c571441ce1c8e47b5864aee3e043cd7f620ca7f9abc208ddb1134ac6f35700c73a30f934c56169ff5bca235ba32faba4a0d2325bc926a
@@ -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://travis-ci.org/ankane/lockbox.svg?branch=master)](https://travis-ci.org/ankane/lockbox)
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
- - Metadata like image width and height are not extracted when encrypted
268
- - Direct uploads cannot be encrypted
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
- Generate a key
445
+ #### Models
446
+
447
+ Include the attachment as normal:
394
448
 
395
449
  ```ruby
396
- key = Lockbox.generate_key
450
+ class User < ApplicationRecord
451
+ include LicenseUploader::Attachment(:license)
452
+ end
397
453
  ```
398
454
 
399
- Create a lockbox
455
+ And encrypt in a controller (or background job, etc) with:
400
456
 
401
457
  ```ruby
402
- lockbox = Lockbox.new(key: key)
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
- Encrypt files before passing them to Shrine
463
+ To serve encrypted files, use a controller action.
406
464
 
407
465
  ```ruby
408
- LicenseUploader.upload(lockbox.encrypt_io(file), :store)
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
- And decrypt them after reading
473
+ #### Non-Models
474
+
475
+ Generate a key
412
476
 
413
477
  ```ruby
414
- lockbox.decrypt(uploaded_file.read)
478
+ key = Lockbox.generate_key
415
479
  ```
416
480
 
417
- For models, encrypt with:
481
+ Create a lockbox
418
482
 
419
483
  ```ruby
420
- license = params.require(:user).fetch(:license)
421
- user.license = lockbox.encrypt_io(license)
484
+ lockbox = Lockbox.new(key: key)
422
485
  ```
423
486
 
424
- To serve encrypted files, use a controller action.
487
+ Encrypt files before passing them to Shrine
425
488
 
426
489
  ```ruby
427
- def license
428
- user = User.find(params[:id])
429
- send_data lockbox.decrypt(user.license.read), type: user.license.mime_type
430
- end
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 Separation
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
- 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:
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
- And set it directly before renaming:
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. If we have a status larger than 15 bytes, it will have a different length than the others.
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 to save space.
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
- You should disable Base64 encoding if you do this.
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.
@@ -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
- # ideally encrypt and decrypt would happen at the blob/service level
2
- # however, there isn't really a great place to define encryption settings there
3
- # instead, we encrypt and decrypt at the attachment level,
4
- # and we define encryption settings at the model level
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|
@@ -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 = cipher.update(message) + cipher.final
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
- cipher.update(ciphertext) + cipher.final
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
@@ -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 public/private key" if key && (encryption_key || decryption_key)
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 public key set" unless @encryption_box
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 private key set" unless @decryption_box
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)
@@ -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(&:first)
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)
@@ -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
@@ -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
- # add default options
32
- encrypted_attribute = "#{name}_ciphertext"
37
+ # per attribute options
38
+ # TODO use a different name
39
+ options = original_options.dup
33
40
 
34
- options = options.dup
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
- # use same approach as devise
91
+ # maintain order
92
+ # replace ciphertext attributes w/ virtual attributes (filtered)
75
93
  def inspect
76
- inspection =
77
- serializable_hash.map do |k,v|
78
- "#{k}: #{respond_to?(:attribute_for_inspect) ? attribute_for_inspect(k) : v.inspect}"
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
- begin
267
- send(name)
268
- rescue Lockbox::DecryptionError
269
- # this is expected for hybrid cryptography
270
- warn "[lockbox] Decrypting previous value failed" unless options[:algorithm] == "hybrid"
271
- nil
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.5.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.5.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"
@@ -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] = Lockbox.attribute_key(table: table, attribute: attribute, master_key: options.delete(:master_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
- options[:previous_versions] = options[:previous_versions].dup
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
- options[:previous_versions][i] = version.merge(key: Lockbox.attribute_key(table: table, attribute: attribute, master_key: version.delete(:master_key)))
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 32 bytes (64 hex digits)" if key.bytesize != size
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
- # TODO raise ArgumentError
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
 
@@ -1,3 +1,3 @@
1
1
  module Lockbox
2
- VERSION = "0.4.6"
2
+ VERSION = "0.6.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.4.6
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-07-03 00:00:00.000000000 Z
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.2
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: []