ADR-0078: TOTP secret encryption via Vault Transit¶
- Status: Accepted
- Date: 2026-05-23
- Driver: 2026-05-23 best-practices review §2.8.2 — TOTP secret stored
plaintext in
customer_users.totp_secretcolumn means a database leak compromises every second factor on the platform.
Context¶
The current 2FA flow:
enrollTOTP(userEmail)→ generates a 160-bit base32 secret + QR code- UI shows the QR for the user to scan into Google Authenticator / etc.
- The plaintext secret is written to
customer_users.totp_secret(TEXT column) - Verification reads the same column + runs
authenticator.verify({token, secret})
The TOTP secret functions as a persistent shared secret between the server and the user's authenticator app. If a DB leak occurs, the attacker can forge valid 2FA codes for every user in the dump until each user manually re-enrolls. This is materially worse than a password leak — passwords are hashed; this column is not.
The 2026-05-23 best-practices review §2.8.2 flagged the file comment
on lib/auth/totp.ts that already acknowledged the gap:
Secret armazenado encrypted at rest via HSM (Wave-1 stub: Postgres plaintext column — substitute by Vault Transit em prod).
The Go side already has a Vault Transit client in
pkg/octopus/common/hsm/vault.go. This ADR brings the customer-app
TS side to parity.
Decision¶
Wrap the TOTP secret in Vault Transit encrypt/decrypt at the storage boundary. The plaintext secret continues to exist only during the enrollment + verification call windows — never on disk.
Components¶
| Module | Role |
|---|---|
lib/auth/vault-transit.ts |
New. Minimal HTTP wrapper for POST /v1/<mount>/encrypt/<key> + decrypt/<key>. ~150 LoC. |
lib/auth/totp.ts |
Updated. enrollTOTP now returns secretCiphertext (the column-ready value). verifyTOTPStored accepts the stored value (encrypted OR legacy plaintext) and reports legacyPlaintext so callers can rotate. |
instrumentation.ts |
Add requireVaultProduction() call at startup so prod pods refuse to boot without Vault config (planned in companion PR; not strict-blocker for this ADR). |
Configuration¶
OCTOPUS_VAULT_ADDR = https://vault.octopus.svc.cluster.local:8200
OCTOPUS_VAULT_TOKEN = (service account token; projected volume; rotated)
OCTOPUS_VAULT_MOUNT = "transit" (default)
OCTOPUS_VAULT_TOTP_KEY = "totp-secrets" (default)
Bootstrap on the Vault side:
vault secrets enable transit
vault write -f transit/keys/totp-secrets type=aes256-gcm96
aes256-gcm96 is FIPS-eligible. PQC-ready when Vault 1.18+ ships
ML-KEM on Transit (already on roadmap).
Backward compat — legacy plaintext rows¶
Pre-2026-05-23 enrollments wrote the secret as plaintext base32
(JBSWY3DPEHPK3PXP...). The new verifyTOTPStored detects this via
the vault:vN: prefix:
isVaultCiphertext(stored)
→ true → decrypt via Vault → verify(plaintext, token)
→ false → use stored as-is + flag legacyPlaintext=true
When the verify succeeds AND legacyPlaintext=true, the caller is
expected to silently re-enroll the user (encrypt their existing
plaintext + UPDATE customer_users) so the next verify uses the
encrypted path. This pattern mirrors the password-hash
needsRehash flow introduced in PR #973.
Dev fallback¶
When OCTOPUS_VAULT_ADDR is empty (typical local dev), encryptTransit
passes the plaintext through unchanged with a console.warn. This:
- Keeps
npm run devworking without a real Vault server - Makes the missing config obvious (warn line)
- Forces production to fail-fast —
requireVaultProduction()throws ifNODE_ENV=productionand Vault is unconfigured
The combination is the safe default: never silently degrade in prod.
Consequences¶
Positive¶
- DB leak no longer compromises TOTP secrets — attacker would need both the DB dump AND Vault access (separate trust domain).
- Key rotation is independent of column rotation — bump the Vault key version, set
min_decryption_versionafter rewrap, no DB writes needed. - Vault audit log captures every encrypt/decrypt op — forensic visibility.
- 15 unit tests (vault-transit + isVaultCiphertext + requireVaultProduction) prove the contract.
Negative¶
- One extra round-trip per verify — adds ~5-15 ms to 2FA verify (Vault Transit is local-cluster; intra-pod latency).
- Hard prod dependency on Vault —
requireVaultProduction()is a fail-fast guard; ops must ensure Vault is healthy before customer-app pods boot. Mitigated by Vault HA cluster + K8s startup-probe wait. - Token rotation — service-account Vault token rotation must be wired (recommended:
vault agentsidecar with projected service-account token; not in scope of this ADR).
Alternatives considered¶
- Encrypt with a hardcoded AES key in env — rejected: same problem (env vars leak with the secret manager dump).
pgcryptocolumn encryption — rejected: key lives in the same DB, defeating the threat model.- AWS KMS / GCP KMS direct — rejected initially: introduces cloud-vendor coupling. ADR-0078 stays cloud-agnostic via Vault Transit; Vault can front KMS as a backend if desired.
- Custom AES + key file mounted from K8s Secret — rejected: K8s Secret rotation is heavyweight (rolling restart) and the key material sits on the node filesystem in plain etcd.
See also¶
pkg/octopus/customer-app/src/lib/auth/vault-transit.tspkg/octopus/customer-app/src/lib/auth/totp.tspkg/octopus/common/hsm/vault.go— Go-side Vault Transit consumer- PR #973 — rotate-on-verify pattern (sibling, for password hashes)
- ADR-0054 — PQC roadmap (Vault Transit ML-KEM support coming)