concerns_on_rails 1.20.0 → 1.21.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: 7eae55cba50d5c8a671187f6aba31e3b33e25eab1349054a45346400f64c70d1
4
- data.tar.gz: e1369be961046f7f3926280083ee1f07fa027ad7c93d2161422f2b6dac687f57
3
+ metadata.gz: c419970fee56d2f4d1d02f534c07abd646219db975823b089f1801192eb15689
4
+ data.tar.gz: 266601e25a1dd81854dacc62f77c85d913e7dc633614a32001b43359abe33069
5
5
  SHA512:
6
- metadata.gz: 82eff9b0770e1d464418fb68ccf10b2564e48cf8305de882d644edef0134e1e07a90cec0f4a43cc6fdaa50945a663452e57bb0a4a20382be978b1a6204ad545d
7
- data.tar.gz: ab30c390449a59a83b263393e2b0f1b66eb1d1e2a06d3e054354814f4ef2b916d5739bd6f4b3b48bca4b3772b1f75389cdaf5e1b6ef6014e55e8c3051deff52a
6
+ metadata.gz: 5069eb540a84ecda03c062eeae3dea59ec47ecff05d6107d1c198b936a0c55c2a8364a558c4376d7ee38ad9c082e5ddf0aafd72042a2370415f5056714a71098
7
+ data.tar.gz: 65f7b71e32329fbb8575342f44349757f37a835b913334ebf6f362b015962c70c5f3e8fd45fac0afdace8da2914beb2c49a1d74b564c36e3795de4a2b7f1b9b6
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  <!-- CHANGELOG.md -->
2
2
 
3
+ ## 1.21.0 (2026-07-01)
4
+
5
+ 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.
6
+
7
+ ### Added
8
+ - **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.
9
+
3
10
  ## 1.20.0 (2026-06-18)
4
11
 
