Deep dive: one legacy key slot is a design ceiling on key rotation #349
Labels
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: Cordy/Cairn#349
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
The finding
Every key provider holds exactly one retained identity:
StaticKeys{Identity, Recovery, RecoveryLegacy, LegacyIdentity}(internal/storage/encrypt/static.go)KeycloakKeys{Recovery, RecoveryLegacy}(keycloak.go)OpenBaoKeys{Recovery, RecoveryLegacy}(openbao.go)cmd/cairnd/main.goseals state toRecoveryIdentity+DeploymentIdentityand opens with the two*Legacysingulars.Because rotation is not retroactive, a stored object stays readable only by the identities it was written to. One legacy slot therefore means an instance can be rotated once. A second rotation silently evicts the still-load-bearing key and orphans every object that depended on it.
Note the asymmetry: per-user and per-space identities do not have this problem —
resolveKeysetkeeps a fullall []age.Identitylist and retains every older identity for decryption (that is what makes upgrade-on-touch lossless). The restriction exists only for the deployment-level identities, which are parsed from single config strings.Why this is a deep dive and not a fix
Widening
RecoveryLegacyto a list is a small change on its face, and it may well be the answer. But it decides several things at once, and getting them wrong is unrecoverable by construction:recoveryIdentityLegacyis a documented scalar. Does it become an array, does a second field appear, or do keys move out of config entirely into the state store (which #138 already relocated everything else into)?Recipients(). Worth confirming rather than assuming, since it determines whether a mixed classical/hybrid retained set is legal.Where it stands today
Documented rather than fixed, in
docs/handbook/encryption.md→ "Rotating the recovery identity", which states the one-slot constraint as a hard rule and tells operators to finish retiring the old key before rotating again. That is correct guidance for current behaviour and buys time, but it leaves a real ceiling in place.Outcome wanted from this issue
A decision — recorded as an ADR under
docs/adr/— on how deployment-level key history should be represented, covering the five points above. Implementation follows from it and may well be small.Related
Decided and recorded: ADR 0002 — rotate, drain, verify, discard (PR #408, operator direction 2026-09-03).
The keychain option is rejected outright: no growing retained-key list at the deployment level. Instead the two-key shape (active + one retiring) stays, and rotation becomes a completable operation — a managed background drain re-encrypts everything the retiring key covers (through the normal write pipeline, so custody re-resolution and PQ upgrade-on-touch ride along), #348's header scan verifies coverage has reached zero, and only then is the old key discarded. Next rotation is allowed from that state.
Answers to the five questions in this issue's brief:
Header-surgery rewrap (MinIO-style) was evaluated and rejected with reasoning in the ADR: on S3 the object must be re-uploaded either way, and the hand-rolled header serialization loses on trust surface, flavour rules, and PQ synergy.
Precedent this follows: Vault transit's rotate→rewrap→raise-min-decryption-version cycle, MinIO's payload-untouched key rotation, NIST SP 800-57's re-encrypt-on-compromise. Sources in the ADR.
Implementation path: #348 builds the scan (now explicitly the drain's first pass), then a follow-up issue for the drain job + ledger + tripwire + admin surface + handbook rewrite. This issue can close once the ADR reads right to you.
Closing: the outcome this issue asked for — a recorded decision — is delivered and approved. ADR 0002 (
docs/adr/0002-key-rotation-drain.md, PR #408) fixes the bounded two-key model: rotate → drain → verify → discard, at most two keys ever, no growing keychain.Already real in v0.6.115/116: the verify step (#348 coverage scan) and the Rotation tab lifecycle, exercised on both dogfoods including a live rotation on cairn-openbao and the mixed classical/hybrid window on cairn-enc.
Implementation of the drain (re-encrypt job, ledger, boot tripwire, admin surface) is tracked in #411, including the two flags raised 2026-09-03: ownership preserved by construction + regression test, and locked-file deferral with the approved UI.