concerns_on_rails 1.21.0 → 1.21.1

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: c419970fee56d2f4d1d02f534c07abd646219db975823b089f1801192eb15689
4
- data.tar.gz: 266601e25a1dd81854dacc62f77c85d913e7dc633614a32001b43359abe33069
3
+ metadata.gz: 87a3ceebf2b9eb0f8a00c9d6a8cc57d8f50c8b1ff698be887aef42da64eb08c3
4
+ data.tar.gz: 1a82dd3dbe363e65f3a0d91c66cb453d54415032def91e90a388663ce2e90dab
5
5
  SHA512:
6
- metadata.gz: 5069eb540a84ecda03c062eeae3dea59ec47ecff05d6107d1c198b936a0c55c2a8364a558c4376d7ee38ad9c082e5ddf0aafd72042a2370415f5056714a71098
7
- data.tar.gz: 65f7b71e32329fbb8575342f44349757f37a835b913334ebf6f362b015962c70c5f3e8fd45fac0afdace8da2914beb2c49a1d74b564c36e3795de4a2b7f1b9b6
6
+ metadata.gz: ebfb6e59e582240aab7bc0fe18e64e48f1f291f482b38df2dac97c3cc843f5731857594724868484fee75b9269ccaca923f6d229d862b3ce4ddfdf49c3f9aa27
7
+ data.tar.gz: 53d1b47d39588d102a4b6527b43b1801a1f87c47c1da7a67b823fb2138160e0dc8e1de9ea6d3b16070a2a33ca247a88832556de01e512b8ac474a9b5de288b6e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.21.1 (2026-07-18)
4
+
5
+ Blind-index support for Models::Encryptable, making encrypted fields queryable by exact match — the follow-up promised in 1.21.0. 991 examples, 0 failures.
6
+
7
+ ### Added
8
+ - **Models::Encryptable**: `blind_index:` option for exact-match lookups on otherwise-unqueryable encrypted columns. Encryption stays non-deterministic by design, so opting in with `encryptable :email, blind_index: true` maintains a deterministic keyed HMAC of the value in a companion `<field>_bidx` column and generates `find_by_<field>` / `where_<field>` / `<field>_fingerprint` class methods. `where_<field>` accepts one value, several, or an array (multiple become an IN query) and returns a plain Relation, so it chains with scopes, `.or`, and `.merge` for joins (`Order.joins(:user).merge(User.where_email(...))`). The fingerprint HMAC key is domain-separated from the AES key (a labeled-HMAC subkey via `Support::Encryptor.blind_index`), so the two are cryptographically independent while both driven from the configured key. `blind_index: { column:, expression: }` overrides the companion column and supplies a normalization callable (e.g. `->(v) { v.to_s.downcase }`) applied symmetrically on write and query for case-insensitive lookups. The index is recomputed in `before_save` only when the plaintext changes, stores `nil` for a nil value, and never contains plaintext. Macro-time `ArgumentError` validation: the blind-index column must exist, a custom `column:` cannot combine with multiple fields, and `expression:` must be callable. Key resolution was extracted to `Encryption::Config#resolve_material` so encryption and blind indexing share one path. Deterministic equality leaks (identical values share a digest) — documented; use it only for lookup keys. Zero new runtime dependencies.
9
+
3
10
  ## 1.21.0 (2026-07-01)
4
11
 
5
12
  A new model concern for transparent field-level encryption — the "encrypt SSN/DOB at rest" capability the sensitive-data toolkit (Maskable, Sanitizable, Tokenizable) was missing. Hand-rolled on stdlib OpenSSL so it behaves identically on Rails 5.0–8, rather than delegating to Rails 7.1+ native `encrypts`. 978 examples, 0 failures.
@@ -21,6 +21,10 @@ module ConcernsOnRails
21
21
  DEFAULT_KDF_SALT = "concerns_on_rails/encryptable/v1".freeze
22
22
  KDF_ITERATIONS = 65_536
23
23
 
24
+ # Sentinel returned by Config#resolve_material when no key is configured and
25
+ # on_missing_key is :passthrough — callers then store/read plaintext.
26
+ PASSTHROUGH = :__concerns_on_rails_passthrough__
27
+
24
28
  # Base class so callers can `rescue ConcernsOnRails::Encryption::Error`.
