Module: ConcernsOnRails::Support::Encryptor
- Defined in:
- lib/concerns_on_rails/support/encryptor.rb
Overview
Pure, stateless AES-256-GCM codec shared by Models::Encryptable. Like the other Support modules (Masker, Money) it holds no state — key material is always passed in — and uses only stdlib OpenSSL, matching the dependency -free crypto already in Controllers::WebhookVerifiable.
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
Class Method Summary collapse
-
.decrypt(envelope, key:, salt: ConcernsOnRails::Encryption::DEFAULT_KDF_SALT) ⇒ Object
Decrypt a Base64 envelope back to the plaintext String.
-
.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.
Class Method Details
.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).
52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 52 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 |
.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.
35 36 37 38 39 40 41 42 43 44 45 46 47 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 35 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.
85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 |
# File 'lib/concerns_on_rails/support/encryptor.rb', line 85 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/) OpenSSL::KDF.pbkdf2_hmac( material, salt: salt.to_s, iterations: ConcernsOnRails::Encryption::KDF_ITERATIONS, length: KEY_BYTES, hash: "SHA256" ) end |