hub v0.103.0 — a host can read the packages we kept for it (R-311)

ListSupersededEscrow had zero production callers for nineteen days. It is the only
reader of a retained identity_blob, so the retention shipped in v0.93.0 was
material the product could not reach - proven on the fixture 2026-08-12, where a
code that opens a retained package was answered as a code that opened nothing.

New GET /api/v1/hosts/<id>/escrow/retained: self-scoped exactly as the current-row
GET, same recovery-mode gate, same audit event written BEFORE the bytes leave,
capped at 16.

Rows with a NULL identity_blob are WITHHELD and returned as unopenable_count.
They retain the PBS key, not the repository password, so they can never open what
the caller is asking about; serving them would have the agent try packages that
cannot succeed and would let the screen claim an earlier package is openable on
exactly the boxes the original defect hurt. The count is returned because their
existence is load-bearing and underivable.

The trade, stated rather than waved through: the hub still cannot read any of it -
sealed bytes in, sealed bytes out, no decrypt path, no recovery code ever held.
What widens is volume, bounded by self-scope, the recovery-mode gate and the cap.

The response is a NAMED TYPE, not a map, so the wire-contract gate can resolve it;
the wire is declared as a fourth ROOT and the gate now checks 182 tags rather than
174. A positive control shows that check is name-presence, not decodability -
filed as R-315 rather than reported as coverage.