25
29
  class Error < StandardError; end
26
30
 
@@ -60,6 +64,24 @@ module ConcernsOnRails
60
64
  def key?
61
65
  !key_material.nil?
62
66
  end
67
+
68
+ # Resolve the effective key material for a field: a per-field override
69
+ # (String or Proc) wins, else the global key. Returns PASSTHROUGH in the
70
+ # escape-hatch mode, or raises MissingKeyError. Shared by encryption and
71
+ # blind indexing so both derive from the same key.
72
+ def resolve_material(field_key = nil)
73
+ material = field_key.respond_to?(:call) ? field_key.call : field_key
74
+ material = material.to_s unless material.nil?
75
+ return material if material && !material.empty?
76
+
77
+ global = key_material
78
+ return global unless global.nil?
79
+ return PASSTHROUGH if on_missing_key == :passthrough
80
+
81
+ raise MissingKeyError,
82
+ "ConcernsOnRails::Models::Encryptable: no encryption key configured. Set " \
83
+ "ConcernsOnRails.configure_encryption { |c| c.key = ... } or pass key: to the macro."
84
+ end
63
85
  end
64
86
  end
65
87
  end
@@ -19,8 +19,9 @@ module ConcernsOnRails
19
19
  # class Patient < ApplicationRecord
20
20
  # include ConcernsOnRails::Models::Encryptable
21
21
  #
22
- # encryptable :ssn, :notes # transparent string encryption
23
- # encryptable :dob, type: :date # decrypts back to a Date
22
+ # encryptable :ssn, :notes # transparent string encryption
23
+ # encryptable :dob, type: :date # decrypts back to a Date
24
+ # encryptable :email, blind_index: true # + a queryable fingerprint column
24
25
  # end
25
26
  #
26
27
  # p = Patient.create!(ssn: "123-45-6789", dob: Date.new(1990, 1, 1))
@@ -28,22 +29,34 @@ module ConcernsOnRails
28
29
  # p.reload.dob # => Wed, 01 Jan 1990
29
30
  # p.ssn_ciphertext # => "AQEA..." (Base64 envelope; no plaintext at rest)
30
31
  # p.ssn_encrypted? # => true
32
+ # Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
31
33
  #
32
34
  # `type:` casts the decrypted value (reuses the Storable caster set:
33
35
  # :string default, :integer, :float, :decimal, :boolean, :date, :datetime).
34
36
  # `key:` overrides the gem-level key per field (a String or a Proc).
35
37
  #
38
+ # BLIND INDEX (`blind_index: true` or a Hash): because encryption is
39
+ # non-deterministic, encrypted columns are not directly queryable. Opt into
40
+ # a blind index and the concern maintains a deterministic keyed HMAC of the
41
+ # value in a companion `<field>_bidx` column (override with `column:`), and
42
+ # generates `find_by_<field>` / `where_<field>` / `<field>_fingerprint`
43
+ # class methods for exact-match lookups. Pass `expression:` (a callable) to
44
+ # normalize before hashing (e.g. `->(v) { v.to_s.downcase }`) — it is applied
45
+ # on BOTH write and query so they stay symmetric. The index leaks equality
46
+ # (identical values share a digest); use it only for lookup keys.
47
+ #
36
48
  # Notes:
37
49
  # * The declared column must be `text` (or binary): it stores an opaque
38
50
  # Base64 envelope, not the logical type. `nil` stays `nil` (never an
39
- # encrypted blank).
40
- # * Non-deterministic by design (random IV) — the same plaintext yields
41
- # different ciphertext every write, so encrypted columns are NOT
42
- # queryable/searchable (`where(:ssn)` matches ciphertext). Deterministic,
43
- # queryable fields and multi-key rotation are planned follow-ups; the
44
- # envelope already reserves the bytes for them.
51
+ # encrypted blank). A blind-index column is `string`/`text` (a 64-char
52
+ # hex digest) — add an index on it.
53
+ # * The ciphertext itself is non-deterministic (random IV) the same
54
+ # plaintext yields different ciphertext every write, so `where(:ssn)`
55
+ # matches nothing. Query through a blind index instead. Presence/NULL
56
+ # checks (`where.not(ssn: nil)`) work normally.
45
57
  # * Never `update_column`/`update_columns` an encrypted field — those
