Read-only recon of the escrow -> recovery chain, from a dead node to an open
repository. No production code, no build, no version bump.
Headline: the hub's superseded-escrow retention does NOT retain the offsite
repository password. host_escrow_superseded has no identity_blob column and
demoteCurrentEscrowTx copies only the K-escrow blob, so what survives a
supersession is the PBS datastore key, not the restic repo password. The next
escrow ceremony -- which the system tells a rebuilt box's customer to run --
destroys the last copy. Both demo boxes crossed that line on 2026-08-04.
Also established:
- the hub's blob-serving endpoints (re-enroll / restore-directive) have zero
callers anywhere: agent, hub UI, scripts, runbooks (R-199)
- POST /backup/offbox/inject-password is routed and handled but no template
contains the form (R-200)
- nothing in the recovery path has ever been exercised; the one live
round-trip proof (2026-06-10) predates the ResticRepoPassword field (R-201)
- a fail-closed mint refusal IS implementable: the report ACK already carries
escrow{identity_blob_present, restic_pw_sha256} and the controller discards
it whenever no offbox target exists
Corrections: yesterday's spike annotated (candidate (b) overturned in part --
unattended recovery is impossible, customer-present is not); capability-map
retention claim struck through and replaced with what the code does.
Deliverable: documentation/audits/RECON-offsite-dr-chain-2026-08-04.md
Register: new R-198..R-201; R-193 and R-192 updated; STATUS.md refreshed.
7.9 KiB
REPORT — RECON: can a customer actually get their backups back? (2026-08-04)
Class: recon / spike. No production code. No build, no deploy, no version bump.
Baselines re-read on arrival: felhom-agent 856a127cd60b (v0.124.1) · felhom-controller
0887fd676d60 (v0.194.0) · felhom.eu d26f49ad680e (hub v0.92.0). All three trees clean.
Supersedes the earlier 2026-08-04 report (R-193/R-192 spike + R-195), per the overwrite convention. Nothing durable from it lived only here — it is recorded in
hub/CHANGELOG.md,OPEN-ITEMS.mdR-192/R-193/R-195/R-196/R-197 andaudits/SPIKE-offsite-credential-recovery-2026-08-04.md.
Deliverable: documentation/audits/RECON-offsite-dr-chain-2026-08-04.md — Q1–Q8 with method,
measurement and ruling per question, the eleven-link chain table, the drill design, every query used,
and the operator's open decisions.
1. What was established
The chain from a dead node to an open repository is not assembled, and the failure is deeper than "some links are manual".
- Links 1–4 are PROVEN-LIVE — mint → stage to the agent → seal under R → store on the hub.
- Link 5 is lossy, and this is the finding of the session.
host_escrow_supersededhas noidentity_blobcolumn, anddemoteCurrentEscrowTx(hub/internal/store/store.go:2547-2556) copies only the K-escrowblob. The restic repository password lives inidentity_blob— so the retention feature built for the reinstall-orphan incident does not retain the secret that incident is about. The next escrow ceremony destroys the last copy, and that ceremony is exactly what the system tells a rebuilt box's customer to run. Both demo boxes crossed that line on 2026-08-04 at 07:15:36 and 07:20:08. → R-198 - Link 6 has no client anywhere.
handleReEnroll/handleGetRestoreDirectiveserve the blobs; zero callers in the agent, the hub UI, any script or any runbook. Retrieval issqlite3by hand on the hub pod. → R-199 - Links 7–8 are manual-only.
UnwrapIdentityBundle's only production caller is--selftest=identity-consume, reading R fromFELHOM_RECOVERY_CODE; nothing extractsrestic_repo_passwordfrom the recovered bundle. - Link 9's form does not exist.
POST /backup/offbox/inject-passwordis routed, handled and unit-tested; no template contains it. → R-200 - Link 11 has never happened. No blob served, no fork-4 bundle unsealed with a real R outside a
unit test, no recovered password injected, no repo reopened, no restore. The one live round-trip
proof (2026-06-10) predates the
ResticRepoPasswordfield by a month. → R-201
And the most useful thing settled: a fail-closed refusal IS implementable. The hub already sends
every box escrow{identity_blob_present, restic_pw_sha256, created_at} on every report ACK
(api/handler.go:504-510), and the controller discards it whenever no offbox target exists
(report/escrow_confirm.go:75-84). Persisting it needs no new hub API and no new secret; the
ClaimSync set-only pattern is the precedent.
The operator's ruled recovery-screen shape is buildable and is priced row by row in §9 of the
findings doc: fresh-install signal exists (the mint branch's own os.Stat); hub-has-a-blob exists on
the wire; the preview is cheap and genuinely read-only (restic snapshots --json +
stats --mode raw-data --json are already the box's own counters); the one real cost is crossing
agent→controller for the unseal, because the controller image ships no age — one new agent local-API
endpoint mirroring /escrow/ceremony/claim, plus a narrow hub GET /hosts/<id>/escrow.
2. Corrections owed and made
- Yesterday's spike annotated, not rewritten (
audits/SPIKE-offsite-credential-recovery-2026-08-04.md): a dated block at the top plus an inline note on Q8(b). Its candidate (b) verdict "not implementable against a zero-knowledge escrow" is too strong — unattended recovery is impossible; a customer-present recovery is not. Everything else in that spike stands, and its Q3 remains unmeasured. - Capability map corrected (
architecture/00-capability-map.md, escrow-ceremony row): the hub v0.60.0 retention claim "a previous passphrase stays recoverable with its recovery code" is struck through and replaced with what the code does — K-escrow only, no read path,ListSupersededEscrowhas zero production callers. - The same false promise is made to the customer in Hungarian on the orphan card
(
controller/internal/web/templates/backups_remote.html:66,69). Recorded in R-198; not edited — this session ships no controller change. - The prompt's Q4 premise is a conflation, and it is corrected in the doc:
escrow_confirm.godoes fire on a mint (pending branch, hash mismatch, one WARN). The "wired to the wrong path" finding was about the hub'sescrow_staleevent (R-196), a different mechanism. What is missing is not the detection but the consequence — the warning reaches a log file and prescribes the act that closes the recovery window.
3. Register
- New: R-198, R-199, R-200, R-201 (highest previous ID was R-197; grepped across
OPEN-ITEMS.md,ROADMAP.md,STATUS.md,CONTEXT.mdbefore minting). - Updated: R-193 (seven-part addendum: (b) overturned in part, the chain census, the retention finding, fail-closed implementable, the operator ruling priced, the drill, Q3 still unmeasured) and R-192 (two inputs, no verdict change).
STATUS.mdupdated in operator language: two new "what's broken" entries, three new "waiting on you" items, a "changed since" line.
4. Live measurements taken (read-only)
Hub SQLite snapshot with the -wal (kubectl exec … cat), PRAGMA integrity_check → ok, freshness
proven positively (MAX(host_reports.received_at) = 2026-08-04 10:00:40 vs datetime('now') =
10:03:48). Escrow tables, PRAGMA table_info on both, latest-report $.offsite.* and $.claimed
per customer, the claimed 1→0→1 transition across both rebuilds, and the events table since
2026-08-03. Plus one ls -la --time-style=full-iso inside guest 9201 proving the data dir, the
encryption.key and offbox/repo_password are all newborn as of 2026-08-03 07:17:25–07:18:02.
Every query with its field names and values is in §11 of the findings doc.
Nothing was written, run, rotated, re-issued, deleted or reconfigured anywhere. No ceremony, no
selftest=identity-consume, no touch on the storage endpoint.
5. What could not be established
The 2026-08-05 ~02:15 UTC run's outcome (still the decisive measurement; neither box has run —
last_run absent on both); whether demo-hp's data dir was fresh (measured only on demo-felhom;
demo-hp needs the break-glass credential this read-only session did not use); whether the orphaned
ciphertext still exists at the provider; whether age is present on either demo host. Named in §12
of the findings doc.
6. Observations recorded, not acted on
A fresh encryption.key on every rebuild makes every pre-rebuild app.yaml ENC: secret
undecryptable — circular dependency C-1 of _recovery-inventory-2026-07-28.md, which fired live on
both demo boxes this week and is a strictly larger blast radius than the offsite repo. Claimed
returning to 1 with no event means "the customer claimed this box" has no audit trail.
offboxRecordStats persists RepoSizeBytes as a side effect of counting. handleReEnroll returns a
freshly minted API key in its response body on an endpoint nothing currently calls.
7. Conventions
- No
CHANGELOG.mdentry.felhom.euhas no root changelog — they are per-area (hub/,scripts/,website/) and log code changes. This session changed none of those areas. Stated rather than silently skipped. REUSE.mduntouched — no shared helper or pattern moved.- Gates + CI run id are recorded in the session summary.