Skip to content

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_secret column means a database leak compromises every second factor on the platform.

Context

The current 2FA flow:

  1. enrollTOTP(userEmail) → generates a 160-bit base32 secret + QR code
  2. UI shows the QR for the user to scan into Google Authenticator / etc.
  3. The plaintext secret is written to customer_users.totp_secret (TEXT column)
  4. 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 dev working without a real Vault server
  • Makes the missing config obvious (warn line)
  • Forces production to fail-fast — requireVaultProduction() throws if NODE_ENV=production and 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_version after 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 VaultrequireVaultProduction() 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 agent sidecar 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).
  • pgcrypto column 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.ts
  • pkg/octopus/customer-app/src/lib/auth/totp.ts
  • pkg/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)