Legacy-key coverage report: find files still encrypted to the retained recovery key #348
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#348
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?
Why
Rotating the recovery identity is not retroactive.
Recipients()adds the recovery key at write time and nothing revisits a stored object, so after a rotation every pre-rotation file opens only with the key now sitting inrecoveryIdentityLegacy.Cairn keeps exactly one legacy slot. That gives an operator a hard ceiling: they can rotate once, and cannot safely rotate again until no stored object depends on the retained key — because a second rotation evicts it and orphans anything still encrypted to it.
Today the operator has no way to find out whether that condition holds.
docs/handbook/encryption.md("Retiring an old recovery key") can only offer wait it out or rewrite everything and hope, and explicitly tells them to keep the old key indefinitely because they cannot prove otherwise. That is an honest answer to a question the product should simply answer.This issue is the first version of that answer, and it is what unblocks repeat rotation as a supported operation.
Scope (v1)
An admin-triggered scan that reports which stored objects are still encrypted to the retained legacy recovery key.
recoveryIdentityLegacyis set.age.ExtractHeadergives the stanzas without decrypting the payload, so this is a header scan, not a decrypt pass.Definition of done
Deliberately out of scope here
Automatic re-encryption of the legacy-only set, scheduling, and any multi-key generalisation. Those belong to the follow-up deep dive (see the companion issue on legacy-slot handling) — this issue only has to make the current state visible.
Notes for implementation
mlkem768x25519vsX25519distinguishes post-quantum from classical objects and should appear in the report, since a PQ migration and a rotation can be in flight at the same time.deploymentIdentityLegacy; keep the scan generic over "retained identity" rather than hardcoding the recovery slot.Design decided (2026-09-03, mockups in
key-rotation-mockup.html): variant B — lifecycle stepper.Encryption & keys gains a "Retired recovery key" card, present only while a
*Legacyidentity is configured, headed by the ADR 0002 lifecycle drawn as a four-step rail (Rotate ✓ → Re-encrypt → Verify → Discard) with live sub-labels. Card states: coverage unknown → scanning (background, resumable, "headers only · no decryption") → report (three count cells: active / retired-only / neither, with PQ-flavour split and paths grouped by prefix with counts — never thousands of raw lines) → green verdict naming the exact config field to remove. "Covered by neither" renders as a red cell with an always-open path list. The Re-encrypt button's home is reserved but ships with the drain follow-up, not here.Implementation findings from the design pass:
age.Decryptwith one identity at a time; nil error = covered,ErrIncorrectIdentity= not. Public API only, microseconds per object per key, payload never read. The S3 driver's lazy rangedOpenmeans this costs one small ranged GET per object; no new driver capability.reservedandencrypt): coverage truth includes the.cairn-state/*.ageobjects — the recovery key seals those too, and a "covers nothing" verdict that ignored them would be a lie.deploymentIdentityLegacyuses the same machinery.Building now:
internal/keyscan+GET/POST /api/v1/admin/keyscan+ the stepper/card UI + i18n ×4 + handbook touch.Shipped — v0.6.115 (PR #409), live on both dogfoods.
Built exactly to the variant-B design:
internal/keyscantrial-unwrap scanner over the bare base driver (state objects included),GET/POST/DELETE /api/v1/admin/keyscan, and the Rotation tab on Encryption & keys with the lifecycle stepper, report grid, prefix-grouped paths, and the green discard verdict. Handbook retirement section rewritten to point at the scan. Two red-cycle fixes on the branch (missing package-dir mkdir in the one-shot; my own wrong test expectations for the two-segment grouping rule — the code was right).Dogfood note: both dogfoods have no
recoveryIdentityLegacy, so the Rotation tab is correctly hidden today — that in itself is checklist item 1. To exercise the full flow without a real rotation:age-keygen), put its secret intorecoveryIdentityLegacyin thecairn-enc-configsecret (untracked — Argo won't revert), restart the pod.recoveryIdentityLegacy.The amber still-covered state and the Re-encrypt button arrive with the drain follow-up (ADR 0002 rollout); a real rotation on the dogfood would light the amber path up today.
Closing: shipped in v0.6.115 (scan + Rotation tab, variant B stepper) and polished in v0.6.116 (permanent tab with idle explainer, docs link, flavour chips, ago-fix, 409 guard). Operator pass complete on both dogfoods — idle state, live amber lifecycle after a real rotation on cairn-openbao, and the genuine classical-legacy coverage report on cairn-enc (24 active / 3 retired-only / 0 neither). Handbook rewritten to point at the feature.
The re-encryption that drives these counts to zero is #411.