Module: ConcernsOnRails::Support::Encryptor
- Defined in:
- lib/concerns_on_rails/support/encryptor.rb
Overview
Pure AES-256-GCM codec shared by Models::Encryptable. Like the other Support modules (Masker, Money) key material is always passed in, and it uses only stdlib OpenSSL, matching the dependency-free crypto already in Controllers::WebhookVerifiable. The one piece of state is a bounded, mutex-guarded memo of PBKDF2-derived keys (see KEY_CACHE_MAX below): without it every encrypt/decrypt/blind-index call re-runs the 65,536 -iteration KDF, turning a 200-row load into seconds of key stretching.
On-disk envelope (Base64 via pack("m0"), the gem's convention — avoids the base64 gem, no longer default on Ruby 3.4):
ver(1)=0x01 | alg(1)=0x01 | key_id(1) | iv(12) | auth_tag(16) | ciphertext
The 3-byte header is fed to GCM as additional authenticated data (AAD), so
the version/algorithm/key-id cannot be altered without failing the auth
tag. alg 0x11 (deterministic) and a non-zero key_id (rotation) are
reserved for later features — the format tolerates them without a break.
Constant Summary collapse
- VERSION_BYTE =
0x01- ALG_GCM =
0x01- HEADER_FORMAT =
"C3".freeze
- HEADER_LEN =
3- IV_LEN =
96-bit IV: GCM's recommended size
12- TAG_LEN =
128-bit auth tag
16- KEY_BYTES =
AES-256
32- CIPHER =
"aes-256-gcm".freeze
- MIN_ENVELOPE_BYTES =
HEADER_LEN + IV_LEN + TAG_LEN
- BLIND_INDEX_INFO =
Domain-separation label so the blind-index HMAC key differs from the AES key even when both derive from the same configured secret.
"concerns_on_rails/blind_index/v1".freeze
- KEY_CACHE_MUTEX =
Mutex.new
- KEY_CACHE_MAX =
64
Class Method Summary collapse
-
.blind_index(value, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Deterministic keyed fingerprint (lowercase hex) for equality lookups — a "blind index".
-
.decrypt(envelope, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Decrypt a Base64 envelope back to the plaintext String.
-
.derived_key(material, salt) ⇒ Object
PBKDF2 is deliberately slow; the derived key for a given (passphrase, salt, iterations) triple never changes, so memoize it.
-
.encrypt(plaintext, key:, key_id: 0, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Encrypt a String, returning a Base64 envelope.
-
.normalize_key(value, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Coerce key material to a 32-byte AES key: raw 32-byte binary as-is, a 64-hex string decoded, otherwise a passphrase stretched with PBKDF2.
- .reset_key_cache! ⇒ Object
Class Method Details
.blind_index(value, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Deterministic keyed fingerprint (lowercase hex) for equality lookups — a "blind index". The HMAC key is domain-separated from the AES key via BLIND_INDEX_INFO, so the two are cryptographically independent. The same value + key always yields the same digest, enabling an indexed WHERE.
92 93 94 95 96 97 98 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 92 def blind_index(value, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) return nil if value.nil? master = normalize_key(key, salt: salt) subkey = OpenSSL::HMAC.digest("SHA256", master, BLIND_INDEX_INFO) OpenSSL::HMAC.hexdigest("SHA256", subkey, value.to_s) end |
.decrypt(envelope, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Decrypt a Base64 envelope back to the plaintext String. nil -> nil. A wrong key, tampered ciphertext, or malformed envelope raises Encryption::DecryptionError (never a raw OpenSSL error).
58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 58 def decrypt(envelope, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) return nil if envelope.nil? # "m0" is strict Base64 and raises ArgumentError on non-Base64 input. raw = begin envelope.to_s.unpack1("m0").to_s rescue ArgumentError raise ConcernsOnRails::Encryption::DecryptionError, "malformed encryption envelope" end raise ConcernsOnRails::Encryption::DecryptionError, "malformed encryption envelope" if raw.bytesize < MIN_ENVELOPE_BYTES header = raw.byteslice(0, HEADER_LEN) iv = raw.byteslice(HEADER_LEN, IV_LEN) tag = raw.byteslice(HEADER_LEN + IV_LEN, TAG_LEN) ciphertext = raw.byteslice((HEADER_LEN + IV_LEN + TAG_LEN)..) || "" derived = normalize_key(key, salt: salt) cipher = OpenSSL::Cipher.new(CIPHER) cipher.decrypt cipher.key = derived cipher.iv = iv cipher.auth_tag = tag cipher.auth_data = header cipher.update(ciphertext) + cipher.final rescue OpenSSL::Cipher::CipherError raise ConcernsOnRails::Encryption::DecryptionError, "could not decrypt value (wrong key or tampered ciphertext)" end |
.derived_key(material, salt) ⇒ Object
PBKDF2 is deliberately slow; the derived key for a given
(passphrase, salt, iterations) triple never changes, so memoize it.
Keyed by the passphrase's SHA-256 digest rather than the passphrase
itself to avoid retaining extra plaintext copies. Correctness never
depends on invalidation — different material/salt is a different key —
so reset_key_cache! (called from ConcernsOnRails.configure_encryption)
is memory hygiene for rotated-away secrets, not a cache-coherence hook.
124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 124 def derived_key(material, salt) cache_key = [OpenSSL::Digest::SHA256.digest(material), salt, ConcernsOnRails::Encryption::KDF_ITERATIONS] KEY_CACHE_MUTEX.synchronize do cache = (@key_cache ||= {}) cache.fetch(cache_key) do cache.shift if cache.size >= KEY_CACHE_MAX # FIFO eviction cache[cache_key] = OpenSSL::KDF.pbkdf2_hmac( material, salt: salt, iterations: ConcernsOnRails::Encryption::KDF_ITERATIONS, length: KEY_BYTES, hash: "SHA256" ) end end end |
.encrypt(plaintext, key:, key_id: 0, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Encrypt a String, returning a Base64 envelope. nil passes through as nil (a blank column stays blank / NULL-able), never an encrypted empty value.
41 42 43 44 45 46 47 48 49 50 51 52 53 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 41 def encrypt(plaintext, key:, key_id: 0, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) return nil if plaintext.nil? derived = normalize_key(key, salt: salt) cipher = OpenSSL::Cipher.new(CIPHER) cipher.encrypt cipher.key = derived iv = cipher.random_iv header = [VERSION_BYTE, ALG_GCM, key_id & 0xFF].pack(HEADER_FORMAT) cipher.auth_data = header ciphertext = cipher.update(plaintext.to_s) + cipher.final [header + iv + cipher.auth_tag + ciphertext].pack("m0") end |
.normalize_key(value, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Coerce key material to a 32-byte AES key: raw 32-byte binary as-is, a 64-hex string decoded, otherwise a passphrase stretched with PBKDF2. The salt + iteration count are part of the derived key's identity.
103 104 105 106 107 108 109 110 111 112 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 103 def normalize_key(value, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) value = value.call if value.respond_to?(:call) material = value.to_s raise ConcernsOnRails::Encryption::MissingKeyError, "no encryption key configured" if material.empty? return material.b if material.bytesize == KEY_BYTES && material.encoding == Encoding::BINARY return [material].pack("H*") if material.match?(/\A\h{64}\z/) derived_key(material, salt.to_s) end |
.reset_key_cache! ⇒ Object
142 143 144 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 142 def reset_key_cache! KEY_CACHE_MUTEX.synchronize { @key_cache = {} } end |