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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 732b29630e94d7e05292a10ff4106f05152418a335fb7a5e7da9b5a632c05856
4
- data.tar.gz: 8d7d40d7d4f8bb5ecc4737c4664a87bfc33d1f331b0a46d36ebc6a3ce5573ee2
3
+ metadata.gz: 3c892b20d24ec1db29f60f508b0103c315450bf85f71e3d5ff9b92d3f21d1cca
4
+ data.tar.gz: 000fdbc4bc30ba97738d3823b719bbb37d23c4d926792d2f65197043502e843e
5
5
  SHA512:
6
- metadata.gz: 11ba243e0e997a3140a5f3682f38023b9dcf29fdd9c181ea9f693404b4204bd7e83a4caef669126ff9fe432882773ff8f52e129117c201f0dc6eae7de9539849
7
- data.tar.gz: 206197fbff415597cb816f77d9354fc8b2ee0c60ceccfb1b5414a44cf19a14b1b9224e09d1c7b0148a9ecb4b0d6673f70f2f35008b6a105bab8e02a55fdf81ce
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
@@ -1,6 +1,6 @@
1
1
  The MIT License (MIT)
2
2
 
3
- Copyright (c) 2018-2025 Andrew Kane
3
+ Copyright (c) 2018-2026 Andrew Kane
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
data/README.md CHANGED
@@ -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.0]
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.0]
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.0]
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.0]
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.0]
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.0]
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 = Lockbox::Utils.decode_key(encryption_key, size: 64) if encryption_key
10
- decryption_key = Lockbox::Utils.decode_key(decryption_key, size: 64) if 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
- @encryption_box = RbNaCl::Boxes::Curve25519XSalsa20Poly1305.new(encryption_key.slice(0, 32), encryption_key.slice(32..-1)) if encryption_key
30
- @decryption_box = RbNaCl::Boxes::Curve25519XSalsa20Poly1305.new(decryption_key.slice(32..-1), decryption_key.slice(0, 32)) if decryption_key
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
- nonce = generate_nonce(@encryption_box)
48
- ciphertext = @encryption_box.encrypt(nonce, message)
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
- ciphertext = @box.encrypt(nonce, message)
66
+ nonce + @box.encrypt(nonce, message)
53
67
  else
54
68
  nonce = generate_nonce(@box)
55
- ciphertext = @box.encrypt(nonce, message, associated_data)
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
- nonce, ciphertext = extract_nonce(@decryption_box, ciphertext)
68
- @decryption_box.decrypt(nonce, ciphertext)
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
@@ -1,6 +1,8 @@
1
1
  module Lockbox
2
2
  module CarrierWaveExtensions
3
3
  def encrypt(**options)
4
+ Utils.check_options(options)
5
+
4
6
  class_eval do
5
7
  # uses same hook as process (before cache)
6
8
  # processing can be disabled, so better to keep separate
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
- if ActiveRecord::VERSION::STRING.to_f >= 7.2
263
- def self.insert(attributes, **options)
264
- super(lockbox_map_record_attributes(attributes), **options)
265
- end
264
+ def self.insert(attributes, **options)
265
+ super(lockbox_map_record_attributes(attributes), **options)
266
+ end
266
267
 
267
- def self.insert!(attributes, **options)
268
- super(lockbox_map_record_attributes(attributes), **options)
269
- end
268
+ def self.insert!(attributes, **options)
269
+ super(lockbox_map_record_attributes(attributes), **options)
270
+ end
270
271
 
271
- def self.upsert(attributes, **options)
272
- super(lockbox_map_record_attributes(attributes, check_readonly: true), **options)
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
- if ActiveRecord::VERSION::STRING.to_f >= 7.2
348
- # TODO improve
349
- if pending_attribute_modifications.any? { |v| v.is_a?(ActiveModel::AttributeRegistration::ClassMethods::PendingDefault) && v.name == name.to_s }
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
- elsif ActiveRecord::VERSION::STRING.to_f >= 7.2
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("@value_before_type_cast", message)
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
@@ -1,3 +1,3 @@
1
1
  module Lockbox
2
- VERSION = "2.1.0"
2
+ VERSION = "2.3.0"
3
3
  end
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
- # encryption and decryption servers exchange public keys
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(bob.public_key.to_bytes + alice.to_bytes),
61
- decryption_key: to_hex(bob.to_bytes + alice.public_key.to_bytes)
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.1
102
- if ar_version >= 7.0
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.1.0
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.2'
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: 3.6.9
58
+ rubygems_version: 4.0.20
59
59
  specification_version: 4
60
60
  summary: Modern encryption for Ruby and Rails
61
61
  test_files: []