Module: OpenLoam::Encryption::Cipher
- Defined in:
- lib/open_loam/encryption/cipher.rb
Overview
AES-256-GCM sealing. GCM is authenticated encryption: the 16-byte auth tag turns tampering — or decrypting with the wrong key — into a loud failure on open, never silent garbage.
Stored format, one string column:
"v1:" + base64( iv[12] ++ auth_tag[16] ++ ciphertext ) (no AAD)
"v2:" + base64( iv[12] ++ auth_tag[16] ++ ciphertext ) sealed WITH AAD
The version tag lets the scheme evolve without a stop-the-world migration: v2 binds Additional Authenticated Data (the field's tenant+table+column) into the auth tag, so a ciphertext moved to a DIFFERENT column/table/tenant fails the tag on read — it can't be transplanted. Old "v1:" rows (no AAD) keep decrypting, so upgrading is a lazy re-encrypt (open_loam:encryption:rotate writes v2), never a data migration. The AAD is authenticated but NOT secret — it never conceals anything, it only pins WHERE the ciphertext is allowed to live.
Constant Summary collapse
- VERSION =
legacy, no AAD — still readable
"v1".freeze
- V2 =
current writes — AAD-bound
"v2".freeze
- IV_BYTES =
GCM's standard nonce size
12- TAG_BYTES =
full-length GCM tag; a shorter tag weakens authentication
16
Class Method Summary collapse
-
.open(payload, key, aad: nil) ⇒ Object
Decrypt, or raise OpenLoam::Encryption::DecryptionError.
-
.seal(plaintext, key, aad: nil) ⇒ Object
Encrypt with a fresh random IV.
Class Method Details
.open(payload, key, aad: nil) ⇒ Object
Decrypt, or raise OpenLoam::Encryption::DecryptionError. The wrong tenant's
key, a tampered blob, a truncated tag, a v2 blob read with the WRONG (or
missing) AAD, or plain garbage all fail the same loud way — never a
partial or silently-wrong plaintext. A v1 blob carries no AAD, so the
passed aad: is ignored for it (backward compatible).
45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 |
# File 'lib/open_loam/encryption/cipher.rb', line 45 def self.open(payload, key, aad: nil) version, blob = payload.to_s.split(":", 2) raise DecryptionError, "unrecognized ciphertext format" unless [ VERSION, V2 ].include?(version) && blob raw = blob.unpack1("m0") # Enforce the full IV+tag length BEFORE slicing: OpenSSL will verify a # truncated tag against a truncated blob, so a short payload must be # rejected here, not handed to the cipher. raise DecryptionError, "ciphertext too short" if raw.nil? || raw.bytesize < IV_BYTES + TAG_BYTES iv = raw.byteslice(0, IV_BYTES) tag = raw.byteslice(IV_BYTES, TAG_BYTES) ciphertext = raw.byteslice(IV_BYTES + TAG_BYTES..) || "" cipher = OpenSSL::Cipher.new("aes-256-gcm").decrypt cipher.key = key cipher.iv = iv cipher.auth_tag = tag cipher.auth_data = aad if version == V2 && aad # v2 rows require the matching AAD plaintext = cipher.update(ciphertext) + cipher.final # Decryption yields ASCII-8BIT bytes; our columns hold UTF-8 text. plaintext.force_encoding(Encoding::UTF_8) rescue OpenSSL::Cipher::CipherError, ArgumentError, TypeError # $! is preserved as the DecryptionError's `cause`. The message stays # deliberately vague — it must not distinguish "wrong key" from # "corrupt data" to a caller. raise DecryptionError, "could not decrypt (wrong key or corrupt data)" end |
.seal(plaintext, key, aad: nil) ⇒ Object
Encrypt with a fresh random IV. Reusing an IV under one key is
catastrophic for GCM, so the IV is never derived or fixed — always
OpenSSL's CSPRNG, once per value. With an aad: the ciphertext is bound
to that context (v2); without one it stays v1 (a bare tenant-scoped blob).
29 30 31 32 33 34 35 36 37 38 |
# File 'lib/open_loam/encryption/cipher.rb', line 29 def self.seal(plaintext, key, aad: nil) cipher = OpenSSL::Cipher.new("aes-256-gcm").encrypt cipher.key = key iv = cipher.random_iv version = aad ? V2 : VERSION cipher.auth_data = aad if aad # folded into the tag, not encrypted ciphertext = cipher.update(plaintext) + cipher.final tag = cipher.auth_tag(TAG_BYTES) "#{version}:" + [iv + tag + ciphertext].pack("m0") end |