Skip to content

0052 — Key providers: local, OpenBAO transit and AWS KMS envelope, behind one interface

Status: accepted; the default is none per 0055 Date: 2026-09-18

The three providers and the interface below stand, for a deployment that needs to crypto-shred. What changed on 2026-09-22 is which one a deployment starts with: none of them.

Context

0047 decided that external identifiers become keyed pseudonyms, one key per tenant and purpose, never rotated, destroyed for erasure. It left open where those keys live. A deployment's answer depends on what it already runs — a secret manager, a cloud KMS, or neither — and the answer is permanent: keys cannot move between providers without either re-keying every tenant (a second identity for every person) or exporting key material, which two of the providers exist to make impossible.

The requirements every provider must meet:

  1. Stable. Same tenant, purpose and identifier give the same pseudonym, on every replica, for the whole retention.
  2. Separated. Two purposes give two unrelated pseudonyms; so do two tenants.
  3. Keyed. Nobody without the key can compute or check a pseudonym.
  4. Erasable one tenant at a time, and remembered as erased: a later record for an erased tenant must not quietly mint a fresh key.
  5. Scoped. A process may be able to make pseudonyms without being able to open sealed identities or erase keys.
  6. Sealing. The identity behind a pseudonym can be kept, sealed under the same key, for the resolutions the law requires — and dies with the key.

Decision

One interface (keys.Provider, and keys.Sealer for sealing), three implementations, chosen per deployment and recorded in its own decision.

local — a root and a directory

  • Key material. Each (tenant, purpose) key is 32 random bytes, generated by the writer on first use, wrapped (AES-GCM) under a 32-byte root the deployment supplies, and written to a file in a directory. Create-once: two writers racing on one directory mint one key, not two.
  • Pseudonym. HMAC-SHA256(key, identifier), computed in the writer.
  • Seal. AES-GCM under a key derived from the data key.
  • Erasure. The key file is removed and a tombstone written beside it.
  • Scope. None: whoever has the root and the directory can do everything.
  • Replicas. Only on one shared directory (ReadWriteMany); the writer refuses otherwise, and binds the directory's identity in the database.
  • Loss. Losing the directory is losing every tenant's pseudonyms, silently.
  • For: tests, a laptop, a single instance.

transit — OpenBAO (or Vault)

  • Key material. Each (tenant, purpose) is a key inside the transit engine, named <prefix>.<purpose>.<tenant>, created by the writer on first use through the encrypt endpoint. It never leaves the engine.
  • Pseudonym. The engine's hmac of the identifier, pinned to the key's first version.
  • Seal. The engine's encrypt, pinned to the first version; open is its decrypt.
  • Erasure. Rotate once, raise the minimum usable version past the first, trim the first away. The key remains as its own tombstone and cannot be re-created or un-trimmed.
  • Scope. The engine's policy, per role: the writer may hmac and encrypt on its purposes and nothing under transit/keys; resolve may decrypt only; a single-purpose role gets its purpose only; erasure is a human role's. A leaked writer credential can make pseudonyms and cannot open or erase any.
  • Authentication. The pod's projected service-account token on a JWT auth mount; no stored credential. A namespace per environment and a private CA are supported.
  • Replicas. Any number; they ask the same engine.
  • Loss. Covered by the engine's own storage, snapshots and DR. A snapshot taken before an erasure holds the key until it ages out.
  • Cost. A round trip per pseudonym.
  • For: a deployment where secrets already live in OpenBAO or Vault.

kms — AWS KMS envelope (built; amended 2026-10-09)

Built as the storage port's kms adapter (keys.adapter: kms, storage/keys/kms), reached through the SDK's default credential chain, so it runs in a pod (IRSA, Pod Identity) as well as in Lambda. The wrapped copies are kept where keys.state.backend says: database (the key table in the index database, audit_wrapped_keys with audit_wrapped_key_tombstones, migration 0007, the writer's role) or ssm (SSM Parameter Store). database needs no SSM.

  • Key material. One customer-managed KMS key per deployment is the root. Each (tenant, purpose) key is minted by GenerateDataKey with the encryption context {tenant: <t>, purpose: <p>}; the wrapped copy is stored in a key table in the index database. On first use the writer calls Decrypt with the same context and holds the plaintext data key in memory.
  • Pseudonym and seal. As local, in the writer, with the unwrapped key.
  • Erasure. The wrapped copy is deleted from the key table and the row kept as a tombstone. Not in the archive bucket: Object Lock would forbid the delete.
  • Scope. IAM conditions on kms:EncryptionContext:purpose (and tenant) decide which roles may unwrap which keys; Decrypt without the right context fails, so a blob cannot be unwrapped as another tenant's.
  • Replicas. Any number; they share the key table.
  • Loss. The key table is a database with backups; losing it without a backup is losing every pseudonym, as with local. A backup taken before an erasure holds the wrapped key until it ages out.
  • Cost. One Decrypt per key per process; no call per pseudonym.
  • For: a deployment on AWS with no OpenBAO.
  • Why not one KMS key with derived children: KMS derives nothing. It has no per-tenant HMAC without a KMS key per tenant, and thousands of KMS keys cost and count. GenerateDataKeyPair is the asymmetric variant; pseudonyms need symmetric key bytes, and the one asymmetric key this component uses — the digest signing key — is a KMS signing key used with Sign.

Choosing

local transit kms
key material leaves the provider yes (files) never into writer memory
a leaked writer credential can everything make pseudonyms only unwrap its purposes' keys
more than one replica shared volume only yes yes
what must be backed up the directory the engine (already) the key table
erasure is remembered by a tombstone file the key itself a tombstone row
per-pseudonym cost none a network call none
built yes yes yes (keys.adapter: kms)

Tests and trials use local. A deployment with OpenBAO uses transit. A deployment on AWS without OpenBAO uses kms.

Consequences

  • A deployment's key provider is part of its own decision record, and cannot be changed later without re-keying.
  • The writer's credential is the most sensitive in the installation under local, and one of the least under transit; that difference is the main reason transit is preferred where it is available.
  • Erasure is complete only when the provider's own backups taken before it have aged out; a deployment states that period with its retention terms.
  • Building kms meant (done, with keys.state.backend: database; the keys.adapter: kms shape replaced keys.provider: kms): the provider, the key table and its migration, destroy with the tombstone, --key-provider kms, the chart's keys.provider: kms, and tests against a KMS that implements GenerateDataKey with a context.

Alternatives considered

  • One key for everything. Breaks separation (2) and makes erasure all or nothing.
  • A key derived per tenant from one root with an HKDF. Stable and cheap, but erasure cannot be done one tenant at a time: the root derives every key again. A tombstone could refuse derivation, but anyone with the root and the algorithm would still compute the erased key.
  • Pseudonymising in the application. Every emitter would hold keys, and a profile's treatment would be decided in a hundred places instead of one. Rejected in 0047.
  • Rotating keys. Gives every person a second identity at each rotation; the threat rotation answers is answered by where the key lives.