KemalIdentity::MFA::AesSecretBox
Inherits KemalIdentity::MFA::SecretBox < Reference < Object
AES-256-CBC with an HMAC-SHA-256 tag over the ciphertext: encrypt-then-MAC.
Why not GCM
It would be the obvious choice, and Crystal's OpenSSL::Cipher does not expose the
authentication tag — there is no auth_tag accessor and no EVP_CIPHER_CTX_ctrl binding,
so a GCM tag cannot be read out or supplied back without binding libcrypto directly. That
is a dependency this shard will not take for one table. Encrypt-then-MAC over CBC is the
classical construction it replaced, it is secure when done in this order, and every piece
of it is in the standard library.
The order matters and is not a detail: MAC over the ciphertext, verified before decrypting. MAC-then-encrypt invites a padding oracle, because the padding is checked on data that has not been authenticated yet. Here a blob that fails the tag is never fed to the cipher at all.
The blob
version (1) | iv (16) | tag (32) | ciphertext (16n)
The version byte is covered by the tag and exists so a future scheme can be added without a migration: a reader that meets a version it does not know refuses the row rather than guessing at its layout.
Constants
Domain separation. One master key, two derived keys that must never be interchangeable: a construction where the same bytes both encrypt and authenticate is one where an attacker who breaks one has broken both.
Constructors
key is the application's master key, and it is the whole of the protection: it belongs
in configuration or a secrets manager, never in the database this box writes to, and
never in the repository.
At least 32 bytes, because the derived keys are only as strong as what they came from.
Generate one with Random::Secure.hex(32) and treat losing it as losing every enrolled
factor — see #reseal.
Instance methods
Redacted: the derived keys are the whole protection, and a config dump in a crash report must not print them.
The secret inside sealed, or nil if it does not authenticate under this key.
Re-encrypts a blob under this box, given the one it was sealed with.
Key rotation, in the only form this design supports: read every factor, reseal, write
it back. That is an offline job over a small table, not a migration, and it is deliberate
— carrying several keys and a key id inside the blob would make the common path pay for
a rotation that happens once.
Returns nil when previous cannot open the blob, so a partly-rotated table is visible
rather than silently re-sealed as garbage.