concerns_on_rails 1.20.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: 7eae55cba50d5c8a671187f6aba31e3b33e25eab1349054a45346400f64c70d1
4
- data.tar.gz: e1369be961046f7f3926280083ee1f07fa027ad7c93d2161422f2b6dac687f57
3
+ metadata.gz: 87a3ceebf2b9eb0f8a00c9d6a8cc57d8f50c8b1ff698be887aef42da64eb08c3
4
+ data.tar.gz: 1a82dd3dbe363e65f3a0d91c66cb453d54415032def91e90a388663ce2e90dab
5
5
  SHA512:
6
- metadata.gz: 82eff9b0770e1d464418fb68ccf10b2564e48cf8305de882d644edef0134e1e07a90cec0f4a43cc6fdaa50945a663452e57bb0a4a20382be978b1a6204ad545d
7
- data.tar.gz: ab30c390449a59a83b263393e2b0f1b66eb1d1e2a06d3e054354814f4ef2b916d5739bd6f4b3b48bca4b3772b1f75389cdaf5e1b6ef6014e55e8c3051deff52a
6
+ metadata.gz: ebfb6e59e582240aab7bc0fe18e64e48f1f291f482b38df2dac97c3cc843f5731857594724868484fee75b9269ccaca923f6d229d862b3ce4ddfdf49c3f9aa27
7
+ data.tar.gz: 53d1b47d39588d102a4b6527b43b1801a1f87c47c1da7a67b823fb2138160e0dc8e1de9ea6d3b16070a2a33ca247a88832556de01e512b8ac474a9b5de288b6e
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
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
+
10
+ ## 1.21.0 (2026-07-01)
11
+
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.
13
+
14
+ ### Added
15
+ - **Models::Encryptable**: transparent per-field encryption for sensitive columns (SSN, DOB, cards) — AES-256-GCM via stdlib OpenSSL, no new dependency (the crypto toolbox already proven in Controllers::WebhookVerifiable). `encryptable :ssn, :dob, type: :date, key: ...` (repeatable; per-field options) registers a custom `ActiveModel::Type` on the declared column, so reads/writes stay plaintext and the DB column holds a versioned, authenticated Base64 envelope (`ver|alg|key_id|iv|tag|ciphertext`, the 3-byte header fed to GCM as additional authenticated data). Because it is an immutable value type, dirty tracking compares the decrypted plaintext — a re-save of unchanged data is never spuriously dirtied by GCM's random IV, and an unchanged field is not re-encrypted. `type:` casts the decrypted value through the Storable caster set (`:string`/`:integer`/`:float`/`:decimal`/`:boolean`/`:date`/`:datetime`; `:decimal` precision-safe, `:datetime` UTC microseconds). Keys come from a gem-level config (`ConcernsOnRails.configure_encryption { |c| c.key = ... }`, memoized like `.deprecator`; a String / 64-hex / 32-byte-raw value or a lazy Proc, stretched via PBKDF2-HMAC-SHA256) or a per-field `key:` override; a missing key raises `MissingKeyError` at first use, never at class-load. Adds `<field>_ciphertext` (raw envelope) and `<field>_encrypted?` readers. Composes transparently: Normalizable normalizes plaintext before it is encrypted (order-independent), Maskable masks the decrypted value; declaring a field with BOTH `encryptable` and `auditable_by` RAISES (auditing would persist plaintext). Wrong key / tampered ciphertext / malformed envelope raise `DecryptionError` (never a raw OpenSSL error); `on_missing_key: :passthrough` and `raise_on_decrypt_error: false` are documented dev-only escape hatches. Non-deterministic by design, so encrypted columns are not queryable/searchable — deterministic equality lookups and multi-key rotation are planned follow-ups (the envelope already reserves the `alg`/`key_id` bytes). Zero new runtime dependencies.
16
+
3
17
  ## 1.20.0 (2026-06-18)
4
18
 
