Envelope encryption deep-dive
Every sensitive field in CertAutoPilot's database is protected by a two-level key hierarchy: a per-field Data Encryption Key (DEK) encrypts the payload; a single Key Encryption Key (KEK) wraps the DEK. This page is the cryptographic anatomy — what's in each envelope, how encryption and decryption flow, why the design looks this way.
For the user-facing overview and the list of what's encrypted, see Encryption → Overview. For rotation mechanics, see KEK rotation.
Envelope shape
Every encrypted field lands in MongoDB as a document with this shape:
{
"wrapped_dek": BinData, // DEK encrypted by the KEK
"nonce": BinData, // 12–16 bytes of AES-GCM IV
"ciphertext": BinData, // AES-256-GCM(payload, DEK, nonce)
"kek_version": 2, // which KEK wrapped this DEK
"provider": "env" // "env" or "pkcs11"
}
The authentication tag is appended to ciphertext by the AEAD construction (Go's cipher.AEAD.Seal convention).
Write path
- Generate 32 random bytes → DEK.
- Generate 12-byte nonce (16 bytes for HSMs that override, see below).
ciphertext = AES-256-GCM(payload, DEK, nonce).- Ask the active KEK provider to wrap the DEK:
- env: AES-256-GCM of the DEK under the KEK bytes (with its own internal nonce).
- pkcs11: call
CKM_AES_GCMon the HSM handle for versionN.
- Write the envelope doc.
- Discard the DEK from memory.
The DEK never persists. The KEK is in memory (env) or inaccessible (pkcs11). A mongodump yields only wrapped_dek + ciphertext — decryption requires both the KEK and the ability to run AES-GCM, which is what the running backend process has.
Read path
- Read the envelope doc.
- Check
provider— reject if it disagrees with the configured provider (defence against install-lock bypass). - Ask the provider to unwrap the DEK using
kek_version. If the version isn't loaded, abort with a clear error. payload = AES-256-GCM⁻¹(ciphertext, DEK, nonce).- On AEAD tag mismatch: abort. The envelope was tampered with.
- Return payload; discard DEK.
Why a per-field DEK
- Blast radius. A leak of one envelope is one field. Since each field has its own DEK, the adversary doesn't get a skeleton key.
- Unlimited AES-GCM message count. AES-GCM has a nonce-space limit of 2⁹⁶ per key. Using a fresh DEK per field trivially stays far below the birthday bound.
- Rotation locality. Rotating the KEK re-wraps DEKs; it does NOT re-encrypt payloads. The hot path for a 10 M-record rotation is 10 M small writes, not 10 M crypto passes on potentially-large payloads.
Nonce size
CertAutoPilot uses the 12-byte nonce that AES-GCM prescribes by default. Some HSMs (SoftHSM2 in particular) report a ulMinLen of 16 bytes — the pkcs11 provider detects this at startup and switches to aead.NonceSize() which picks the right number automatically. The legal range is [12, 16].
Provider tag safety
Tagging each envelope with the provider that sealed it is a defensive check: the backend refuses to unwrap a provider: env envelope when the active provider is pkcs11, and vice versa. This prevents silent data corruption if someone mutates the kek_install record manually or tries to mix two installs' data.
Version tag and rotation
kek_version lets one backend instance unwrap envelopes sealed by any previously-loaded version. Multiple versions can be loaded simultaneously — that's what makes the rotation workflow non-disruptive:
- Before rotation: every envelope has
kek_version: 1. - Add V2. Both V1 and V2 are loaded. New writes seal with V2. Old reads unwrap with V1.
- Run
kek rotate. Worker reads each envelope, unwraps with V1, re-wraps with V2, writes back.kek_versionflips to2. - After rotation: every envelope is
kek_version: 2. V1 can be retired.
The algorithm is the same for env and pkcs11. See KEK rotation.
Threat model
In scope
- MongoDB compromise / backup leak. Envelope encryption makes a stolen DB useless without the KEK.
- Read-replica exposure. Same story — encrypted payload is encrypted payload.
- Operator misbehaviour at the DB tier. A DBA with
find()access still needs the KEK to learn anything sensitive. - Cloud-provider data exfil. Disk snapshot of the MongoDB volume doesn't expose cleartext.
Out of scope
- Running-process memory. The backend must hold the KEK (env) or an HSM handle (pkcs11) to operate. A memory scraper inside the process can extract either. Mitigate with
LimitCORE=0,MemoryDenyWriteExecute=true, host-level access controls. - Catastrophic secret loss. Lose the KEK — standalone's
secrets.env, K8s's Secret, or the HSM key material — and encrypted data is unrecoverable. Back up both the DB AND the secret store. - Side channels on the HSM. CertAutoPilot uses the HSM's standard API; any side-channel vulnerability in the vendor's implementation is out of our hands.
Compared to MongoDB client-side field-level encryption (CSFLE)
MongoDB's CSFLE does something conceptually similar but requires the enterprise DB + a specific driver setup. Our envelope implementation runs at the Go code layer, works against any MongoDB 6.0+ cluster, and ships with HSM-backed key custody via PKCS#11. The two can coexist if you want belt-and-braces.
What is not envelope-encrypted
- User passwords — bcrypt hashed; irreversible by design.
- Refresh tokens — SHA-256 hashed; reuse detection needs a fixed digest for comparison.
- Certificate PEMs (public parts) — safe to expose.
Full list and the collection-level map: Encryption → Overview.