5
12
  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,65 @@
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
+ # Base class so callers can `rescue ConcernsOnRails::Encryption::Error`.
25
+ class Error < StandardError; end
26
+
27
+ # No key could be resolved at encrypt/decrypt time.
28
+ class MissingKeyError < Error; end
29
+
30
+ # Decryption failed: wrong key, tampered ciphertext (GCM auth-tag mismatch),
31
+ # or a malformed envelope. Never surfaces raw OpenSSL exceptions to callers.
32
+ class DecryptionError < Error; end
33
+
34
+ class Config
35
+ # key: raw 32-byte binary, 64-hex, passphrase, or a Proc returning one.
36
+ # key_derivation_salt: PBKDF2 salt (part of key identity — keep stable).
37
+ # on_missing_key: :raise (default) or :passthrough (dev/test escape hatch
38
+ # that stores/reads plaintext when no key is configured — never in prod).
39
+ # raise_on_decrypt_error: true (default) raises DecryptionError on a bad
40
+ # read; false returns nil (a narrow reporting-path opt-out, less safe).
41
+ attr_accessor :key, :key_derivation_salt, :on_missing_key, :raise_on_decrypt_error
42
+
43
+ def initialize
44
+ @key = nil
45
+ @key_derivation_salt = DEFAULT_KDF_SALT
46
+ @on_missing_key = :raise
47
+ @raise_on_decrypt_error = true
48
+ end
49
+
50
+ # Resolve the configured key (calling a Proc) to raw String material, or
51
+ # nil when unset. Callers decide raise-vs-passthrough from that nil.
52
+ def key_material
53
+ material = key.respond_to?(:call) ? key.call : key
54
+ return nil if material.nil?
55
+
56
+ material = material.to_s
57
+ material.empty? ? nil : material
58
+ end
59
+
60
+ def key?
61
+ !key_material.nil?
62
+ end
63
+ end
64
+ end
65
+ 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,279 @@
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
+ # end
25
+ #
26
+ # p = Patient.create!(ssn: "123-45-6789", dob: Date.new(1990, 1, 1))
27
+ # p.ssn # => "123-45-6789"
28
+ # p.reload.dob # => Wed, 01 Jan 1990
29
+ # p.ssn_ciphertext # => "AQEA..." (Base64 envelope; no plaintext at rest)
30
+ # p.ssn_encrypted? # => true
31
+ #
32
+ # `type:` casts the decrypted value (reuses the Storable caster set:
33
+ # :string default, :integer, :float, :decimal, :boolean, :date, :datetime).
34
+ # `key:` overrides the gem-level key per field (a String or a Proc).
35
+ #
36
+ # Notes:
37
+ # * The declared column must be `text` (or binary): it stores an opaque
38
+ # 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.
45
+ # * Never `update_column`/`update_columns` an encrypted field — those
46
+ # bypass the type and write raw plaintext to the column.
47
+ # * Auditing an encrypted field would persist its plaintext to the audit
48
+ # column, so declaring a field with BOTH `encryptable` and `auditable_by`
49
+ # raises. Maskable masks the decrypted value; Normalizable normalizes the
50
+ # plaintext before it is encrypted (order-independent).
51
+ module Encryptable
52
+ extend ActiveSupport::Concern
53
+
54
+ LABEL = "ConcernsOnRails::Models::Encryptable".freeze
55
+ VALID_TYPES = %i[string integer float decimal boolean date datetime].freeze
56
+
57
+ # Reusable ActiveModel casters. :decimal and :datetime round-trip through
58
+ # Strings and are handled explicitly (mirrors Models::Storable).
59
+ CASTERS = {
60
+ string: ActiveModel::Type::String.new,
61
+ integer: ActiveModel::Type::Integer.new,
62
+ float: ActiveModel::Type::Float.new,
63
+ boolean: ActiveModel::Type::Boolean.new,
64
+ date: ActiveModel::Type::Date.new,
65
+ datetime: ActiveModel::Type::DateTime.new
66
+ }.freeze
67
+
68
+ included do
69
+ # { field => { type:, key: } }. Subclasses inherit and may add fields.
70
+ class_attribute :encryptable_rules, instance_accessor: false, default: {}
71
+ # Backstop for the reverse declaration order (Auditable added AFTER
72
+ # Encryptable): the macro-time guard can't see a not-yet-declared audit.
73
+ before_save :encryptable_guard_audited_plaintext!
74
+ end
75
+
76
+ # Custom type registered on each encrypted column. cast handles user input
77
+ # (plaintext in memory), serialize encrypts on the write-to-DB path, and
78
+ # deserialize decrypts on the read-from-DB path. An immutable value type,
79
+ # so dirty tracking compares the cast plaintext — a re-save of unchanged
80
+ # data is not dirtied by GCM's random IV.
81
+ class EncryptedType < ActiveModel::Type::Value
82
+ PASSTHROUGH = :__concerns_on_rails_passthrough__
83
+
84
+ def initialize(type: :string, key: nil)
85
+ @type = type
86
+ @key = key
87
+ super()
88
+ end
89
+
90
+ # user assignment -> typed plaintext (no crypto)
91
+ def cast(value)
92
+ cast_typed(value)
93
+ end
94
+
95
+ # DB ciphertext -> typed plaintext
96
+ def deserialize(value)
97
+ return nil if value.nil?
98
+
99
+ plaintext = read_plaintext(value)
100
+ return nil if plaintext.nil?
101
+
102
+ cast_typed(plaintext)
103
+ end
104
+
105
+ # typed plaintext -> DB ciphertext
106
+ def serialize(value)
107
+ return nil if value.nil?
108
+
109
+ plaintext = canonical_string(value)
110
+ return nil if plaintext.nil?
111
+
112
+ write_ciphertext(plaintext)
113
+ end
114
+
115
+ private
116
+
117
+ def cast_typed(value)
118
+ case @type
119
+ when :decimal then to_big_decimal(value)
120
+ when :datetime then to_time(value)
121
+ else CASTERS[@type].cast(value)
122
+ end
123
+ rescue StandardError
124
+ nil
125
+ end
126
+
127
+ # Canonical, reversible String form fed to the cipher: cast to the typed
128
+ # value first, then format that type as a stable String.
129
+ def canonical_string(value)
130
+ typed = cast_typed(value)
131
+ return nil if typed.nil?
132
+
133
+ stringify(typed)
134
+ rescue StandardError
135
+ nil
136
+ end
137
+
138
+ def stringify(typed)
139
+ case @type
140
+ when :decimal then typed.to_s("F")
141
+ when :date then typed.iso8601
142
+ when :datetime then typed.utc.iso8601(6)
143
+ else typed.to_s
144
+ end
145
+ end
146
+
147
+ def to_big_decimal(value)
148
+ return nil if value.nil?
149
+
150
+ value.is_a?(BigDecimal) ? value : BigDecimal(value.to_s)
151
+ end
152
+
153
+ def to_time(value)
154
+ case value
155
+ when nil then nil
156
+ when ActiveSupport::TimeWithZone, Time then value
157
+ when DateTime then value.to_time
158
+ when Date then Time.utc(value.year, value.month, value.day)
159
+ when String
160
+ begin
161
+ Time.iso8601(value)
162
+ rescue ArgumentError
163
+ CASTERS[:datetime].cast(value)
164
+ end
165
+ else CASTERS[:datetime].cast(value)
166
+ end
167
+ end
168
+
169
+ def write_ciphertext(plaintext)
170
+ config = ConcernsOnRails.encryption
171
+ material = resolve_key_material(config)
172
+ return plaintext if material == PASSTHROUGH
173
+
174
+ ConcernsOnRails::Support::Encryptor.encrypt(
175
+ plaintext, key: material, salt: config.key_derivation_salt
176
+ )
177
+ end
178
+
179
+ def read_plaintext(stored)
180
+ config = ConcernsOnRails.encryption
181
+ material = resolve_key_material(config)
182
+ return stored if material == PASSTHROUGH
183
+
184
+ ConcernsOnRails::Support::Encryptor.decrypt(
185
+ stored, key: material, salt: config.key_derivation_salt
186
+ )
187
+ rescue ConcernsOnRails::Encryption::DecryptionError
188
+ raise if config.raise_on_decrypt_error
189
+
190
+ nil
191
+ 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
+ end
209
+
210
+ module ClassMethods
211
+ include ConcernsOnRails::Support::ColumnGuard
212
+
213
+ # 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
+
217
+ type = type.to_sym
218
+ raise ArgumentError, "#{LABEL}: unknown type ':#{type}' (valid: #{VALID_TYPES.join(', ')})" unless VALID_TYPES.include?(type)
219
+
220
+ ensure_columns!(LABEL, *fields)
221
+
222
+ fields.each do |field|
223
+ field = field.to_sym
224
+ encryptable_guard_auditable!(field)
225
+ self.encryptable_rules = encryptable_rules.merge(field => { type: type, key: key })
226
+ attribute field, EncryptedType.new(type: type, key: key)
227
+ encryptable_define_helpers(field)
228
+ encryptable_register_filter_parameter(field)
229
+ end
230
+ end
231
+
232
+ private
233
+
234
+ def encryptable_define_helpers(field)
235
+ # Raw stored value: the DB ciphertext once persisted (before the type
236
+ # deserializes it). Useful for migrations, debugging, and asserting no
237
+ # plaintext is at rest.
238
+ define_method("#{field}_ciphertext") { read_attribute_before_type_cast(field) }
239
+ define_method("#{field}_encrypted?") { read_attribute_before_type_cast(field).present? }
240
+ end
241
+
242
+ # Macro-time guard for the common order (Encryptable declared after
243
+ # Auditable): a field must not be both encrypted and audited.
244
+ def encryptable_guard_auditable!(field)
245
+ return unless respond_to?(:auditable_fields)
246
+ return unless Array(auditable_fields).map(&:to_sym).include?(field.to_sym)
247
+
248
+ raise ArgumentError,
249
+ "#{LABEL}: ':#{field}' is also declared with Auditable; auditing would persist the " \
250
+ "decrypted plaintext to the audit column. Remove it from auditable_by."
251
+ end
252
+
253
+ # Redact encrypted fields from Rails parameter logging when running
254
+ # inside a Rails app (guarded — no-op under bare ActiveRecord/tests).
255
+ def encryptable_register_filter_parameter(field)
256
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
257
+
258
+ filters = Rails.application.config.filter_parameters
259
+ filters << field unless filters.include?(field)
260
+ rescue StandardError
261
+ nil
262
+ end
263
+ end
264
+
265
+ private
266
+
267
+ def encryptable_guard_audited_plaintext!
268
+ return unless self.class.respond_to?(:auditable_fields)
269
+
270
+ overlap = self.class.encryptable_rules.keys & Array(self.class.auditable_fields).map(&:to_sym)
271
+ return if overlap.empty?
272
+
273
+ raise ArgumentError,
274
+ "#{LABEL}: #{overlap.map { |f| ":#{f}" }.join(', ')} declared with both Encryptable and " \
275
+ "Auditable; auditing would persist decrypted plaintext. Remove them from auditable_by."
276
+ end
277
+ end
278
+ end
279
+ end
@@ -0,0 +1,103 @@
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
+
33
+ # Encrypt a String, returning a Base64 envelope. nil passes through as nil
34
+ # (a blank column stays blank / NULL-able), never an encrypted empty value.
35
+ def encrypt(plaintext, key:, key_id: 0, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
36
+ return nil if plaintext.nil?
37
+
38
+ derived = normalize_key(key, salt: salt)
39
+ cipher = OpenSSL::Cipher.new(CIPHER)
40
+ cipher.encrypt
41
+ cipher.key = derived
42
+ iv = cipher.random_iv
43
+ header = [VERSION_BYTE, ALG_GCM, key_id & 0xFF].pack(HEADER_FORMAT)
44
+ cipher.auth_data = header
45
+ ciphertext = cipher.update(plaintext.to_s) + cipher.final
46
+ [header + iv + cipher.auth_tag + ciphertext].pack("m0")
47
+ end
48
+
49
+ # Decrypt a Base64 envelope back to the plaintext String. nil -> nil. A
50
+ # wrong key, tampered ciphertext, or malformed envelope raises
51
+ # Encryption::DecryptionError (never a raw OpenSSL error).
52
+ def decrypt(envelope, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
53
+ return nil if envelope.nil?
54
+
55
+ # "m0" is strict Base64 and raises ArgumentError on non-Base64 input.
56
+ raw =
57
+ begin
58
+ envelope.to_s.unpack1("m0").to_s
59
+ rescue ArgumentError
60
+ raise ConcernsOnRails::Encryption::DecryptionError, "malformed encryption envelope"
61
+ end
62
+ raise ConcernsOnRails::Encryption::DecryptionError, "malformed encryption envelope" if raw.bytesize < MIN_ENVELOPE_BYTES
63
+
64
+ header = raw.byteslice(0, HEADER_LEN)
65
+ iv = raw.byteslice(HEADER_LEN, IV_LEN)
66
+ tag = raw.byteslice(HEADER_LEN + IV_LEN, TAG_LEN)
67
+ ciphertext = raw.byteslice((HEADER_LEN + IV_LEN + TAG_LEN)..) || ""
68
+
69
+ derived = normalize_key(key, salt: salt)
70
+ cipher = OpenSSL::Cipher.new(CIPHER)
71
+ cipher.decrypt
72
+ cipher.key = derived
73
+ cipher.iv = iv
74
+ cipher.auth_tag = tag
75
+ cipher.auth_data = header
76
+ cipher.update(ciphertext) + cipher.final
77
+ rescue OpenSSL::Cipher::CipherError
78
+ raise ConcernsOnRails::Encryption::DecryptionError,
79
+ "could not decrypt value (wrong key or tampered ciphertext)"
80
+ end
81
+
82
+ # Coerce key material to a 32-byte AES key: raw 32-byte binary as-is, a
83
+ # 64-hex string decoded, otherwise a passphrase stretched with PBKDF2. The
84
+ # salt + iteration count are part of the derived key's identity.
85
+ def normalize_key(value, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT)
86
+ value = value.call if value.respond_to?(:call)
87
+ material = value.to_s
88
+ raise ConcernsOnRails::Encryption::MissingKeyError, "no encryption key configured" if material.empty?
89
+
90
+ return material.b if material.bytesize == KEY_BYTES && material.encoding == Encoding::BINARY
91
+ return [material].pack("H*") if material.match?(/\A\h{64}\z/)
92
+
93
+ OpenSSL::KDF.pbkdf2_hmac(
94
+ material,
95
+ salt: salt.to_s,
96
+ iterations: ConcernsOnRails::Encryption::KDF_ITERATIONS,
97
+ length: KEY_BYTES,
98
+ hash: "SHA256"
99
+ )
100
+ end
101
+ end
102
+ end
103
+ end
@@ -1,3 +1,3 @@
1
1
  module ConcernsOnRails
2
- VERSION = "1.20.0".freeze
2
+ VERSION = "1.21.0".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.0
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-17 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