class

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

CIPHER = "aes-256-cbc"
ENCRYPTION_CONTEXT = "kemal_identity/mfa/secret-box/v1/encryption"

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.

IV_BYTES = 16
KEY_BYTES = 32
MAC_CONTEXT = "kemal_identity/mfa/secret-box/v1/authentication"
MIN_LENGTH = ((1 + IV_BYTES) + TAG_BYTES) + 16
TAG_BYTES = 32
VERSION = 1_u8

Constructors

new(key : Secret, random : RandomSource)

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.

Source

Instance methods

inspect(io : IO) : Nil

Redacted: the derived keys are the whole protection, and a config dump in a crash report must not print them.

Source
open?(sealed : Bytes) : Bytes | Nil

The secret inside sealed, or nil if it does not authenticate under this key.

Source
reseal(sealed : Bytes, previous : SecretBox) : Bytes | Nil

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.

Source
seal(secret : Bytes) : Bytes

Encrypts secret, returning an opaque blob to store.

Source
to_s(io : IO) : Nil

Redacted: the derived keys are the whole protection, and a config dump in a crash report must not print them.

Source