Six tests through the real endpoint; four red-proofs asserted applied.
This commit is contained in:
2026-08-12 18:43:07 +02:00
parent 1d4985d87c
commit 6362bb6cb6
7 changed files with 443 additions and 16 deletions
+159
View File
@@ -245,6 +245,13 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow")
h.handleHostEscrowGet(w, r, hostID)
// R-311 (v0.103.0): the RETAINED siblings of the row above. The two cases cannot collide — one
// ends `/escrow`, the other `/escrow/retained` — but do NOT "tidy" them into a single prefix
// match: a prefix match would route retained reads to the CURRENT row, which is silently the
// wrong package and is exactly the confusion this endpoint exists to end.
case r.Method == http.MethodGet && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow/retained"):
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow/retained")
h.handleHostEscrowRetainedGet(w, r, hostID)
// G1 break-glass: day-0 vaults the root@pam console credential (self-scoped host key); the
// operator retrieves it via the /admin/ path (global key only).
case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/recovery-credential"):
@@ -1371,6 +1378,158 @@ func (h *Handler) handleHostEscrowGet(w http.ResponseWriter, r *http.Request, pa
})
}
// handleHostEscrowRetainedGet serves a host its own RETAINED (superseded) sealed identity packages.
//
// ── WHY THIS EXISTS (R-311) ─────────────────────────────────────────────────────────────────────
//
// `ListSupersededEscrow` has been the only reader of a retained identity blob since v0.93.0 and had
// ZERO production callers — every call site was a test. The consequence, measured on the fixture
// 2026-08-12: a recovery code that demonstrably opens a retained package (proven by hand: unsealed,
// and it restored planted files byte-identical from a store the box could no longer open) was
// reported to the customer as a code that opened nothing. The screen already hedged that with two
// possible causes (R-222/R-226) and said it could not tell them apart. **It could not tell them
// apart because nothing ever looked.** This endpoint is what makes looking possible.
//
// ── WHAT THIS DOES NOT CHANGE ───────────────────────────────────────────────────────────────────
//
// The hub still cannot read any of it. Sealed bytes in, sealed bytes out; there is no decrypt path
// here and the hub has never held a recovery code. What DOES widen is volume: a host key that could
// previously fetch one opaque package can now fetch N. The trade is stated rather than waved through
// — see the audit — and it is bounded three ways: the same self-scope as the current row, the same
// recovery-mode gate, and an explicit cap so a host with a long supersession history cannot turn one
// request into an unbounded read.
//
// Rows whose `identity_blob` is NULL are NOT served and are counted separately. They are the
// pre-v0.93.0 rows; they retain the PBS key and not the repository password, so they can never open
// anything the caller is asking about. Serving them would make the agent try packages that cannot
// succeed and would let the screen claim an earlier package is openable when it is not — the same
// false-explanation trap `SupersededPresent` already avoids at store.go. They are counted because
// their EXISTENCE is a true and load-bearing fact: on those boxes a correct old code opens nothing,
// and the honest reason is a defect of ours, not the customer's typing.
const retainedEscrowServeCap = 16
// RetainedEscrowPackage / RetainedEscrowResponse are the WIRE for GET /hosts/<id>/escrow/retained.
//
// They are named types rather than a `map[string]any` on purpose: the wire-contract gate resolves a
// declared ROOT by TYPE, so an untyped map is a cross-repo contract the gate cannot see. This wire is
// declared in `scripts/wire_contract_gate.py` ROOTS — hub → agent — and the agent's mirror is
// `felhom-agent/internal/hub.RetainedEscrowResponse`. Change a tag here and the gate fails there,
// which is the entire point.
type RetainedEscrowPackage struct {
// Index is a label WITHIN ONE RESPONSE. Not durable, never persisted, never a lookup key.
Index int `json:"index"`
// SupersededAt is when this package stopped being the current one. It is the only field here a
// customer ever sees — it is how they recognise which recovery code they are holding.
SupersededAt string `json:"superseded_at"`
KeyFingerprint string `json:"key_fingerprint"`
// IdentityEscrowB64 is OPAQUE ciphertext. The hub cannot open it and never could.
IdentityEscrowB64 string `json:"identity_escrow_b64"`
}
type RetainedEscrowResponse struct {
HostID string `json:"host_id"`
Count int `json:"count"`
// UnopenableCount is the number of retained rows withheld because they carry no key material
// (every pre-v0.93.0 row). Underivable by the caller and load-bearing: on a box with only those,
// a perfectly correct old code opens nothing and the reason is a defect of ours.
UnopenableCount int `json:"unopenable_count"`
TruncatedCount int `json:"truncated_count"`
Packages []RetainedEscrowPackage `json:"packages"`
}
func (h *Handler) handleHostEscrowRetainedGet(w http.ResponseWriter, r *http.Request, pathHostID string) {
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
if !ok {
http.Error(w, "Unauthorized", http.StatusUnauthorized)
return
}
if pathHostID == "" {
http.Error(w, "Missing host_id", http.StatusBadRequest)
return
}
// SELF-SCOPED, identically to the current-row GET. Without this line any host key is a fleet-wide
// reader of every retained package, which is strictly worse than the same hole on one row.
if !isGlobal && authHostID != pathHostID {
h.logger.Printf("[WARN] retained escrow GET REFUSED: host %s asked for %s's packages (self-scope)", authHostID, pathHostID)
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
return
}
host, err := h.store.GetHost(pathHostID)
if err != nil {
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
if host == nil {
http.Error(w, "Unknown host_id", http.StatusNotFound)
return
}
// The SAME §8.3 gate as the current row. A retained package is not less sensitive than the
// current one; if self-service retrieval is ever switched off, it must go dark with it.
if !escrowSelfServiceRetrieval && !host.InRecoveryMode(time.Now().UTC()) {
h.logger.Printf("[WARN] retained escrow GET REFUSED for %s — self-service retrieval is disabled and recovery mode is not armed", pathHostID)
http.Error(w, "Forbidden: host not in recovery mode (operator must arm it)", http.StatusForbidden)
return
}
retained, rerr := h.store.ListSupersededEscrow(pathHostID)
if rerr != nil {
h.logger.Printf("[ERROR] retained escrow GET for %s: %v", pathHostID, rerr)
http.Error(w, "Internal error", http.StatusInternalServerError)
return
}
packages := make([]RetainedEscrowPackage, 0, len(retained))
unopenable := 0
for _, e := range retained {
if len(e.IdentityBlob) == 0 {
unopenable++
continue
}
if len(packages) >= retainedEscrowServeCap {
continue
}
packages = append(packages, RetainedEscrowPackage{
Index: len(packages),
SupersededAt: e.UpdatedAt,
KeyFingerprint: e.KeyFingerprint,
IdentityEscrowB64: base64.StdEncoding.EncodeToString(e.IdentityBlob),
})
}
truncated := 0
if n := len(retained) - unopenable; n > len(packages) {
truncated = n - len(packages)
}
// The audit row is written BEFORE the bytes leave, exactly as the current-row GET does, so a
// retrieval cannot be served without its record. Severity matches that path deliberately: this is
// a recovery in progress and nothing else, and it must reach the operator by e-mail.
if host.CustomerID != "" && len(packages) > 0 {
msg := fmt.Sprintf("Retained recovery packages served: host %s retrieved %d retained sealed package(s) (%d unopenable pre-v0.93.0 row(s) withheld). "+
"This is the recovery path in use — the packages cannot be opened without the customer's recovery code, which the hub never holds. "+
"If no recovery is in progress on that box, investigate.", pathHostID, len(packages), unopenable)
details, _ := json.Marshal(map[string]any{
"host_id": pathHostID,
"served": len(packages),
"unopenable": unopenable,
"truncated": truncated,
"self_scope": !isGlobal,
})
if _, eerr := h.store.SaveEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub"); eerr != nil {
h.logger.Printf("[WARN] %s event save FAILED for %s (serving anyway): %v", eventEscrowBlobServed, pathHostID, eerr)
} else if h.dispatcher != nil {
go h.dispatcher.ProcessEvent(host.CustomerID, eventEscrowBlobServed, "warning", msg, string(details), "hub")
}
}
h.logger.Printf("[WARN] retained escrow SERVED to host %s (%d package(s), %d unopenable withheld, %d over cap, self_scope=%v)",
pathHostID, len(packages), unopenable, truncated, !isGlobal)
writeJSON(w, http.StatusOK, RetainedEscrowResponse{
HostID: pathHostID,
Count: len(packages),
UnopenableCount: unopenable,
TruncatedCount: truncated,
Packages: packages,
})
}
// maybeEmitRepoKeyChanged raises ONE operator signal per supersession when the sealed offsite repo
// password demonstrably changed. Both hashes have been stored since SLICE 3 (host_escrow and, since
// v0.60.0, host_escrow_superseded) and NOTHING compared them: demo-felhom's repository password