hub v0.94.0: a box can fetch its own sealed recovery package (R-199 link 6)
gates / gates (push) Successful in 7s

Link 6 of the recovery chain had no client. The hub has served the identity blob since
slice 10D from handleReEnroll / handleGetRestoreDirective, gated on operator-armed recovery
mode and the global key -- and nothing in the agent, the hub UI, any script or any runbook
ever called either. The only documented retrieval was sqlite3 writefile() by hand on a
kubectl cp-ed database.

GET /api/v1/hosts/{host_id}/escrow is the box-authenticated mirror of the PUT that put the
blob there. Self-scoped (a per-host key reads only its own; global may read any). A host with
no bundle gets 200 {present:false} -- a 404 is indistinguishable from an unknown host and a
bare empty 200 from a zero-length blob.

THE TRADE IS RECORDED IN THE HANDLER, not inferred: obtaining the blob used to require the
operator to arm recovery mode; now whoever controls a rebuilt box can obtain it with that
box's own credential. They still cannot open it -- the hub has never held R and a wrong code
fails closed at age's scrypt KDF. The mitigation is that every retrieval raises
escrow_blob_served (warning, operator-only), recorded before the bytes leave.

escrowSelfServiceRetrieval is the single decision point: flip it to false and the endpoint
additionally requires recovery mode, changing nothing else.

The operator-driven DR path is untouched, pinned by a test. Red-proofs observed: removing the
ownership check serves host B's blob to host A; removing the record makes it silent.
This commit is contained in:
2026-08-04 13:39:27 +02:00
parent 9f31956201
commit 435f4a5229
4 changed files with 367 additions and 0 deletions
+33
View File
@@ -1,3 +1,36 @@
## v0.94.0 — a box can fetch its own sealed recovery package (2026-08-04, R-199 link 6)
**Chain link 6 had no client.** The hub has served the identity blob since slice 10D, from
`handleReEnroll` / `handleGetRestoreDirective` — gated on operator-armed recovery mode and the global
key. Nothing in the agent, the hub UI, any script or any runbook ever called either. The only
documented retrieval was `sqlite3 … writefile(…)` by hand on a `kubectl cp`-ed database.
**`GET /api/v1/hosts/{host_id}/escrow`** — the box-authenticated MIRROR of the PUT that put the blob
there. Self-scoped: a per-host key reads only its own; the global key may read any, the same asymmetry
the PUT has. A host with no sealed bundle gets `200 {present:false}` — a clean answer, because a 404
is indistinguishable from an unknown host and a bare empty 200 from a zero-length blob, and neither
of those is what "no ceremony has run yet" means.
**THE TRADE, RECORDED IN THE HANDLER RATHER THAN INFERRED.** Before this, obtaining the blob required
the OPERATOR to arm recovery mode. Now whoever controls a rebuilt box can obtain it with that box's
own credential. That is a real reduction in the number of parties required, and it is the whole of the
trade — they still cannot open it, because the hub has never held R and a wrong code fails closed at
age's scrypt KDF. **The mitigation is that the capability is audited rather than silent:** every
successful retrieval raises `escrow_blob_served` (warning, operator-only), recorded before the bytes
leave. A silent capability on this object is the shape the last two weeks were spent removing.
`escrowSelfServiceRetrieval` is a single named constant — the §8.2/§8.3 decision point. Flipping it to
false re-imposes the recovery-mode requirement on this endpoint and changes nothing else, so the
operator can overrule the trade at the cost of a boolean rather than a redesign.
**The operator-driven DR path is untouched** — same gate, same behaviour, pinned by a test that
exercises re-enroll and restore-directive with recovery mode off and on. Red-proofs observed: removing
the ownership check makes a cross-host read succeed (host A served host B's blob); removing the audit
record makes the retrieval silent.
**Not in this release:** the customer-facing flow. No card, no form, no preview — those are designed on
ground that has been walked, and R-200/R-201 are that walk.
## v0.93.0 — the retention keeps the key it was built to keep, and three things stop lying (2026-08-04, R-198/R-197/R-196/R-192)
### R-198 — the superseded-escrow retention was preserving the wrong key, and the ceremony was destroying the right one