Key rotation: managed re-encryption (drain job + ledger + boot tripwire) #411

Closed
opened 2026-09-03 19:17:41 +00:00 by Cordy · 5 comments
Owner

Implements the drain half of ADR 0002 (docs/adr/0002-key-rotation-drain.md, merged via PR #408). #348 shipped the verify half (coverage scan + Rotation tab); this issue makes the rotate→drain→verify→discard cycle complete without waiting on users to touch files.

Drain job

  • Admin-triggered background job from the Rotation tab (Re-encrypt button in the card slot reserved for it since v0.6.115).
  • Walks the backend at the encrypt layer (below quota/ownermeta/filelock/trash/hold/audit/scope). For each retired-only object: decrypt with the retiring key — which by definition opens the whole drain set — and rewrite through the encrypted write path. That re-resolves the owner's current custody recipient, stamps the new recovery key, and upgrades the flavour to hybrid in the same pass.
  • Checkpointed on the listing cursor (resumable after interruption/restart), throttled (objects-per-second budget, Pi-friendly), convergent (new writes already carry the active key).
  • One key-drain audit event per run, with counts.

Locked-file deferral (decided 2026-09-03)

Never rewrite beneath a live lock — a stale re-encryption racing an Office save could clobber the newer content. Instead:

  • Consult the lock store before each rewrite; skip live-locked files, count them as deferred — locked, in use.
  • UI per the approved mockup (drain-locked-files-mockup.html): amber inline notice during the run ("N files are locked and in use — skipped, not stuck") with an expandable list showing path, lock type and holder; end-of-pass summary splits re-encrypted / deferred (locked) / failed; stepper sub-label counts only the true remainder; hint copy tells the operator not to break locks — deferral converges on its own (a save re-encrypts under the new key; the next pass catches the rest).
  • Verify stays gated until the count is genuinely zero.

Ownership guarantee (flagged 2026-09-03)

Safe by construction, pinned by test:

  • The drain writes below ownermeta, so no owner re-stamping can occur — there is no code path by which the drain could attribute a file to anyone.
  • Owner records are themselves encrypted objects and get drained content-identically (decrypt → re-encrypt of byte-identical plaintext).
  • Regression test required: drain an object plus its owner sidecar, assert the listing's Owner is unchanged.

Safety pieces (per ADR 0002)

  • Drain ledger: public halves of the active and retiring keys recorded in the state store.
  • Boot tripwire: refuse to start if an undrained key vanishes from config — replaces today's silent orphaning.

UI + docs

  • Rotation tab: Re-encrypt button, progress card (stepper step 2 goes live), Recovery ↔ Rotation cross-link.
  • Handbook: drain section + the rotation guide page at the canonical URL the idle tab already links (https://www.swisscairn.ch/docs/encryption#rotation).
  • Copy nits from the #348 screenshots: "1 objects skipped" needs the singular form; root-level group renders as a cryptic /… (2) — give the root group a legible label.

Known cost

Drained objects get a new modified time → sync clients re-download the drained set once. Document it.

Testbeds

Both dogfoods are live drain candidates: cairn-enc has 3 classical retired-only objects (first-ever green discard within reach), cairn-openbao has 18 hybrid ones from the 2026-09 rotation.

Implements the drain half of ADR 0002 (`docs/adr/0002-key-rotation-drain.md`, merged via PR #408). #348 shipped the **verify** half (coverage scan + Rotation tab); this issue makes the rotate→**drain**→verify→discard cycle complete without waiting on users to touch files. ## Drain job - Admin-triggered background job from the Rotation tab (**Re-encrypt** button in the card slot reserved for it since v0.6.115). - Walks the backend at the **encrypt layer** (below quota/ownermeta/filelock/trash/hold/audit/scope). For each retired-only object: decrypt with the **retiring** key — which by definition opens the whole drain set — and rewrite through the encrypted write path. That re-resolves the owner's current custody recipient, stamps the new recovery key, and upgrades the flavour to hybrid in the same pass. - **Checkpointed** on the listing cursor (resumable after interruption/restart), **throttled** (objects-per-second budget, Pi-friendly), **convergent** (new writes already carry the active key). - One `key-drain` audit event per run, with counts. ## Locked-file deferral (decided 2026-09-03) Never rewrite beneath a live lock — a stale re-encryption racing an Office save could clobber the newer content. Instead: - Consult the lock store before each rewrite; skip live-locked files, count them as **deferred — locked, in use**. - UI per the approved mockup (`drain-locked-files-mockup.html`): amber inline notice during the run ("N files are locked and in use — skipped, not stuck") with an expandable list showing path, lock type and holder; end-of-pass summary splits **re-encrypted / deferred (locked) / failed**; stepper sub-label counts only the true remainder; hint copy tells the operator not to break locks — deferral converges on its own (a save re-encrypts under the new key; the next pass catches the rest). - Verify stays gated until the count is genuinely zero. ## Ownership guarantee (flagged 2026-09-03) Safe by construction, pinned by test: - The drain writes below `ownermeta`, so no owner re-stamping can occur — there is no code path by which the drain could attribute a file to anyone. - Owner records are themselves encrypted objects and get drained **content-identically** (decrypt → re-encrypt of byte-identical plaintext). - **Regression test required:** drain an object plus its owner sidecar, assert the listing's Owner is unchanged. ## Safety pieces (per ADR 0002) - **Drain ledger:** public halves of the active and retiring keys recorded in the state store. - **Boot tripwire:** refuse to start if an undrained key vanishes from config — replaces today's silent orphaning. ## UI + docs - Rotation tab: Re-encrypt button, progress card (stepper step 2 goes live), Recovery ↔ Rotation cross-link. - Handbook: drain section + the rotation guide page at the canonical URL the idle tab already links (`https://www.swisscairn.ch/docs/encryption#rotation`). - Copy nits from the #348 screenshots: "1 object**s** skipped" needs the singular form; root-level group renders as a cryptic `/… (2)` — give the root group a legible label. ## Known cost Drained objects get a new modified time → sync clients re-download the drained set once. Document it. ## Testbeds Both dogfoods are live drain candidates: **cairn-enc** has 3 classical retired-only objects (first-ever green discard within reach), **cairn-openbao** has 18 hybrid ones from the 2026-09 rotation.
Cordy closed this issue 2026-09-03 20:05:10 +00:00
Author
Owner

Shipped in v0.6.117 (PR #412, merged; both dogfoods rolled and healthy).

Design decisions recorded 2026-09-03

  • Drain placement: rewrites go through the in-process encrypt decorator captured below quota/ownermeta/filelock — ownership preservation is by construction (no code path can re-stamp an owner; owner sidecars are drained content-identically) and pinned by TestDrainMovesRetiredOnly's sidecar assertion.
  • Per-user custody: the drain synthesises auth.ContextWithUser from the /home/<user>/ segment, so keycloak-profile and OpenBao custody re-resolve the owner's current recipient — and PQ upgrade-on-touch rides along, exactly as ADR 0002 promised.
  • Locked files (Nikola's flag): never rewritten (a stale rewrite could race a client save) — deferred with path/type/holder via filelock.Covering, shown in the amber notice + expandable list from the approved drain-locked-files-mockup.html; copy says outright that nothing is stuck and locks must not be broken.
  • State objects: re-seal via the statestore (Load with retained identities, Save to current recipients) — never through the content path, so state is never double-wrapped.
  • No persisted checkpoint: the pass is idempotent (classification skips already-active objects), so pause/resume/restart is simply running it again.
  • Ledger + tripwire: key-rotation.json beside the other runtime state (statestore-bound on s3); scan-proven zero marks entries drained; an undrained key vanishing from config refuses boot; CAIRN_KEYDRAIN_FORCE=1 is the documented escape hatch. Handbook section rewritten.

Operator pass wanted: both dogfoods are live testbeds — cairn-enc has 3 classical retired-only objects (one Re-encrypt press away from the first-ever verified discard), cairn-openbao ~18 hybrid. Deploys wiped sessions; fresh login needed.

**Shipped in v0.6.117** (PR #412, merged; both dogfoods rolled and healthy). **Design decisions recorded 2026-09-03** - *Drain placement:* rewrites go through the in-process encrypt decorator captured below quota/ownermeta/filelock — ownership preservation is by construction (no code path can re-stamp an owner; owner sidecars are drained content-identically) and pinned by `TestDrainMovesRetiredOnly`'s sidecar assertion. - *Per-user custody:* the drain synthesises `auth.ContextWithUser` from the `/home/<user>/` segment, so keycloak-profile and OpenBao custody re-resolve the owner's current recipient — and PQ upgrade-on-touch rides along, exactly as ADR 0002 promised. - *Locked files (Nikola's flag):* never rewritten (a stale rewrite could race a client save) — deferred with path/type/holder via `filelock.Covering`, shown in the amber notice + expandable list from the approved `drain-locked-files-mockup.html`; copy says outright that nothing is stuck and locks must not be broken. - *State objects:* re-seal via the statestore (Load with retained identities, Save to current recipients) — never through the content path, so state is never double-wrapped. - *No persisted checkpoint:* the pass is idempotent (classification skips already-active objects), so pause/resume/restart is simply running it again. - *Ledger + tripwire:* `key-rotation.json` beside the other runtime state (statestore-bound on s3); scan-proven zero marks entries drained; an undrained key vanishing from config refuses boot; `CAIRN_KEYDRAIN_FORCE=1` is the documented escape hatch. Handbook section rewritten. **Operator pass wanted:** both dogfoods are live testbeds — cairn-enc has 3 classical retired-only objects (one Re-encrypt press away from the first-ever verified discard), cairn-openbao ~18 hybrid. Deploys wiped sessions; fresh login needed.
Author
Owner

v0.6.118 (PR #413, dogfoods rolled): first real drain on cairn-enc surfaced two things.

  1. Principal-less paths/test/cairn-handoff.md, /cairn-fix-first.md, /sovereignty-check.txt are pre-per-user-homes relics at the backend root; per-user custody has no principal to resolve, so the encrypt layer refused. They now re-encrypt directly to the active deployment-level recipients (Drainer.Fallback): such objects open only via the retiring recovery key today, so this preserves exactly their current access, on the new key.
  2. Bogus-principal edge — a file directly under /home no longer resolves its own name as a username, so per-user custody can never be asked to mint IdP keys for a principal that does not exist.

Open observation: /home/Mülltrennung.docx lists but does not open (not found) — the same object the scan counts as "skipped". Likely a unicode-normalisation ghost (NFD vs NFC umlaut in the S3 key). Not a drain problem; if it is not visible/deletable in the UI, it becomes its own issue against the s3 driver.

**v0.6.118** (PR #413, dogfoods rolled): first real drain on cairn-enc surfaced two things. 1. *Principal-less paths* — `/test/cairn-handoff.md`, `/cairn-fix-first.md`, `/sovereignty-check.txt` are pre-per-user-homes relics at the backend root; per-user custody has no principal to resolve, so the encrypt layer refused. They now re-encrypt **directly to the active deployment-level recipients** (`Drainer.Fallback`): such objects open only via the retiring recovery key today, so this preserves exactly their current access, on the new key. 2. *Bogus-principal edge* — a file directly under `/home` no longer resolves its own name as a username, so per-user custody can never be asked to mint IdP keys for a principal that does not exist. **Open observation:** `/home/Mülltrennung.docx` lists but does not open (`not found`) — the same object the scan counts as "skipped". Likely a unicode-normalisation ghost (NFD vs NFC umlaut in the S3 key). Not a drain problem; if it is not visible/deletable in the UI, it becomes its own issue against the s3 driver.
Author
Owner

v0.6.119 (PR #415, dogfoods rolled): a finished drain pass now hands straight to verification — OnDone (on completion, not on pause) starts the coverage scan, so the Rotation card refreshes to the true remaining count instead of the stale pre-pass figure, and a zero result marks the ledger drained without any further click.

Found on cairn-openbao: the pass moved all 18 objects, but the headline (fed by the last scan) kept saying “18 objects remaining”, and a second Re-encrypt press just reported 0/0/0 until a manual Rescan. Decision recorded: auto-verify instead of a “Continue” button — the flow self-advances and the ledger benefits as a side effect.

Dogfood state: both instances reached the green “Covers nothing” verdict — cairn-enc (3 classical drained; the #414 ghost counts as skipped) and cairn-openbao (18 hybrid drained). Both are cleared for the discard step: remove recoveryIdentityLegacy from the untracked config Secret, restart, expect a clean boot with the ledger retiring the entry.

**v0.6.119** (PR #415, dogfoods rolled): a finished drain pass now hands straight to verification — `OnDone` (on completion, not on pause) starts the coverage scan, so the Rotation card refreshes to the true remaining count instead of the stale pre-pass figure, and a zero result marks the ledger drained without any further click. Found on cairn-openbao: the pass moved all 18 objects, but the headline (fed by the last scan) kept saying “18 objects remaining”, and a second Re-encrypt press just reported 0/0/0 until a manual Rescan. Decision recorded: auto-verify instead of a “Continue” button — the flow self-advances and the ledger benefits as a side effect. **Dogfood state:** both instances reached the green “Covers nothing” verdict — cairn-enc (3 classical drained; the #414 ghost counts as skipped) and cairn-openbao (18 hybrid drained). Both are cleared for the discard step: remove `recoveryIdentityLegacy` from the untracked config Secret, restart, expect a clean boot with the ledger retiring the entry.
Author
Owner

v0.6.120 (PR #416, dogfoods rolled): the tripwire (and the ledger-open failure path) now cancels the heartbeat and releases the state-backend instance lock before exiting. Found live during cairn-enc's first discard: every refused boot stranded the lock, so the recovery boot (key restored or CAIRN_KEYDRAIN_FORCE=1) sat in CrashLoopBackOff for the full 5-minute stale window.

And for the record — the tripwire's first live catch was a genuine one: the cairn-enc discard was attempted without a post-drain verified scan (the drain had moved all 3 objects, but the last completed scan still said 3 covered; the operator's rescan that day was on cairn-openbao). Boot refused exactly as designed; recovery via the documented CAIRN_KEYDRAIN_FORCE=1 escape hatch; ledger entry dropped with the loud warning. v0.6.119's auto-verify makes this scenario much harder to reproduce, since a finished pass now runs the scan itself.

**v0.6.120** (PR #416, dogfoods rolled): the tripwire (and the ledger-open failure path) now cancels the heartbeat and releases the state-backend instance lock before exiting. Found live during cairn-enc's first discard: every refused boot stranded the lock, so the recovery boot (key restored or `CAIRN_KEYDRAIN_FORCE=1`) sat in CrashLoopBackOff for the full 5-minute stale window. **And for the record — the tripwire's first live catch was a genuine one:** the cairn-enc discard was attempted without a post-drain verified scan (the drain had moved all 3 objects, but the last *completed* scan still said 3 covered; the operator's rescan that day was on cairn-openbao). Boot refused exactly as designed; recovery via the documented `CAIRN_KEYDRAIN_FORCE=1` escape hatch; ledger entry dropped with the loud warning. v0.6.119's auto-verify makes this scenario much harder to reproduce, since a finished pass now runs the scan itself.
Author
Owner

Closing on operator sign-off: rotate → drain → verify → discard completed end-to-end on both dogfoods (cairn-enc: 3 classical objects incl. the first-ever tripwire catch and escape-hatch recovery; cairn-openbao: 18 hybrid objects through the clean happy path). Shipped across v0.6.117–120 with three dogfound fixes along the way. Follow-ups live in #414 (s3 driver unicode roundtrip).

Closing on operator sign-off: rotate → drain → verify → discard completed end-to-end on both dogfoods (cairn-enc: 3 classical objects incl. the first-ever tripwire catch and escape-hatch recovery; cairn-openbao: 18 hybrid objects through the clean happy path). Shipped across v0.6.117–120 with three dogfound fixes along the way. Follow-ups live in #414 (s3 driver unicode roundtrip).
Sign in to join this conversation.
No labels
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: Cordy/Cairn#411
No description provided.