14 KiB
REPORT — the offsite backup key comes back (2026-08-04, R-199 closed, R-200 half)
Class: implementation across three repos + one decisive live proof. Nothing wiped, deleted, rotated or reconfigured; no customer-facing change in any language.
1. SCENARIO A — THE RESULT, FIRST, BECAUSE IT IS THE SESSION
On demo-felhom, 2026-08-04 13:49 CEST, through the real endpoints end to end:
=== offsite key recovery check (R-200) — compares, never installs ===
on-disk sha256: c60c8bc737a6b7c6647c7849283f52087f650a885babedb4ef5fdf9a5c9543cb
recovered sha256: c60c8bc737a6b7c6647c7849283f52087f650a885babedb4ef5fdf9a5c9543cb
[MATCH] the offsite repository password IS recoverable from the sealed escrow.
Nothing was written: this check compares and never installs.
EXIT=0
The hashes matched. And they match a third, independent source: host_escrow.restic_pw_sha256
on the hub reads c60c8bc737a6… for demo-felhom-8363b5, recorded at the ceremony and never involved
in this comparison. The box's file, the sealed bundle and the hub's record all agree.
The offsite repository password is recoverable from the sealed escrow with the customer's recovery code. That had never been demonstrated — the one live round-trip on record (2026-06-10) predates the field by a month.
2. Part 0 — the recovery code
Answered by the operator before the session began: the 2026-08-04 07:16/07:20 codes had not been
kept, so the operator ran a fresh ceremony on both nodes and saved the codes (supplied to this
session as R_DEMO-FELHOM in the DooPlex credentials file). No ceremony was run by CC.
Measured hub-side, and worth recording because it exercised two of yesterday's behaviours live:
the new escrow rows are stamped 2026-08-04T11:11:37Z (demo-hp) and 11:13:06Z (demo-felhom), and
restic_pw_sha256 is unchanged on both — so SaveHostEscrow correctly treated them as
same-password re-ceremonies: no superseded row was created and no offsite_repo_key_changed
fired. That is v0.93.0's Scenario E, live. It also means yesterday's identity_blob retention is
still unit-proven and structurally verified, not yet exercised live — no supersession has happened.
Subject box: demo-felhom (Tier 0, reachable with a baked key, current escrow). Not demo-hp (break-glass only), not DooPlex, not ep0.
3. The §8.2 decision as built, and where the gate is
Built as §8.2: GET /api/v1/hosts/{host_id}/escrow, authenticated with the box's own per-host key,
self-scoped (global may read any — the same asymmetry the PUT has).
The single gate point is api.escrowSelfServiceRetrieval (hub/internal/api/handler.go), a named
constant. false re-imposes the recovery-mode requirement on this endpoint and changes nothing else —
the same unseal, the same extraction, the same proof, minus the self-service property. The trade is
written above the handler in dr.go's style: what it gives out, why it is safe, and what it changes
about who is required — obtaining the blob used to need an operator to arm recovery mode and now
needs only that box's credential. They still cannot open it. The mitigation is the audit row.
4. Files, commits, deployed versions
| Repo | Commits | Version | Deployed |
|---|---|---|---|
felhom.eu |
435f4a5 (code) · dd08926 (manifest) · docs commit below |
hub v0.94.0 | ArgoCD Synced/Healthy, felhom-hub:0.94.0 |
felhom-agent |
6d79047 |
v0.125.0 | published sha f7d8339b53d9…, verified by independent download, installed on demo-felhom (felhom-agent --version → 0.125.0, active). NOT vouched |
felhom-controller |
9640e51 |
v0.195.0 | felhom-controller:0.195.0 on guest 9201, Up (healthy) |
Hub: internal/api/handler.go (route + handleHostEscrowGet + escrowSelfServiceRetrieval),
internal/notify/dispatcher.go (escrow_blob_served → operatorOnlyEvents),
internal/api/escrow_get_test.go (new).
Agent: internal/escrow/recover.go (new — OffsiteKeyRecoverer, distinct errors),
internal/hub/client.go (FetchIdentityEscrow), internal/localapi/escrow_recover.go (new route),
internal/localapi/server.go (seam + registration), cmd/felhom-agent/main.go (production wiring +
the §8.6 selftest message fix), plus recover_test.go and escrow_recover_wiring_test.go (new).
Controller: internal/backup/offbox_recovery_check.go + offbox_recovery_cli.go (new),
internal/agentapi/escrow.go (RecoverOffsiteRepoPassword), cmd/controller/main.go
(--recover-offsite-check), plus offbox_recovery_check_test.go (new).
Green gate in each repo after each phase (go build && go vet && go test ./..., rc=0), plus
repo_gates.py / agent_gates.py / controller_gates.py — all OK. No test run was combined with a
commit.
5. Tests and every red-proof
| Scenario | Result | Red-proof — mutation → outcome |
|---|---|---|
| A recovered key == on-disk key | PASS (unit, real age) + PROVEN LIVE |
return bundle.PBSToken instead of ResticRepoPassword → FAIL: "the recovered key is not the sealed repository password (len 24 vs 64)". This is the mutation that mattered: every field of that bundle is a non-empty string that looks like a secret |
| B wrong R fails closed, nothing written | PASS + PROVEN LIVE | — (the property is age's KDF, asserted rather than guarded, so there is no guard to remove) |
| C a box fetches only its own blob | PASS | delete the !isGlobal && authHostID != pathHostID check → FAIL: host A was served host B's blob, body and all |
| D no blob → a clean "none" | PASS | — |
| E every retrieval leaves a record | PASS + PROVEN LIVE | if false && on the audit block → FAIL: "a sealed recovery blob was served and NOTHING recorded it" |
| F R persists nowhere | PASS + PROVEN LIVE | write R to a temp file inside the unseal → FAIL (see the correction below) |
| G the operator DR path is untouched | PASS | — |
| H the seam is wired (AST) | PASS | _ = escrowRecoverer with the Options field removed → FAIL: "localapi.Options … carries no EscrowRecovery field" |
| §8.5 compare-never-install | PASS + PROVEN LIVE | add InjectOffboxPassword to the check → FAIL: "the check INSTALLED the recovered password" |
TWO MUTATIONS DID NOT REMOVE EVERY GUARD ON THE FIRST ATTEMPT, AND BOTH ARE RECORDED RATHER THAN QUIETLY REDONE (§9.13 — two of the last four sessions found a red-proof that passed):
- Scenario F. The planted leak wrote R into the walked directory and the test still passed — because a later failing call OVERWROTE the leak file with the wrong code, and the test only scanned for the first one. Instrumenting the mutation (printing where it wrote) is what found it. The test was wrong, not the mutation. It now asserts emptiness of the TMPDIR tree as its primary check — nothing under it is created by the test, so any survivor is a leak regardless of content — with the content scan kept as defence in depth over all three secrets. Re-run: FAIL, "the unseal left 1 file(s) behind under TMPDIR".
- Scenario H. Commenting the wiring line out failed to compile (
declared and not used), which is not a red-proof. The mutation that compiles keeps the variable alive (_ = escrowRecoverer) and drops the struct field. Re-run: FAIL as quoted above.
Every -run filter was verified to have matched (-v, === RUN lines counted) before any result
was read as a proof.
6. The live validation trail, in order
- Wrong code first, deliberately (13:44:44) — hub:
escrow blob SERVED to host demo-felhom-8363b5 (572 opaque bytes, self_scope=true) — recovery path in use; agent:offsite key recovery FAILED … err="escrow: the recovery code did not unwrap the identity escrow (wrong recovery code, or a corrupt blob): exit status 1"; CLI:[FAIL] … nothing was written., exit 1. This is Scenario B live AND it proves links 6 and 7 ran independently of the success — the blob was fetched and the unseal was attempted and refused. - The real code (13:49:47) — §1's output, exit 0.
- Nothing was written —
offbox/repo_passwordmtime on the live box is still2026-08-03 07:18:02, unchanged by a check that ran at11:49UTC. The wholeoffbox/directory is byte-for-byte the pre-check listing.
7. The retrieval record as it appears at the hub
customer_id severity created_at details_json
demo-felhom warning 2026-08-04 11:44:44 {"blob_bytes":572,"host_id":"demo-felhom-8363b5","self_scope":true}
demo-felhom warning 2026-08-04 11:49:47 {"blob_bytes":572,"host_id":"demo-felhom-8363b5","self_scope":true}
And the routing, which is the half that shows the register works:
customer_id channel status created_at
demo-felhom operator sent 2026-08-04 11:44:44
demo-felhom customer skipped 2026-08-04 11:44:44 ← operator_only
demo-felhom operator suppressed 2026-08-04 11:49:47 ← 1h operator cooldown, itself recorded (R-182)
demo-felhom customer skipped 2026-08-04 11:49:47
Both retrievals raised an event; the operator was mailed for the first; the second's mail was cooldown-suppressed and that suppression is written down. The audit is per-retrieval and complete even where the mail is not.
8. Evidence that R persisted nowhere — the search, not a claim
Searched on the subject host with the pattern read from a file (never in argv), across the agent
journal, the controller's container log, and /tmp, /var/tmp, /var/lib/felhom-agent, /root:
agent journal lines containing R: 0
controller log lines containing R: 0
host files containing R: 0
leftover felhom-idesc-* staging dirs: 0
staged escrow secret file: 0 entries
With a positive control, because an absent match is not a measurement unless the instrument is shown
to work: a copy of R was planted under /tmp, the same sweep found 1, the copy was shredded, and
the sweep returned 0. The instrument detects R when R is there.
R reached the box only as: the credentials-file value on DooPlex → a pipe → the container's stdin → the request body → the agent's memory. It was never an argument, never a file on either machine, and appears in no log line at any level.
9. Register
- R-199 → SHIPPED + PROVEN-LIVE. Links 6, 7 and 8 assembled and walked.
- R-200 → plumbing shipped; the FORM is not built and the row stays open for it and for link 9.
- R-201 → OPEN, and the distinction is deliberate: the KEY is proven recoverable; a recovered password has never been installed, no repository has been reopened under one, and no file has ever been restored. The drill's pass condition is unchanged — a byte-identical sentinel file, not "the store opened".
- R-202 → OPEN and untouched. The orphan card still promises recoverability unconditionally.
- The orphaned-ciphertext deletion (~1.2 GB) is STILL OWED — operator ruling 2 of 2026-08-04, deliberately not ridden along with a code release.
- Capability map,
03-host-agent.md§8a,CONTEXT.mdS-29,REUSE.mdandROADMAP.mdupdated;STATUS.mdrewritten for the operator.
10. The capability-map row, and what it deliberately does not claim
Added: "The offsite repository password can be RECOVERED from the sealed escrow with the customer's recovery code" — PROVEN-LIVE (2026-08-04), with the evidence above.
What the row explicitly refuses to claim, stated in the row itself because the previous over-claim on this subject was struck out four hours earlier: it covers the key, not the data. No recovered password has been installed, no repository reopened, no file restored. And the proof used a box whose local key still exists — the rebuilt-box case, where there is nothing to compare against, is exactly what the drill covers and it has not run.
11. CI
Run numbers, task ids and conclusions for all commits are quoted in the session summary.
--no-verify was NOT used — every push ran its repo's pre-push gate and passed.
12. Teardown
Nothing was provisioned. The temporary pattern file used by the R sweep and its positive control
were shredded on the host by the same command that created them (verified: 0 files remain). No
diagnostic artifact persists on either machine. The agent's previous binary is retained as
/usr/local/bin/felhom-agent.bak-0.124.1, per the documented deploy.
13. Observations — noticed, NOT acted on
- The
escrow_blob_servedmail is subject to the 1-hour operator cooldown — visible above, where the second retrieval's mail was suppressed. The EVENT rows are per-retrieval so the audit is complete, but if retrieval ever becomes routine, a burst would produce one mail. Worth revisiting when the customer-facing flow lands, not before. ListSupersededEscrowstill has no production caller. Recovery reads the CURRENT blob; a customer needing a PREVIOUS generation's key (which is what R-198's retention exists for) has no path at all. That is the next gap in this area after the drill.- The controller binary is at
/usr/local/bin/felhom-controller, not/app/…— the first diagnostic invocation failed on a guessed path. Worth a line in the runbook when one is written. ageis at the hardcoded/usr/bin/ageon both demo hosts (1.2.1 on demo-felhom) — the recon listed this as unestablished; it is now established for demo-felhom.ageBinaryis a package variable with no config override, so a host that installsageelsewhere would fail the unseal at the last step.- Yesterday's
identity_blobretention has still not been exercised live — the operator's fresh ceremonies sealed the same password, so no supersession occurred. It remains unit-proven with a verified schema; the first real supersession will be its live proof.