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

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