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:
- Stable. Same tenant, purpose and identifier give the same pseudonym, on every replica, for the whole retention.
- Separated. Two purposes give two unrelated pseudonyms; so do two tenants.
- Keyed. Nobody without the key can compute or check a pseudonym.
- Erasable one tenant at a time, and remembered as erased: a later record for an erased tenant must not quietly mint a fresh key.
- Scoped. A process may be able to make pseudonyms without being able to open sealed identities or erase keys.
- 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
hmacof the identifier, pinned to the key's first version. - Seal. The engine's
encrypt, pinned to the first version; open is itsdecrypt. - 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
hmacandencrypton its purposes and nothing undertransit/keys; resolve maydecryptonly; 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
GenerateDataKeywith 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 callsDecryptwith 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(andtenant) decide which roles may unwrap which keys;Decryptwithout 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
Decryptper 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.
GenerateDataKeyPairis 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 withSign.
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 undertransit; that difference is the main reasontransitis 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
kmsmeant (done, withkeys.state.backend: database; thekeys.adapter: kmsshape replacedkeys.provider: kms): the provider, the key table and its migration, destroy with the tombstone,--key-provider kms, the chart'skeys.provider: kms, and tests against a KMS that implementsGenerateDataKeywith 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.