lockbox 2.1.0 → 2.3.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 +4 -4
- data/CHANGELOG.md +8 -0
- data/LICENSE.txt +1 -1
- data/README.md +6 -6
- data/lib/lockbox/active_storage_extensions.rb +0 -13
- data/lib/lockbox/box.rb +38 -11
- data/lib/lockbox/carrier_wave_extensions.rb +2 -0
- data/lib/lockbox/model.rb +17 -47
- data/lib/lockbox/utils.rb +10 -0
- data/lib/lockbox/version.rb +1 -1
- data/lib/lockbox.rb +7 -10
- metadata +3 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3c892b20d24ec1db29f60f508b0103c315450bf85f71e3d5ff9b92d3f21d1cca
|
|
4
|
+
data.tar.gz: 000fdbc4bc30ba97738d3823b719bbb37d23c4d926792d2f65197043502e843e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4fc7026684f02a5f8a34e827c16566425e2987efc5f5b02ff905431e1d1e9bdbf2ac542d87413418d8c0cb2b3d8feb2ce58c61aaed96b1dc32bf2a490cea8c4c
|
|
7
|
+
data.tar.gz: a04a81cab9ea3b9c8b8591846fe10866610fadd4829e04ffdc6bbd67138df307a78e05da0cf820bd1a44544a54980191202a4afd8b8b9e5f4ca1bf6f8109c529
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.3.0 (2026-11-10)
|
|
2
|
+
|
|
3
|
+
- Fixed hybrid encryption key being able to decrypt - [more info](https://github.com/ankane/lockbox/security/advisories/GHSA-586r-mm5q-xhg2)
|
|
4
|
+
|
|
5
|
+
## 2.2.0 (2026-04-04)
|
|
6
|
+
|
|
7
|
+
- Dropped support for Active Record < 7.2 and Ruby < 3.3
|
|
8
|
+
|
|
1
9
|
## 2.1.0 (2025-10-15)
|
|
2
10
|
|
|
3
11
|
- Added warning for `download_chunk` method
|
data/LICENSE.txt
CHANGED
data/README.md
CHANGED
|
@@ -72,7 +72,7 @@ Then follow the instructions below for the data you want to encrypt.
|
|
|
72
72
|
Create a migration with:
|
|
73
73
|
|
|
74
74
|
```ruby
|
|
75
|
-
class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.
|
|
75
|
+
class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.1]
|
|
76
76
|
def change
|
|
77
77
|
add_column :users, :email_ciphertext, :text
|
|
78
78
|
end
|
|
@@ -251,7 +251,7 @@ User.decrypt_email_ciphertext(user.email_ciphertext)
|
|
|
251
251
|
Create a migration with:
|
|
252
252
|
|
|
253
253
|
```ruby
|
|
254
|
-
class AddBodyCiphertextToRichTexts < ActiveRecord::Migration[8.
|
|
254
|
+
class AddBodyCiphertextToRichTexts < ActiveRecord::Migration[8.1]
|
|
255
255
|
def change
|
|
256
256
|
add_column :action_text_rich_texts, :body_ciphertext, :text
|
|
257
257
|
end
|
|
@@ -382,7 +382,7 @@ Encryption is applied to all versions after processing.
|
|
|
382
382
|
You can mount the uploader [as normal](https://github.com/carrierwaveuploader/carrierwave#activerecord). With Active Record, this involves creating a migration:
|
|
383
383
|
|
|
384
384
|
```ruby
|
|
385
|
-
class AddLicenseToUsers < ActiveRecord::Migration[8.
|
|
385
|
+
class AddLicenseToUsers < ActiveRecord::Migration[8.1]
|
|
386
386
|
def change
|
|
387
387
|
add_column :users, :license, :string
|
|
388
388
|
end
|
|
@@ -910,7 +910,7 @@ end
|
|
|
910
910
|
You can use `binary` columns for the ciphertext instead of `text` columns.
|
|
911
911
|
|
|
912
912
|
```ruby
|
|
913
|
-
class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.
|
|
913
|
+
class AddEmailCiphertextToUsers < ActiveRecord::Migration[8.1]
|
|
914
914
|
def change
|
|
915
915
|
add_column :users, :email_ciphertext, :binary
|
|
916
916
|
end
|
|
@@ -961,7 +961,7 @@ end
|
|
|
961
961
|
Create a migration with:
|
|
962
962
|
|
|
963
963
|
```ruby
|
|
964
|
-
class MigrateToLockbox < ActiveRecord::Migration[8.
|
|
964
|
+
class MigrateToLockbox < ActiveRecord::Migration[8.1]
|
|
965
965
|
def change
|
|
966
966
|
add_column :users, :name_ciphertext, :text
|
|
967
967
|
add_column :users, :email_ciphertext, :text
|
|
@@ -994,7 +994,7 @@ end
|
|
|
994
994
|
Then remove the previous gem from your Gemfile and drop its columns.
|
|
995
995
|
|
|
996
996
|
```ruby
|
|
997
|
-
class RemovePreviousEncryptedColumns < ActiveRecord::Migration[8.
|
|
997
|
+
class RemovePreviousEncryptedColumns < ActiveRecord::Migration[8.1]
|
|
998
998
|
def change
|
|
999
999
|
remove_column :users, :encrypted_name, :text
|
|
1000
1000
|
remove_column :users, :encrypted_name_iv, :text
|
|
@@ -118,13 +118,6 @@ module Lockbox
|
|
|
118
118
|
super
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
if ActiveStorage::VERSION::STRING.to_f == 7.1 && ActiveStorage.version >= "7.1.4"
|
|
122
|
-
def transform_variants_later
|
|
123
|
-
blob.instance_variable_set(:@lockbox_encrypted, true) if Utils.encrypted_options(record, name)
|
|
124
|
-
super
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
121
|
def open(**options)
|
|
129
122
|
blob.open(**options) do |file|
|
|
130
123
|
options = Utils.encrypted_options(record, name)
|
|
@@ -149,12 +142,6 @@ module Lockbox
|
|
|
149
142
|
end
|
|
150
143
|
|
|
151
144
|
module Blob
|
|
152
|
-
if ActiveStorage::VERSION::STRING.to_f == 7.1 && ActiveStorage.version >= "7.1.4"
|
|
153
|
-
def preview_image_needed_before_processing_variants?
|
|
154
|
-
!instance_variable_defined?(:@lockbox_encrypted) && super
|
|
155
|
-
end
|
|
156
|
-
end
|
|
157
|
-
|
|
158
145
|
private
|
|
159
146
|
|
|
160
147
|
def extract_content_type(io)
|
data/lib/lockbox/box.rb
CHANGED
|
@@ -6,8 +6,8 @@ module Lockbox
|
|
|
6
6
|
raise ArgumentError, "Cannot pass both key and encryption/decryption key" if key && (encryption_key || decryption_key)
|
|
7
7
|
|
|
8
8
|
key = Lockbox::Utils.decode_key(key) if key
|
|
9
|
-
encryption_key =
|
|
10
|
-
decryption_key =
|
|
9
|
+
encryption_key, legacy_encryption = decode_hybrid_key(encryption_key) if encryption_key
|
|
10
|
+
decryption_key, legacy_decryption = decode_hybrid_key(decryption_key) if decryption_key
|
|
11
11
|
|
|
12
12
|
algorithm ||= "aes-gcm"
|
|
13
13
|
|
|
@@ -26,8 +26,22 @@ module Lockbox
|
|
|
26
26
|
when "hybrid"
|
|
27
27
|
raise ArgumentError, "Missing key" unless encryption_key || decryption_key
|
|
28
28
|
require "rbnacl"
|
|
29
|
-
|
|
30
|
-
|
|
29
|
+
if encryption_key
|
|
30
|
+
@encryption_box =
|
|
31
|
+
if legacy_encryption
|
|
32
|
+
nil
|
|
33
|
+
else
|
|
34
|
+
RbNaCl::Boxes::Sealed.from_public_key(encryption_key)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
if decryption_key
|
|
38
|
+
@decryption_box =
|
|
39
|
+
if legacy_decryption
|
|
40
|
+
RbNaCl::Boxes::Curve25519XSalsa20Poly1305.new(decryption_key.byteslice(32..-1), decryption_key.byteslice(0, 32))
|
|
41
|
+
else
|
|
42
|
+
RbNaCl::Boxes::Sealed.from_private_key(decryption_key)
|
|
43
|
+
end
|
|
44
|
+
end
|
|
31
45
|
else
|
|
32
46
|
raise ArgumentError, "Unknown algorithm: #{algorithm}"
|
|
33
47
|
end
|
|
@@ -44,17 +58,16 @@ module Lockbox
|
|
|
44
58
|
when "hybrid"
|
|
45
59
|
raise ArgumentError, "No encryption key set" unless defined?(@encryption_box)
|
|
46
60
|
raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
|
|
47
|
-
|
|
48
|
-
|
|
61
|
+
Utils.raise_hybrid_error if @encryption_box.nil?
|
|
62
|
+
@encryption_box.encrypt(message)
|
|
49
63
|
when "xsalsa20"
|
|
50
64
|
raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
|
|
51
65
|
nonce = generate_nonce(@box)
|
|
52
|
-
|
|
66
|
+
nonce + @box.encrypt(nonce, message)
|
|
53
67
|
else
|
|
54
68
|
nonce = generate_nonce(@box)
|
|
55
|
-
|
|
69
|
+
nonce + @box.encrypt(nonce, message, associated_data)
|
|
56
70
|
end
|
|
57
|
-
nonce + ciphertext
|
|
58
71
|
end
|
|
59
72
|
|
|
60
73
|
def decrypt(ciphertext, associated_data: NOT_SET)
|
|
@@ -64,8 +77,12 @@ module Lockbox
|
|
|
64
77
|
when "hybrid"
|
|
65
78
|
raise ArgumentError, "No decryption key set" unless defined?(@decryption_box)
|
|
66
79
|
raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
|
|
67
|
-
|
|
68
|
-
|
|
80
|
+
if @decryption_box.is_a?(RbNaCl::Boxes::Sealed)
|
|
81
|
+
@decryption_box.decrypt(ciphertext)
|
|
82
|
+
else
|
|
83
|
+
nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
|
|
84
|
+
@decryption_box.decrypt(nonce, ciphertext)
|
|
85
|
+
end
|
|
69
86
|
when "xsalsa20"
|
|
70
87
|
raise ArgumentError, "Associated data not supported with this algorithm" if associated_data
|
|
71
88
|
nonce, ciphertext = extract_nonce(@box, ciphertext)
|
|
@@ -94,5 +111,15 @@ module Lockbox
|
|
|
94
111
|
nonce = bytes.slice(0, nonce_bytes)
|
|
95
112
|
[nonce, bytes.slice(nonce_bytes..-1)]
|
|
96
113
|
end
|
|
114
|
+
|
|
115
|
+
def decode_hybrid_key(key)
|
|
116
|
+
[Lockbox::Utils.decode_key(key), false]
|
|
117
|
+
rescue Error => e
|
|
118
|
+
begin
|
|
119
|
+
[Lockbox::Utils.decode_key(key, size: 64), true]
|
|
120
|
+
rescue Error
|
|
121
|
+
raise e
|
|
122
|
+
end
|
|
123
|
+
end
|
|
97
124
|
end
|
|
98
125
|
end
|
data/lib/lockbox/model.rb
CHANGED
|
@@ -23,6 +23,8 @@ module Lockbox
|
|
|
23
23
|
# options[:type] = :decimal
|
|
24
24
|
# end
|
|
25
25
|
|
|
26
|
+
Utils.check_options(options)
|
|
27
|
+
|
|
26
28
|
custom_type = options[:type].respond_to?(:serialize) && options[:type].respond_to?(:deserialize)
|
|
27
29
|
valid_types = [nil, :string, :boolean, :date, :datetime, :time, :integer, :float, :decimal, :binary, :json, :hash, :array, :inet]
|
|
28
30
|
raise ArgumentError, "Unknown type: #{options[:type]}" unless custom_type || valid_types.include?(options[:type])
|
|
@@ -259,18 +261,16 @@ module Lockbox
|
|
|
259
261
|
result
|
|
260
262
|
end
|
|
261
263
|
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
end
|
|
264
|
+
def self.insert(attributes, **options)
|
|
265
|
+
super(lockbox_map_record_attributes(attributes), **options)
|
|
266
|
+
end
|
|
266
267
|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
268
|
+
def self.insert!(attributes, **options)
|
|
269
|
+
super(lockbox_map_record_attributes(attributes), **options)
|
|
270
|
+
end
|
|
270
271
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
end
|
|
272
|
+
def self.upsert(attributes, **options)
|
|
273
|
+
super(lockbox_map_record_attributes(attributes, check_readonly: true), **options)
|
|
274
274
|
end
|
|
275
275
|
|
|
276
276
|
def self.insert_all(attributes, **options)
|
|
@@ -344,20 +344,9 @@ module Lockbox
|
|
|
344
344
|
end
|
|
345
345
|
|
|
346
346
|
# warn on default attributes
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
warn "[lockbox] WARNING: attributes with `:default` option are not supported. Use `after_initialize` instead."
|
|
351
|
-
end
|
|
352
|
-
elsif attributes_to_define_after_schema_loads.key?(name.to_s)
|
|
353
|
-
opt = attributes_to_define_after_schema_loads[name.to_s][1]
|
|
354
|
-
|
|
355
|
-
# not ideal, since NO_DEFAULT_PROVIDED is private
|
|
356
|
-
has_default = opt != ActiveRecord::Attributes::ClassMethods.const_get(:NO_DEFAULT_PROVIDED)
|
|
357
|
-
|
|
358
|
-
if has_default
|
|
359
|
-
warn "[lockbox] WARNING: attributes with `:default` option are not supported. Use `after_initialize` instead."
|
|
360
|
-
end
|
|
347
|
+
# TODO improve
|
|
348
|
+
if pending_attribute_modifications.any? { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingDefault) && v.name == name.to_s }
|
|
349
|
+
warn "[lockbox] WARNING: attributes with `:default` option are not supported. Use `after_initialize` instead."
|
|
361
350
|
end
|
|
362
351
|
|
|
363
352
|
# preference:
|
|
@@ -385,7 +374,7 @@ module Lockbox
|
|
|
385
374
|
when :array
|
|
386
375
|
serialize name, type: Array, coder: default_column_serializer || YAML
|
|
387
376
|
end
|
|
388
|
-
|
|
377
|
+
else
|
|
389
378
|
decorate_attributes([name]) do |attr_name, cast_type|
|
|
390
379
|
if cast_type.instance_of?(ActiveRecord::Type::Value)
|
|
391
380
|
original_type = pending_attribute_modifications.find { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingType) && v.name == original_name.to_s && !v.type.nil? }&.type
|
|
@@ -405,27 +394,6 @@ module Lockbox
|
|
|
405
394
|
cast_type
|
|
406
395
|
end
|
|
407
396
|
end
|
|
408
|
-
elsif !attributes_to_define_after_schema_loads.key?(name.to_s)
|
|
409
|
-
# when migrating it's best to specify the type directly
|
|
410
|
-
# however, we can try to use the original type if its already defined
|
|
411
|
-
if attributes_to_define_after_schema_loads.key?(original_name.to_s)
|
|
412
|
-
attribute name, attributes_to_define_after_schema_loads[original_name.to_s].first
|
|
413
|
-
elsif options[:migrating]
|
|
414
|
-
# we use the original attribute for serialization in the encrypt and decrypt methods
|
|
415
|
-
# so we can use a generic value here
|
|
416
|
-
attribute name, ActiveRecord::Type::Value.new
|
|
417
|
-
else
|
|
418
|
-
attribute name, :string
|
|
419
|
-
end
|
|
420
|
-
elsif attributes_to_define_after_schema_loads[name.to_s].first.is_a?(Proc)
|
|
421
|
-
# hack for Active Record 6.1+ to set string type after serialize
|
|
422
|
-
# otherwise, type gets set to ActiveModel::Type::Value
|
|
423
|
-
# which always returns false for changed_in_place?
|
|
424
|
-
# earlier versions of Active Record take the previous code path
|
|
425
|
-
attribute_type = attributes_to_define_after_schema_loads[name.to_s].first.call(nil)
|
|
426
|
-
if attribute_type.is_a?(ActiveRecord::Type::Serialized) && attribute_type.subtype.nil?
|
|
427
|
-
attribute name, ActiveRecord::Type::Serialized.new(ActiveRecord::Type::String.new, attribute_type.coder)
|
|
428
|
-
end
|
|
429
397
|
end
|
|
430
398
|
|
|
431
399
|
define_method("#{name}_was") do
|
|
@@ -550,7 +518,7 @@ module Lockbox
|
|
|
550
518
|
# set previous attribute so changes populate correctly
|
|
551
519
|
# it's fine if this is set on future decryptions (as is the case when message is nil)
|
|
552
520
|
# as only the first value is loaded into changes
|
|
553
|
-
@attributes[name.to_s].instance_variable_set(
|
|
521
|
+
@attributes[name.to_s].instance_variable_set(:@value_before_type_cast, message)
|
|
554
522
|
|
|
555
523
|
# cache
|
|
556
524
|
# decrypt method does type casting
|
|
@@ -711,6 +679,8 @@ module Lockbox
|
|
|
711
679
|
|
|
712
680
|
module Attached
|
|
713
681
|
def encrypts_attached(*attributes, **options)
|
|
682
|
+
Utils.check_options(options)
|
|
683
|
+
|
|
714
684
|
attributes.each do |name|
|
|
715
685
|
name = name.to_sym
|
|
716
686
|
|
data/lib/lockbox/utils.rb
CHANGED
|
@@ -121,5 +121,15 @@ module Lockbox
|
|
|
121
121
|
content_type: attachment.content_type
|
|
122
122
|
}
|
|
123
123
|
end
|
|
124
|
+
|
|
125
|
+
def self.check_options(options)
|
|
126
|
+
if (options[:encryption_key].is_a?(String) && options[:encryption_key].bytesize == 128) || (options[:decryption_key].is_a?(String) && options[:decryption_key].bytesize == 128)
|
|
127
|
+
raise_hybrid_error
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def self.raise_hybrid_error
|
|
132
|
+
raise Lockbox::Error, "Rotate your hybrid cryptography keys and data to address GHSA-586r-mm5q-xhg2"
|
|
133
|
+
end
|
|
124
134
|
end
|
|
125
135
|
end
|
data/lib/lockbox/version.rb
CHANGED
data/lib/lockbox.rb
CHANGED
|
@@ -50,15 +50,10 @@ module Lockbox
|
|
|
50
50
|
|
|
51
51
|
def self.generate_key_pair
|
|
52
52
|
require "rbnacl"
|
|
53
|
-
|
|
54
|
-
# this produces smaller ciphertext than sealed box
|
|
55
|
-
alice = RbNaCl::PrivateKey.generate
|
|
56
|
-
bob = RbNaCl::PrivateKey.generate
|
|
57
|
-
# alice is sending message to bob
|
|
58
|
-
# use bob first in both cases to prevent keys being swappable
|
|
53
|
+
key = RbNaCl::PrivateKey.generate
|
|
59
54
|
{
|
|
60
|
-
encryption_key: to_hex(
|
|
61
|
-
decryption_key: to_hex(
|
|
55
|
+
encryption_key: to_hex(key.public_key.to_bytes),
|
|
56
|
+
decryption_key: to_hex(key.to_bytes)
|
|
62
57
|
}
|
|
63
58
|
end
|
|
64
59
|
|
|
@@ -98,8 +93,10 @@ end
|
|
|
98
93
|
if defined?(ActiveSupport.on_load)
|
|
99
94
|
ActiveSupport.on_load(:active_record) do
|
|
100
95
|
ar_version = ActiveRecord::VERSION::STRING.to_f
|
|
101
|
-
if ar_version < 7.
|
|
102
|
-
if ar_version >= 7.
|
|
96
|
+
if ar_version < 7.2
|
|
97
|
+
if ar_version >= 7.1
|
|
98
|
+
raise Lockbox::Error, "Active Record #{ActiveRecord::VERSION::STRING} requires Lockbox < 2.2"
|
|
99
|
+
elsif ar_version >= 7.0
|
|
103
100
|
raise Lockbox::Error, "Active Record #{ActiveRecord::VERSION::STRING} requires Lockbox < 2.1"
|
|
104
101
|
elsif ar_version >= 5.2
|
|
105
102
|
raise Lockbox::Error, "Active Record #{ActiveRecord::VERSION::STRING} requires Lockbox < 2"
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: lockbox
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.
|
|
4
|
+
version: 2.3.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Andrew Kane
|
|
@@ -48,14 +48,14 @@ required_ruby_version: !ruby/object:Gem::Requirement
|
|
|
48
48
|
requirements:
|
|
49
49
|
- - ">="
|
|
50
50
|
- !ruby/object:Gem::Version
|
|
51
|
-
version: '3.
|
|
51
|
+
version: '3.3'
|
|
52
52
|
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
53
53
|
requirements:
|
|
54
54
|
- - ">="
|
|
55
55
|
- !ruby/object:Gem::Version
|
|
56
56
|
version: '0'
|
|
57
57
|
requirements: []
|
|
58
|
-
rubygems_version:
|
|
58
|
+
rubygems_version: 4.0.20
|
|
59
59
|
specification_version: 4
|
|
60
60
|
summary: Modern encryption for Ruby and Rails
|
|
61
61
|
test_files: []
|