5
19
  Two new concerns — the conditional counter cache the prior design panel held back as "too risky for one release" (1.19.0's notes), now shipped with the full update matrix specified and tested, plus the HTTP conditional-GET layer the API-oriented controller suite was missing. 933 examples, 0 failures.
@@ -0,0 +1,87 @@
1
+ module ConcernsOnRails
2
+ # Configuration and error types backing Models::Encryptable.
3
+ #
4
+ # The gem stays agnostic about where secrets live: the host app supplies the
5
+ # key (commonly a Proc reading Rails credentials) via a small config object,
6
+ # mirroring the existing ConcernsOnRails.deprecator accessor pattern:
7
+ #
8
+ # ConcernsOnRails.configure_encryption do |c|
9
+ # c.key = -> { Rails.application.credentials.dig(:encryption, :key) }
10
+ # end
11
+ #
12
+ # A key may be raw 32-byte binary, a 64-char hex string, or any passphrase
13
+ # (stretched to 32 bytes with PBKDF2-HMAC-SHA256). Resolution is lazy — a
14
+ # missing key raises MissingKeyError at first encrypt/decrypt, never at
15
+ # class-load, so a model file can be required before credentials load.
16
+ module Encryption
17
+ # The KDF salt and iteration count are part of the derived key's identity:
18
+ # change them and existing ciphertext can no longer be decrypted. They are
19
+ # deliberately fixed constants (the salt is overridable via config only for
20
+ # apps that must diverge and are prepared to re-encrypt).
21
+ DEFAULT_KDF_SALT = "concerns_on_rails/encryptable/v1".freeze
22
+ KDF_ITERATIONS = 65_536
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
+
28
+ # Base class so callers can `rescue ConcernsOnRails::Encryption::Error`.
29
+ class Error < StandardError; end
30
+
31
+ # No key could be resolved at encrypt/decrypt time.
32
+ class MissingKeyError < Error; end
33
+
34
+ # Decryption failed: wrong key, tampered ciphertext (GCM auth-tag mismatch),
35
+ # or a malformed envelope. Never surfaces raw OpenSSL exceptions to callers.
36
+ class DecryptionError < Error; end
37
+
38
+ class Config
39
+ # key: raw 32-byte binary, 64-hex, passphrase, or a Proc returning one.
40
+ # key_derivation_salt: PBKDF2 salt (part of key identity — keep stable).
41
+ # on_missing_key: :raise (default) or :passthrough (dev/test escape hatch
42
+ # that stores/reads plaintext when no key is configured — never in prod).
43
+ # raise_on_decrypt_error: true (default) raises DecryptionError on a bad
44
+ # read; false returns nil (a narrow reporting-path opt-out, less safe).
45
+ attr_accessor :key, :key_derivation_salt, :on_missing_key, :raise_on_decrypt_error
46
+
47
+ def initialize
48
+ @key = nil
49
+ @key_derivation_salt = DEFAULT_KDF_SALT
50
+ @on_missing_key = :raise
51
+ @raise_on_decrypt_error = true
52
+ end
53
+
54
+ # Resolve the configured key (calling a Proc) to raw String material, or
55
+ # nil when unset. Callers decide raise-vs-passthrough from that nil.
56
+ def key_material
57
+ material = key.respond_to?(:call) ? key.call : key
58
+ return nil if material.nil?
59
+
60
+ material = material.to_s
61
+ material.empty? ? nil : material
62
+ end
63
+
64
+ def key?
65
+ !key_material.nil?
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
85
+ end
86
+ end
87
+ end
@@ -25,4 +25,5 @@ module ConcernsOnRails
25
25
  Aliasable = Models::Aliasable
26
26
  Storable = Models::Storable
27
27
  CounterCacheable = Models::CounterCacheable
28
+ Encryptable = Models::Encryptable
28
29
  end
@@ -0,0 +1,349 @@
1
+ require "active_support/concern"
2
+ require "active_model/type"
3
+ require "bigdecimal"
4
+ require "time"
5
+ require "concerns_on_rails/encryption"
6
+ require "concerns_on_rails/support/encryptor"
7
+
8
+ module ConcernsOnRails
9
+ module Models
10
+ # Transparent per-field encryption for sensitive columns (SSN, DOB, cards).
11
+ # Reads and writes stay plaintext; the DB column holds an authenticated
12
+ # AES-256-GCM envelope. Encryption is implemented as a custom
13
+ # ActiveModel::Type on the declared column, so it is invisible to the rest
14
+ # of the stack — sibling concerns that read `self[:field]` (Maskable,
15
+ # Normalizable) compose for free, and dirty tracking compares plaintext.
16
+ #
17
+ # ConcernsOnRails.configure_encryption { |c| c.key = ENV["ENCRYPTION_KEY"] }
18
+ #
19
+ # class Patient < ApplicationRecord
20
+ # include ConcernsOnRails::Models::Encryptable
21
+ #
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
25
+ # end
26
+ #
27
+ # p = Patient.create!(ssn: "123-45-6789", dob: Date.new(1990, 1, 1))
28
+ # p.ssn # => "123-45-6789"
29
+ # p.reload.dob # => Wed, 01 Jan 1990
30
+ # p.ssn_ciphertext # => "AQEA..." (Base64 envelope; no plaintext at rest)
31
+ # p.ssn_encrypted? # => true
32
+ # Patient.find_by_email("a@b.com") # exact-match lookup via the blind index
33
+ #
34
+ # `type:` casts the decrypted value (reuses the Storable caster set:
35
+ # :string default, :integer, :float, :decimal, :boolean, :date, :datetime).
36
+ # `key:` overrides the gem-level key per field (a String or a Proc).
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
+ #
48
+ # Notes:
49
+ # * The declared column must be `text` (or binary): it stores an opaque
50
+ # Base64 envelope, not the logical type. `nil` stays `nil` (never an
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.
57
+ # * Never `update_column`/`update_columns` an encrypted field — those
58
+ # bypass the type and write raw plaintext to the column (and skip the
59
+ # blind-index refresh).
60
+ # * Auditing an encrypted field would persist its plaintext to the audit
61
+ # column, so declaring a field with BOTH `encryptable` and `auditable_by`
62
+ # raises. Maskable masks the decrypted value; Normalizable normalizes the
63
+ # plaintext before it is encrypted (order-independent).
64
+ module Encryptable
65
+ extend ActiveSupport::Concern
66
+
67
+ LABEL = "ConcernsOnRails::Models::Encryptable".freeze
68
+ VALID_TYPES = %i[string integer float decimal boolean date datetime].freeze
69
+
70
+ # Reusable ActiveModel casters. :decimal and :datetime round-trip through
71
+ # Strings and are handled explicitly (mirrors Models::Storable).
72
+ CASTERS = {
73
+ string: ActiveModel::Type::String.new,
74
+ integer: ActiveModel::Type::Integer.new,
75
+ float: ActiveModel::Type::Float.new,
76
+ boolean: ActiveModel::Type::Boolean.new,
77
+ date: ActiveModel::Type::Date.new,
78
+ datetime: ActiveModel::Type::DateTime.new
79
+ }.freeze
80
+
81
+ included do
82
+ # { field => { type:, key:, blind_index: } }. Subclasses inherit and may
83
+ # add fields.
84
+ class_attribute :encryptable_rules, instance_accessor: false, default: {}
85
+ # Backstop for the reverse declaration order (Auditable added AFTER
86
+ # Encryptable): the macro-time guard can't see a not-yet-declared audit.
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
+ )
109
+ end
110
+
111
+ # Custom type registered on each encrypted column. cast handles user input
112
+ # (plaintext in memory), serialize encrypts on the write-to-DB path, and
113
+ # deserialize decrypts on the read-from-DB path. An immutable value type,
114
+ # so dirty tracking compares the cast plaintext — a re-save of unchanged
115
+ # data is not dirtied by GCM's random IV.
116
+ class EncryptedType < ActiveModel::Type::Value
117
+ def initialize(type: :string, key: nil)
118
+ @type = type
119
+ @key = key
120
+ super()
121
+ end
122
+
123
+ # user assignment -> typed plaintext (no crypto)
124
+ def cast(value)
125
+ cast_typed(value)
126
+ end
127
+
128
+ # DB ciphertext -> typed plaintext
129
+ def deserialize(value)
130
+ return nil if value.nil?
131
+
132
+ plaintext = read_plaintext(value)
133
+ return nil if plaintext.nil?
134
+
135
+ cast_typed(plaintext)
136
+ end
137
+
138
+ # typed plaintext -> DB ciphertext
139
+ def serialize(value)
140
+ return nil if value.nil?
141
+
142
+ plaintext = canonical_string(value)
143
+ return nil if plaintext.nil?
144
+
145
+ write_ciphertext(plaintext)
146
+ end
147
+
148
+ private
149
+
150
+ def cast_typed(value)
151
+ case @type
152
+ when :decimal then to_big_decimal(value)
153
+ when :datetime then to_time(value)
154
+ else CASTERS[@type].cast(value)
155
+ end
156
+ rescue StandardError
157
+ nil
158
+ end
159
+
160
+ # Canonical, reversible String form fed to the cipher: cast to the typed
161
+ # value first, then format that type as a stable String.
162
+ def canonical_string(value)
163
+ typed = cast_typed(value)
164
+ return nil if typed.nil?
165
+
166
+ stringify(typed)
167
+ rescue StandardError
168
+ nil
169
+ end
170
+
171
+ def stringify(typed)
172
+ case @type
173
+ when :decimal then typed.to_s("F")
174
+ when :date then typed.iso8601
175
+ when :datetime then typed.utc.iso8601(6)
176
+ else typed.to_s
177
+ end
178
+ end
179
+
180
+ def to_big_decimal(value)
181
+ return nil if value.nil?
182
+
183
+ value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
184
+ end
185
+
186
+ def to_time(value)
187
+ case value
188
+ when nil then nil
189
+ when ActiveSupport::TimeWithZone, Time then value
190
+ when DateTime then value.to_time
191
+ when Date then Time.utc(value.year, value.month, value.day)
192
+ when String
193
+ begin
194
+ Time.iso8601(value)
195
+ rescue ArgumentError
196
+ CASTERS[:datetime].cast(value)
197
+ end
198
+ else CASTERS[:datetime].cast(value)
199
+ end
200
+ end
201
+
202
+ def write_ciphertext(plaintext)
203
+ config = ConcernsOnRails.encryption
204
+ material = config.resolve_material(@key)
205
+ return plaintext if material == ConcernsOnRails::Encryption::PASSTHROUGH
206
+
207
+ ConcernsOnRails::Support::Encryptor.encrypt(
208
+ plaintext, key: material, salt: config.key_derivation_salt
209
+ )
210
+ end
211
+
212
+ def read_plaintext(stored)
213
+ config = ConcernsOnRails.encryption
214
+ material = config.resolve_material(@key)
215
+ return stored if material == ConcernsOnRails::Encryption::PASSTHROUGH
216
+
217
+ ConcernsOnRails::Support::Encryptor.decrypt(
218
+ stored, key: material, salt: config.key_derivation_salt
219
+ )
220
+ rescue ConcernsOnRails::Encryption::DecryptionError
221
+ raise if config.raise_on_decrypt_error
222
+
223
+ nil
224
+ end
225
+ end
226
+
227
+ module ClassMethods
228
+ include ConcernsOnRails::Support::ColumnGuard
229
+
230
+ # Declare one or more encrypted fields. Repeatable; per-field options.
231
+ def encryptable(*fields, type: :string, key: nil, blind_index: nil)
232
+ type = type.to_sym
233
+ encryptable_validate!(fields, type, blind_index)
234
+ ensure_columns!(LABEL, *fields)
235
+
236
+ fields.each do |field|
237
+ field = field.to_sym
238
+ encryptable_guard_auditable!(field)
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 })
242
+ attribute field, EncryptedType.new(type: type, key: key)
243
+ encryptable_define_helpers(field)
244
+ encryptable_define_blind_index(field, bi) if bi
245
+ encryptable_register_filter_parameter(field)
246
+ end
247
+ end
248
+
249
+ private
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
+
272
+ def encryptable_define_helpers(field)
273
+ # Raw stored value: the DB ciphertext once persisted (before the type
274
+ # deserializes it). Useful for migrations, debugging, and asserting no
275
+ # plaintext is at rest.
276
+ define_method("#{field}_ciphertext") { read_attribute_before_type_cast(field) }
277
+ define_method("#{field}_encrypted?") { read_attribute_before_type_cast(field).present? }
278
+ end
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
+
300
+ # Macro-time guard for the common order (Encryptable declared after
301
+ # Auditable): a field must not be both encrypted and audited.
302
+ def encryptable_guard_auditable!(field)
303
+ return unless respond_to?(:auditable_fields)
304
+ return unless Array(auditable_fields).map(&:to_sym).include?(field.to_sym)
305
+
306
+ raise ArgumentError,
307
+ "#{LABEL}: ':#{field}' is also declared with Auditable; auditing would persist the " \
308
+ "decrypted plaintext to the audit column. Remove it from auditable_by."
309
+ end
310
+
311
+ # Redact encrypted fields from Rails parameter logging when running
312
+ # inside a Rails app (guarded — no-op under bare ActiveRecord/tests).
313
+ def encryptable_register_filter_parameter(field)
314
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
315
+
316
+ filters = Rails.application.config.filter_parameters
317
+ filters << field unless filters.include?(field)
318
+ rescue StandardError
319
+ nil
320
+ end
321
+ end
322
+
323
+ private
324
+
325
+ def encryptable_guard_audited_plaintext!
326
+ return unless self.class.respond_to?(:auditable_fields)
327
+
328
+ overlap = self.class.encryptable_rules.keys & Array(self.class.auditable_fields).map(&:to_sym)
329
+ return if overlap.empty?
330
+
331
+ raise ArgumentError,
332
+ "#{LABEL}: #{overlap.map { |f| ":#{f}" }.join(', ')} declared with both Encryptable and " \
333
+ "Auditable; auditing would persist decrypted plaintext. Remove them from auditable_by."
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
347
+ end
348
+ end
349
+ end
@@ -0,0 +1,118 @@
1
+ require "openssl"
2
+ require "concerns_on_rails/encryption"
3
+
4
+ module ConcernsOnRails
5
+ module Support
6
+ # Pure, stateless AES-256-GCM codec shared by Models::Encryptable. Like the
7
+ # other Support modules (Masker, Money) it holds no state — key material is
8
+ # always passed in — and uses only stdlib OpenSSL, matching the dependency
9
+ # -free crypto already in Controllers::WebhookVerifiable.
10
+ #
11
+ # On-disk envelope (Base64 via pack("m0"), the gem's convention — avoids the
12
+ # base64 gem, no longer default on Ruby 3.4):
13
+ #
14
+ # ver(1)=0x01 | alg(1)=0x01 | key_id(1) | iv(12) | auth_tag(16) | ciphertext
15
+ #
16
+ # The 3-byte header is fed to GCM as additional authenticated data (AAD), so
17
+ # the version/algorithm/key-id cannot be altered without failing the auth
18
+ # tag. `alg 0x11` (deterministic) and a non-zero `key_id` (rotation) are
19
+ # reserved for later features — the format tolerates them without a break.
20
+ module Encryptor
21
+ module_function
22
+
23
+ VERSION_BYTE = 0x01
24
+ ALG_GCM = 0x01
25
+ HEADER_FORMAT = "C3".freeze
26
+ HEADER_LEN = 3
27
+ IV_LEN = 12 # 96-bit IV: GCM's recommended size
28
+ TAG_LEN = 16 # 128-bit auth tag
29
+ KEY_BYTES = 32 # AES-256
30
+ CIPHER = "aes-256-gcm".freeze
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
35
+
36
+ # Encrypt a String, returning a Base64 envelope. nil passes through as nil
37
+ # (a blank column stays blank / NULL-able), never an encrypted empty value.
38
+ def encrypt(plaintext, key:, key_id: 0, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
39
+ return nil if plaintext.nil?
40
+
41
+ derived = normalize_key(key, salt: salt)
42
+ cipher = OpenSSL::Cipher.new(CIPHER)
43
+ cipher.encrypt
44
+ cipher.key = derived
45
+ iv = cipher.random_iv
46
+ header = [VERSION_BYTE, ALG_GCM, key_id & 0xFF].pack(HEADER_FORMAT)
47
+ cipher.auth_data = header
48
+ ciphertext = cipher.update(plaintext.to_s) + cipher.final
49
+ [header + iv + cipher.auth_tag + ciphertext].pack("m0")
50
+ end
51
+
52
+ # Decrypt a Base64 envelope back to the plaintext String. nil -> nil. A
53
+ # wrong key, tampered ciphertext, or malformed envelope raises
54
+ # Encryption::DecryptionError (never a raw OpenSSL error).
55
+ def decrypt(envelope, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
56
+ return nil if envelope.nil?
57
+
58
+ # "m0" is strict Base64 and raises ArgumentError on non-Base64 input.
59
+ raw =
60
+ begin
61
+ envelope.to_s.unpack1("m0").to_s
62
+ rescue ArgumentError
63
+ raise ConcernsOnRails::Encryption::DecryptionError, "malformed encryption envelope"
64
+ end
65
+ raise ConcernsOnRails::Encryption::DecryptionError, "malformed encryption envelope" if raw.bytesize < MIN_ENVELOPE_BYTES
66
+
67
+ header = raw.byteslice(0, HEADER_LEN)
68
+ iv = raw.byteslice(HEADER_LEN, IV_LEN)
69
+ tag = raw.byteslice(HEADER_LEN + IV_LEN, TAG_LEN)
70
+ ciphertext = raw.byteslice((HEADER_LEN + IV_LEN + TAG_LEN)..) || ""
71
+
72
+ derived = normalize_key(key, salt: salt)
73
+ cipher = OpenSSL::Cipher.new(CIPHER)
74
+ cipher.decrypt
75
+ cipher.key = derived
76
+ cipher.iv = iv
77
+ cipher.auth_tag = tag
78
+ cipher.auth_data = header
79
+ cipher.update(ciphertext) + cipher.final
80
+ rescue OpenSSL::Cipher::CipherError
81
+ raise ConcernsOnRails::Encryption::DecryptionError,
82
+ "could not decrypt value (wrong key or tampered ciphertext)"
83
+ end
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
+
97
+ # Coerce key material to a 32-byte AES key: raw 32-byte binary as-is, a
98
+ # 64-hex string decoded, otherwise a passphrase stretched with PBKDF2. The
99
+ # salt + iteration count are part of the derived key's identity.
100
+ def normalize_key(value, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
101
+ value = value.call if value.respond_to?(:call)
102
+ material = value.to_s
103
+ raise ConcernsOnRails::Encryption::MissingKeyError, "no encryption key configured" if material.empty?
104
+
105
+ return material.b if material.bytesize == KEY_BYTES && material.encoding == Encoding::BINARY
106
+ return [material].pack("H*") if material.match?(/\A\h{64}\z/)
107
+
108
+ OpenSSL::KDF.pbkdf2_hmac(
109
+ material,
110
+ salt: salt.to_s,
111
+ iterations: ConcernsOnRails::Encryption::KDF_ITERATIONS,
112
+ length: KEY_BYTES,
113
+ hash: "SHA256"
114
+ )
115
+ end
116
+ end
117
+ end
118
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.20.0".freeze
2
+ VERSION = "1.21.1".freeze
3
3
  end
@@ -17,8 +17,26 @@ module ConcernsOnRails
17
17
  def self.deprecator
18
18
  @deprecator ||= ActiveSupport::Deprecation.new("2.0", "concerns_on_rails")
19
19
  end
20
+
21
+ # Gem-wide encryption configuration backing Models::Encryptable. Memoized like
22
+ # `deprecator`; the host app supplies the key (see ConcernsOnRails::Encryption):
23
+ #
24
+ # ConcernsOnRails.configure_encryption do |c|
25
+ # c.key = -> { Rails.application.credentials.dig(:encryption, :key) }
26
+ # end
27
+ def self.encryption
28
+ @encryption ||= Encryption::Config.new
29
+ end
30
+
31
+ def self.configure_encryption
32
+ yield encryption if block_given?
33
+ encryption
34
+ end
20
35
  end
21
36
 
37
+ # Encryption config + error types (loaded before the support codec that uses them)
38
+ require "concerns_on_rails/encryption"
39
+
22
40
  # Shared internal helpers (must load before the concerns that use them)
23
41
  require "concerns_on_rails/support/column_guard"
24
42
  require "concerns_on_rails/support/random_value"
@@ -27,6 +45,7 @@ require "concerns_on_rails/support/sequence_calculator"
27
45
  require "concerns_on_rails/support/html_sanitizers"
28
46
  require "concerns_on_rails/support/masker"
29
47
  require "concerns_on_rails/support/money"
48
+ require "concerns_on_rails/support/encryptor"
30
49
 
31
50
  # Model concerns
32
51
  require "concerns_on_rails/models/sluggable"
@@ -52,6 +71,7 @@ require "concerns_on_rails/models/lockable"
52
71
  require "concerns_on_rails/models/aliasable"
53
72
  require "concerns_on_rails/models/storable"
54
73
  require "concerns_on_rails/models/counter_cacheable"
74
+ require "concerns_on_rails/models/encryptable"
55
75
 
56
76
  # Controller concerns
57
77
  require "concerns_on_rails/controllers/paginatable"
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.20.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-06-18 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
@@ -87,12 +87,14 @@ files:
87
87
  - lib/concerns_on_rails/controllers/throttleable.rb
88
88
  - lib/concerns_on_rails/controllers/timezoneable.rb
89
89
  - lib/concerns_on_rails/controllers/webhook_verifiable.rb
90
+ - lib/concerns_on_rails/encryption.rb
90
91
  - lib/concerns_on_rails/legacy_aliases.rb
91
92
  - lib/concerns_on_rails/models/activatable.rb
92
93
  - lib/concerns_on_rails/models/addressable.rb
93
94
  - lib/concerns_on_rails/models/aliasable.rb
94
95
  - lib/concerns_on_rails/models/auditable.rb
95
96
  - lib/concerns_on_rails/models/counter_cacheable.rb
97
+ - lib/concerns_on_rails/models/encryptable.rb
96
98
  - lib/concerns_on_rails/models/expirable.rb
97
99
  - lib/concerns_on_rails/models/hashable.rb
98
100
  - lib/concerns_on_rails/models/lockable.rb
@@ -113,6 +115,7 @@ files:
113
115
  - lib/concerns_on_rails/models/tokenizable.rb
114
116
  - lib/concerns_on_rails/support/address_data.rb
115
117
  - lib/concerns_on_rails/support/column_guard.rb
118
+ - lib/concerns_on_rails/support/encryptor.rb
116
119
  - lib/concerns_on_rails/support/html_sanitizers.rb
117
120
  - lib/concerns_on_rails/support/masker.rb
118
121
  - lib/concerns_on_rails/support/money.rb