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 +4 -4
- data/CHANGELOG.md +14 -0
- data/lib/concerns_on_rails/encryption.rb +87 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/encryptable.rb +349 -0
- data/lib/concerns_on_rails/support/encryptor.rb +118 -0
- data/lib/concerns_on_rails/version.rb +1 -1
- data/lib/concerns_on_rails.rb +20 -0
- metadata +5 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 87a3ceebf2b9eb0f8a00c9d6a8cc57d8f50c8b1ff698be887aef42da64eb08c3
|
|
4
|
+
data.tar.gz: 1a82dd3dbe363e65f3a0d91c66cb453d54415032def91e90a388663ce2e90dab
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|
data/lib/concerns_on_rails.rb
CHANGED
|
@@ -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.
|
|
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-
|
|
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
|