Decision 0013 — OpenBao Transit as an optional external seal for the DEK

OpenBao Transit as an optional additive seal for the DEK, keeping the age keyring recovery path and treating unreachable as an explicit error.

Status

Accepted · 2026-08-23. Extends Decision 0003: the key-provider seam it named ("an external KMS or OpenBao Transit key can substitute at bootstrap") is now realised as an additive unseal path, not a substitution.

Context

Decision 0003 wraps one 32-byte DEK to N age recipients. The daemon opens the store by unwrapping the DEK with the host identity (host.key), whose at-rest protection is a systemd credential, a scrypt passphrase, or — insecurely — plaintext in the data dir.

Operators who already run OpenBao want the auto-unseal pattern: keep the DEK's wrapping key in OpenBao, so a stolen data volume never contains what unwraps the DEK, and the daemon unseals over the network at boot rather than from a key on the same disk it is protecting.

The hard constraint is disaster recovery (invariant 7): recovery must never depend on a vendor or on a reachable service. So Transit cannot replace the age keyring — it can only add a second unseal path for the running daemon.

Decision

Additive, optional, backward-compatible. When no seal is configured, the on-disk layout and Open() are byte-identical to before: OpenWithSeal(dir, nil) is exactly Open(dir). A regression test pins this (TestUnconfiguredPathUnchanged).

A new file beside keyring.age. When configured, the DEK is wrapped with an OpenBao Transit key (transit/encrypt) and the resulting vault:v1:… ciphertext is stored as keyring.transit. keyring.age is left untouched — it still wraps the same DEK to the age recipients. The two files wrap the same DEK by two independent means.

Open path when configured. OpenWithSeal reads keyring.transit, calls transit/decrypt, and gets the DEK back with no host key required — the auto-unseal value. keyring.age, the recipients file, and the version file are still read (for KeyringAge, backups, and metadata); the host identity is loaded only if present.

Config sources, token never at rest. Address, token, and transit key name come from the environment (EXOCOMP_SEAL_TRANSIT_ADDRESS, _KEY, _TOKEN or _TOKEN_FILE, optional _NAMESPACE). The token is read from the environment or a file outside the data dir and is never written into the data dir — only the transit ciphertext is. The transit key itself never leaves OpenBao.

No new dependency. The Transit API is two POST endpoints and a bearer header; a hand client (internal/secrets/openbao.go) speaks it rather than pulling in the Vault SDK.

The recovery story (explicit)

The age recovery recipient opens the store without OpenBao, exactly as before: age -d -i recovery.key keyring.age yields the DEK, and exocomp kit derive reproduces every subkey offline. Transit is an additional unseal path for the daemon, never the only one. Losing OpenBao loses auto-unseal, not the data: the operator either restores OpenBao, or opens with the host key (unset the seal env), or recovers from the printed card. The monthly DR drill (Decision 0003) still runs from kit contents alone, with no OpenBao present.

Failure mode

A configured-but-unreachable OpenBao is an explicit error from Open, never a silent fallback to the host-key path — a silent fallback would let a network outage quietly downgrade the seal an operator chose. TestUnreachableOpenBaoIsExplicit proves the error surfaces even when a valid host.key that would open the store is present. Reverting to the host key is a deliberate exocomp seal disable (or unsetting the env), not something a dropped connection does.

Consequences

  • The DEK-zeroed-by-Close invariant is unchanged: the transit path lands the DEK in the same single []byte the age path does, mlocked and zeroed identically. A too-short decrypt result is cleared and rejected.
  • Nothing new is logged: the token and the DEK plaintext never reach a log line, a job event, or an error string (the transit error names the address and OpenBao's own error body, which carries no secret). TestNoSecretsLeak asserts it.
  • exocomp seal enable|disable|status manage the blob; serve sources the seal from the environment and reports the posture at boot.
  • OpenBao can now read the DEK it wraps. This is the same trust already extended to the host key's at-rest tier: it moves the wrapping key off the data volume onto OpenBao. It does not defend against root on the box, and OpenBao's own seal/audit posture becomes part of the operator's threat model.
  • Rotating the transit key on OpenBao's side is handled by re-running seal enable, which re-wraps the current DEK; the old keyring.transit is overwritten.

See Decision 0003 for the envelope this extends, and docs/security/threat-model.md for the seal's place in the model.