Skip to main content

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

  1. Generate 32 random bytes → DEK.
  2. Generate 12-byte nonce (16 bytes for HSMs that override, see below).
  3. ciphertext = AES-256-GCM(payload, DEK, nonce).
  4. 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_GCM on the HSM handle for version N.
  5. Write the envelope doc.
  6. 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

  1. Read the envelope doc.
  2. Check provider — reject if it disagrees with the configured provider (defence against install-lock bypass).
  3. Ask the provider to unwrap the DEK using kek_version. If the version isn't loaded, abort with a clear error.
  4. payload = AES-256-GCM⁻¹(ciphertext, DEK, nonce).
  5. On AEAD tag mismatch: abort. The envelope was tampered with.
  6. 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:

  1. Before rotation: every envelope has kek_version: 1.
  2. Add V2. Both V1 and V2 are loaded. New writes seal with V2. Old reads unwrap with V1.
  3. Run kek rotate. Worker reads each envelope, unwraps with V1, re-wraps with V2, writes back. kek_version flips to 2.
  4. 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.

See also