46
- # bypass the type and write raw plaintext to the column.
58
+ # bypass the type and write raw plaintext to the column (and skip the
59
+ # blind-index refresh).
47
60
  # * Auditing an encrypted field would persist its plaintext to the audit
48
61
  # column, so declaring a field with BOTH `encryptable` and `auditable_by`
49
62
  # raises. Maskable masks the decrypted value; Normalizable normalizes the
@@ -66,11 +79,33 @@ module ConcernsOnRails
66
79
  }.freeze
67
80
 
68
81
  included do
69
- # { field => { type:, key: } }. Subclasses inherit and may add fields.
82
+ # { field => { type:, key:, blind_index: } }. Subclasses inherit and may
83
+ # add fields.
70
84
  class_attribute :encryptable_rules, instance_accessor: false, default: {}
71
85
  # Backstop for the reverse declaration order (Auditable added AFTER
72
86
  # Encryptable): the macro-time guard can't see a not-yet-declared audit.
73
87
  before_save :encryptable_guard_audited_plaintext!
88
+ before_save :encryptable_refresh_blind_indexes
89
+ end
90
+
91
+ # Deterministic blind-index fingerprint for a field's value, applying the
92
+ # field's normalization `expression:`. Shared by the generated class
93
+ # finders and the before_save refresh. Returns nil for a nil value.
94
+ def self.blind_fingerprint(rule, value)
95
+ bi = rule[:blind_index]
96
+ return nil unless bi
97
+ return nil if value.nil?
98
+
99
+ normalized = bi[:expression] ? bi[:expression].call(value) : value
100
+ return nil if normalized.nil?
101
+
102
+ config = ConcernsOnRails.encryption
103
+ material = config.resolve_material(rule[:key])
104
+ return normalized.to_s if material == ConcernsOnRails::Encryption::PASSTHROUGH
105
+
106
+ ConcernsOnRails::Support::Encryptor.blind_index(
107
+ normalized, key: material, salt: config.key_derivation_salt
108
+ )
74
109
  end
75
110
 
76
111
  # Custom type registered on each encrypted column. cast handles user input
@@ -79,8 +114,6 @@ module ConcernsOnRails
79
114
  # so dirty tracking compares the cast plaintext — a re-save of unchanged
80
115
  # data is not dirtied by GCM's random IV.
81
116
  class EncryptedType < ActiveModel::Type::Value
82
- PASSTHROUGH = :__concerns_on_rails_passthrough__
83
-
84
117
  def initialize(type: :string, key: nil)
85
118
  @type = type
86
119
  @key = key
@@ -168,8 +201,8 @@ module ConcernsOnRails
168
201
 
169
202
  def write_ciphertext(plaintext)
170
203
  config = ConcernsOnRails.encryption
171
- material = resolve_key_material(config)
172
- return plaintext if material == PASSTHROUGH
204
+ material = config.resolve_material(@key)
205
+ return plaintext if material == ConcernsOnRails::Encryption::PASSTHROUGH
173
206
 
