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 +4 -4
- data/CHANGELOG.md +7 -0
- data/lib/concerns_on_rails/encryption.rb +65 -0
- data/lib/concerns_on_rails/legacy_aliases.rb +1 -0
- data/lib/concerns_on_rails/models/encryptable.rb +279 -0
- data/lib/concerns_on_rails/support/encryptor.rb +103 -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: c419970fee56d2f4d1d02f534c07abd646219db975823b089f1801192eb15689
|
|
4
|
+
data.tar.gz: 266601e25a1dd81854dacc62f77c85d913e7dc633614a32001b43359abe33069
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
@@ -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
|
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.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-
|
|
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
|