lockbox 0.4.6 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 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: []