174
207
  ConcernsOnRails::Support::Encryptor.encrypt(
175
208
  plaintext, key: material, salt: config.key_derivation_salt
@@ -178,8 +211,8 @@ module ConcernsOnRails
178
211
 
179
212
  def read_plaintext(stored)
180
213
  config = ConcernsOnRails.encryption
181
- material = resolve_key_material(config)
182
- return stored if material == PASSTHROUGH
214
+ material = config.resolve_material(@key)
215
+ return stored if material == ConcernsOnRails::Encryption::PASSTHROUGH
183
216
 
184
217
  ConcernsOnRails::Support::Encryptor.decrypt(
185
218
  stored, key: material, salt: config.key_derivation_salt
@@ -189,48 +222,53 @@ module ConcernsOnRails
189
222
 
190
223
  nil
191
224
  end
192
-
193
- # Per-field key: wins; else the gem-level key; else raise (or the
194
- # passthrough sentinel in the dev/test escape-hatch mode).
195
- def resolve_key_material(config)
196
- material = @key.respond_to?(:call) ? @key.call : @key
197
- material = material.to_s unless material.nil?
198
- return material if material && !material.empty?
199
-
200
- global = config.key_material
201
- return global unless global.nil?
202
- return PASSTHROUGH if config.on_missing_key == :passthrough
203
-
204
- raise ConcernsOnRails::Encryption::MissingKeyError,
205
- "#{LABEL}: no encryption key configured. Set " \
206
- "ConcernsOnRails.configure_encryption { |c| c.key = ... } or pass key: to the macro."
207
- end
208
225
  end
209
226
 
210
227
  module ClassMethods
211
228
  include ConcernsOnRails::Support::ColumnGuard
212
229
 
213
230
  # Declare one or more encrypted fields. Repeatable; per-field options.
214
- def encryptable(*fields, type: :string, key: nil)
215
- raise ArgumentError, "#{LABEL}: at least one field is required" if fields.empty?
216
-
231
+ def encryptable(*fields, type: :string, key: nil, blind_index: nil)
217
232
  type = type.to_sym
218
- raise ArgumentError, "#{LABEL}: unknown type ':#{type}' (valid: #{VALID_TYPES.join(', ')})" unless VALID_TYPES.include?(type)
219
-
233
+ encryptable_validate!(fields, type, blind_index)
220
234
  ensure_columns!(LABEL, *fields)
221
235
 
222
236
  fields.each do |field|
223
237
  field = field.to_sym
224
238
  encryptable_guard_auditable!(field)
225
- self.encryptable_rules = encryptable_rules.merge(field => { type: type, key: key })
239
+ bi = encryptable_normalize_blind_index(field, blind_index)
240
+ ensure_columns!(LABEL, bi[:column]) if bi
241
+ self.encryptable_rules = encryptable_rules.merge(field => { type: type, key: key, blind_index: bi })
226
242
  attribute field, EncryptedType.new(type: type, key: key)
227
243
  encryptable_define_helpers(field)
244
+ encryptable_define_blind_index(field, bi) if bi
228
245
  encryptable_register_filter_parameter(field)
229
246
  end
230
247
  end
231
248
 
232
249
  private
233
250
 
251
+ def encryptable_validate!(fields, type, blind_index)
252
+ raise ArgumentError, "#{LABEL}: at least one field is required" if fields.empty?
253
+ raise ArgumentError, "#{LABEL}: unknown type ':#{type}' (valid: #{VALID_TYPES.join(', ')})" unless VALID_TYPES.include?(type)
254
+ return unless blind_index.is_a?(Hash) && blind_index[:column] && fields.size > 1
255
+
256
+ raise ArgumentError, "#{LABEL}: blind_index column: cannot be combined with multiple fields"
257
+ end
258
+
259
+ # nil/false -> no index; true -> defaults; Hash -> { column:, expression: }.
260
+ def encryptable_normalize_blind_index(field, option)
261
+ return nil unless option
262
+
263
+ option = {} if option == true
264
+ raise ArgumentError, "#{LABEL}: blind_index: must be true or a Hash" unless option.is_a?(Hash)
265
+
266
+ expression = option[:expression]
267
+ raise ArgumentError, "#{LABEL}: blind_index expression: must be callable" if expression && !expression.respond_to?(:call)
268
+
269
+ { column: (option[:column] || "#{field}_bidx").to_sym, expression: expression }
270
+ end
271
+
234
272
  def encryptable_define_helpers(field)
235
273
  # Raw stored value: the DB ciphertext once persisted (before the type
236
274
  # deserializes it). Useful for migrations, debugging, and asserting no
@@ -239,6 +277,26 @@ module ConcernsOnRails
239
277
  define_method("#{field}_encrypted?") { read_attribute_before_type_cast(field).present? }
240
278
  end
241
279
 
280
+ # find_by_<field> / where_<field> / <field>_fingerprint for equality
281
+ # lookups through the deterministic blind-index column.
282
+ def encryptable_define_blind_index(field, blind_index)
283
+ column = blind_index[:column]
284
+
285
+ define_singleton_method("#{field}_fingerprint") do |value|
286
+ ConcernsOnRails::Models::Encryptable.blind_fingerprint(encryptable_rules.fetch(field), value)
287
+ end
288
+ # Accepts one value, several, or an array — multiple values become an
289
+ # IN query on the fingerprint column. Returns a Relation, so it chains
290
+ # with scopes, `.or`, `.merge` (for joins), and further `.where`.
291
+ define_singleton_method("where_#{field}") do |*values|
292
+ fingerprints = values.flatten.map { |v| public_send("#{field}_fingerprint", v) }
293
+ where(column => fingerprints.length == 1 ? fingerprints.first : fingerprints)
294
+ end
295
+ define_singleton_method("find_by_#{field}") do |value|
296
+ find_by(column => public_send("#{field}_fingerprint", value))
297
+ end
298
+ end
299
+
242
300
  # Macro-time guard for the common order (Encryptable declared after
243
301
  # Auditable): a field must not be both encrypted and audited.
244
302
  def encryptable_guard_auditable!(field)
@@ -274,6 +332,18 @@ module ConcernsOnRails
274
332
  "#{LABEL}: #{overlap.map { |f| ":#{f}" }.join(', ')} declared with both Encryptable and " \
275
333
  "Auditable; auditing would persist decrypted plaintext. Remove them from auditable_by."
276
334
  end
335
+
336
+ # Recompute each blind-index column from the (changed) plaintext just
337
+ # before the row is written, so the fingerprint always matches the value.
338
+ def encryptable_refresh_blind_indexes
339
+ self.class.encryptable_rules.each do |field, rule|
340
+ bi = rule[:blind_index]
341
+ next unless bi
342
+ next unless public_send("#{field}_changed?")
343
+
344
+ self[bi[:column]] = ConcernsOnRails::Models::Encryptable.blind_fingerprint(rule, public_send(field))
345
+ end
346
+ end
277
347
  end
278
348
  end
279
349
  end
@@ -29,6 +29,9 @@ module ConcernsOnRails
29
29
  KEY_BYTES = 32 # AES-256
30
30
  CIPHER = "aes-256-gcm".freeze
31
31
  MIN_ENVELOPE_BYTES = HEADER_LEN + IV_LEN + TAG_LEN
32
+ # Domain-separation label so the blind-index HMAC key differs from the AES
33
+ # key even when both derive from the same configured secret.
34
+ BLIND_INDEX_INFO = "concerns_on_rails/blind_index/v1".freeze
32
35
 
33
36
  # Encrypt a String, returning a Base64 envelope. nil passes through as nil
34
37
  # (a blank column stays blank / NULL-able), never an encrypted empty value.
@@ -79,6 +82,18 @@ module ConcernsOnRails
79
82
  "could not decrypt value (wrong key or tampered ciphertext)"
80
83
  end
81
84
 
85
+ # Deterministic keyed fingerprint (lowercase hex) for equality lookups — a
86
+ # "blind index". The HMAC key is domain-separated from the AES key via
87
+ # BLIND_INDEX_INFO, so the two are cryptographically independent. The same
88
+ # value + key always yields the same digest, enabling an indexed WHERE.
89
+ def blind_index(value, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
90
+ return nil if value.nil?
91
+
92
+ master = normalize_key(key, salt: salt)
93
+ subkey = OpenSSL::HMAC.digest("SHA256", master, BLIND_INDEX_INFO)
94
+ OpenSSL::HMAC.hexdigest("SHA256", subkey, value.to_s)
95
+ end
96
+
82
97
  # Coerce key material to a 32-byte AES key: raw 32-byte binary as-is, a
83
98
  # 64-hex string decoded, otherwise a passphrase stretched with PBKDF2. The
84
99
  # salt + iteration count are part of the derived key's identity.
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.21.0".freeze
2
+ VERSION = "1.21.1".freeze
3
3
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: concerns_on_rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.21.0
4
+ version: 1.21.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ethan Nguyen
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2026-07-17 00:00:00.000000000 Z
11
+ date: 2026-07-18 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails