diff --git a/CHANGELOG.md b/CHANGELOG.md index 33e9908..c5d7a93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,38 @@ +## v0.195.0 — prove the offsite key comes back (2026-08-04, R-200 plumbing half) — MinAgent 0.125.0 + +**The question, answered for the first time: is the offsite repository password actually recoverable +from the hub's sealed bundle?** Yesterday's hub v0.93.0 made the key SURVIVE a re-ceremony. Nothing +handed it back. The chain's last stretch had no client at link 6, a `--selftest`-only caller at +link 7, and nothing at all at link 8. + +**`--recover-offsite-check`** — a `docker exec` diagnostic in the shape of `--print-reset-code`: +it reads the customer's recovery code from **STDIN**, asks the agent to fetch this host's sealed +bundle and open it (agent ≥ v0.125.0, `POST /escrow/recover-offsite-password`), and reports whether +the recovered key matches the one on disk — **by sha256**. It prints two hashes and a verdict. Never +a password, never the recovery code, never a blob. + + docker exec -i felhom-controller /app/felhom-controller --recover-offsite-check < /root/r.txt + +**R comes from stdin and not a flag** because a flag value is visible in `ps`, in shell history, in a +container's command line and in any transcript of the session that ran it — and R is the one secret +in this system that cannot be rotated, re-issued or recovered. + +**IT COMPARES; IT DOES NOT INSTALL.** The recovered password is never written to `offbox/repo_password`. +Comparing proves recoverability; installing changes a live box on a path nobody has walked, and +"the existing repository opens under a recovered key" is a separate link with a drill around it. +A test asserts the on-disk password and the whole data dir are byte-unchanged after a check, and its +red-proof — adding the install call — fails it. + +**Exit codes are load-bearing:** `0` match, `2` a clean MISMATCH, `1` a step failed. "It failed" and +"it worked and disagreed" must never share a status, because only one of them is a finding about the +system rather than about the run. A box with no local password is reported distinctly too — that is +the rebuilt-box shape, where the next step is to install rather than to compare, and reading it as a +mismatch would be wrong. + +**Deliberately NOT in this release: anything a customer can reach.** No card, no form, no preview, no +wizard. Building an interface on top of a chain nobody has walked is how the preceding three weeks +went wrong; the interface comes next, on proven ground, and so does the wipe-and-restore drill. + ## Changelog ### v0.194.0 — one operator e-mail per backup run, and nothing dropped without a trace (2026-08-03, R-182) — MinAgent: none diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index fdfd94a..85c21c9 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -75,6 +75,7 @@ func main() { showVersion := flag.Bool("version", false, "Show version and exit") printResetCode := flag.Bool("print-reset-code", false, "Customer-claim escape hatch (v0.122.0, F-4): print a fresh one-time local claim/reset code to stdout, then exit. Root-gated by reachability (docker exec). Same gate consumes it.") printInfraImages := flag.Bool("print-infra-images", false, "Print every controller-managed infra image (one per line) and exit. Read by the golden bake (felhom-agent configs/build-golden.sh) so the appliance image pre-pulls exactly what THIS controller version will request.") + recoverOffsiteCheck := flag.Bool("recover-offsite-check", false, "R-200 diagnostic: read the customer recovery code from STDIN, recover the offsite repository password from the hub-held sealed escrow via the agent, and report whether it matches the one on disk — BY HASH. Compares, never installs; writes nothing. Exit 0 = match, 2 = clean mismatch, 1 = a step failed.") flag.Parse() if *showVersion { @@ -91,6 +92,35 @@ func main() { os.Exit(0) } + // R-200 (v0.195.0) — the offsite-key recovery check. A `docker exec` escape hatch in the shape of + // --print-reset-code above, and deliberately NOT a page or a browser-reachable API: the + // customer-facing flow is designed on top of a chain that has been walked, and this is the walk. + // R arrives on STDIN so it never appears in argv, `ps`, shell history or a session transcript. + if *recoverOffsiteCheck { + cfg, err := config.LoadPermissive(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "recover-offsite-check: loading config: %v\n", err) + os.Exit(1) + } + sett, err := settings.Load(cfg.Paths.DataDir+"/settings.json", log.New(os.Stderr, "", 0)) + if err != nil { + fmt.Fprintf(os.Stderr, "recover-offsite-check: loading settings: %v\n", err) + os.Exit(1) + } + ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint) + if err != nil { + fmt.Fprintf(os.Stderr, "recover-offsite-check: agent channel: %v\n", err) + os.Exit(1) + } + os.Exit(backup.RunRecoveryCheck(backup.RecoveryCheckDeps{ + Manager: backup.NewManager(cfg, sett, log.New(os.Stderr, "", 0)), + Recoverer: ac, + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + })) + } + if *printResetCode { cfg, err := config.LoadPermissive(*configPath) if err != nil { diff --git a/controller/internal/agentapi/escrow.go b/controller/internal/agentapi/escrow.go index ffe8e36..82cbd82 100644 --- a/controller/internal/agentapi/escrow.go +++ b/controller/internal/agentapi/escrow.go @@ -117,3 +117,33 @@ func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) { } return out.RecoveryCode, status, nil } + +// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the +// customer's recovery code and return ONLY the offsite restic repository password, plus its sha256 +// (R-199, agent >= v0.125.0). +// +// R CROSSES HERE, AND NOWHERE ELSE IN THIS DIRECTION. It travels in the request body over the pinned +// local-API channel (the operator's 2026-08-04 acceptance) and is not retained by this client. The +// shared POST helper logs path/status/duration and never bodies — do not add a body log, on either +// the request or the response side: the request carries R and the response carries the password. +func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode string) (password, sha256hex string, err error) { + env, status, perr := c.postWithStatus(ctx, "/escrow/recover-offsite-password", + map[string]string{"recovery_code": recoveryCode}) + if perr != nil { + return "", "", perr + } + if rerr := refusalError("/escrow/recover-offsite-password", status, env); rerr != nil { + return "", "", rerr + } + var out struct { + ResticRepoPassword string `json:"restic_repo_password"` + ResticPwSHA256 string `json:"restic_pw_sha256"` + } + if uerr := json.Unmarshal(env.Data, &out); uerr != nil { + return "", "", fmt.Errorf("agentapi: decode /escrow/recover-offsite-password: %w", uerr) + } + if out.ResticRepoPassword == "" || out.ResticPwSHA256 == "" { + return "", "", fmt.Errorf("agentapi: the agent returned an empty recovery result") + } + return out.ResticRepoPassword, out.ResticPwSHA256, nil +} diff --git a/controller/internal/backup/offbox_recovery_check.go b/controller/internal/backup/offbox_recovery_check.go new file mode 100644 index 0000000..51fc309 --- /dev/null +++ b/controller/internal/backup/offbox_recovery_check.go @@ -0,0 +1,74 @@ +package backup + +import ( + "context" + "fmt" + "strings" +) + +// R-200 (controller v0.195.0) — THE DIAGNOSTIC HALF, and only that half. +// +// The question this answers, once, decisively: **is the offsite repository password actually +// recoverable from the hub's sealed bundle?** Everything else in the recovery chain is downstream of +// that, and until 2026-08-04 nobody had ever asked it — the round-trip proof on record (2026-06-10) +// predates the field by a month, and the extraction step did not exist at all. +// +// IT COMPARES; IT DOES NOT INSTALL. The recovered password is NOT written to offboxPwPath. Comparing +// proves recoverability; installing changes a live box's state on a path nobody has walked, and +// "the existing repository opens under a recovered key" is a separate link with a drill around it. +// Keep this function free of any write — if a future change makes it install, it stops being a +// diagnostic and needs the drill's supervision. +// +// IT HANDLES ONLY HASHES OUTSIDE THE AGENT CALL. The agent returns the password and its sha256; this +// reads the hash. The value is dropped on the floor here deliberately, so no controller-side code +// path can grow a habit of holding it. + +// OffsiteKeyRecoverer is the agent-side seam (agent >= v0.125.0, +// POST /escrow/recover-offsite-password): it fetches this host's sealed bundle from the hub, unseals +// it with R, and returns ONLY the offsite repository password plus its sha256. +type OffsiteKeyRecoverer interface { + RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode string) (password, sha256hex string, err error) +} + +// RecoveryCheckResult is the verdict. It carries HASHES ONLY — there is no field here that could +// leak a password into a log, a report or a terminal. +type RecoveryCheckResult struct { + // LocalSHA256 is the hash of the repo password currently on disk ("" when there is none). + LocalSHA256 string + // RecoveredSHA256 is the hash of what came out of the sealed bundle. + RecoveredSHA256 string + // Match is the whole point: byte-identical keys produce identical hashes. + Match bool + // LocalPresent distinguishes "they differ" from "there was nothing to compare against" — a + // rebuilt box with no repo password yet is a legitimate state and must not read as a mismatch. + LocalPresent bool +} + +// CheckOffsiteKeyRecoverable recovers the repository password through the agent and compares it, by +// hash, against the one on this box's disk. It writes nothing anywhere. +// +// R is passed straight through to the agent and is not retained here. The CALLER owns clearing its +// own copy; this function keeps none. +func (m *Manager) CheckOffsiteKeyRecoverable(ctx context.Context, rec OffsiteKeyRecoverer, recoveryCode string) (RecoveryCheckResult, error) { + var out RecoveryCheckResult + if rec == nil { + return out, fmt.Errorf("offbox: no agent recovery seam configured") + } + if strings.TrimSpace(recoveryCode) == "" { + return out, fmt.Errorf("offbox: the recovery code is required") + } + // Read the local side FIRST, so a missing local password is reported as such rather than + // surfacing as a mismatch after a successful recovery. + localHash, ok := m.OffboxRepoPasswordHash() + out.LocalSHA256, out.LocalPresent = localHash, ok + + pw, recoveredHash, err := rec.RecoverOffsiteRepoPassword(ctx, recoveryCode) + if err != nil { + return out, err // the agent's message already names the step and contains no secret + } + pw = "" // the VALUE is not this function's business — §8.5, compare, do not install + _ = pw + out.RecoveredSHA256 = recoveredHash + out.Match = ok && recoveredHash != "" && recoveredHash == localHash + return out, nil +} diff --git a/controller/internal/backup/offbox_recovery_check_test.go b/controller/internal/backup/offbox_recovery_check_test.go new file mode 100644 index 0000000..d0b4c4f --- /dev/null +++ b/controller/internal/backup/offbox_recovery_check_test.go @@ -0,0 +1,197 @@ +package backup + +import ( + "bytes" + "context" + "errors" + "log" + "os" + "path/filepath" + "strings" + "testing" + + "gitea.dooplex.hu/admin/felhom-controller/internal/config" + "gitea.dooplex.hu/admin/felhom-controller/internal/settings" +) + +// R-200 — the diagnostic half. What is asserted here is the VERDICT and the NON-WRITE, because those +// are the two things that make this a proof rather than a change to a live box. + +type fakeRecoverer struct { + pw, sha string + err error + gotCode string + callable bool +} + +func (f *fakeRecoverer) RecoverOffsiteRepoPassword(_ context.Context, code string) (string, string, error) { + f.callable = true + f.gotCode = code + return f.pw, f.sha, f.err +} + +// Scenario A at this layer — the recovered key's hash is compared against the on-disk one and the +// verdict is the equality, not "no error". +func TestCheckOffsiteKeyRecoverable_MatchAndMismatch(t *testing.T) { + m, _ := newOffboxManager(t) + localHash, ok := m.OffboxRepoPasswordHash() + if !ok { + t.Fatal("precondition: no local repo password") + } + + // The key came back identical. + res, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "irrelevant", sha: localHash}, "R") + if err != nil { + t.Fatal(err) + } + if !res.Match || !res.LocalPresent || res.RecoveredSHA256 != localHash || res.LocalSHA256 != localHash { + t.Fatalf("identical keys must report Match: %+v", res) + } + + // A DIFFERENT key must report a mismatch, not an error — "it worked and disagreed" is a finding + // about the system and must be distinguishable from "a step failed". + res, err = m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "x", sha: "0000000000000000000000000000000000000000000000000000000000000000"}, "R") + if err != nil { + t.Fatalf("a mismatch is a verdict, not an error: %v", err) + } + if res.Match { + t.Fatal("a different recovered key must NOT report Match") + } +} + +// A box with no local password reports that distinctly — it is the rebuilt-box shape, where the next +// step is to install rather than to compare, and reading it as a mismatch would be wrong. +func TestCheckOffsiteKeyRecoverable_NoLocalPassword(t *testing.T) { + m := newBareManager(t) + res, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "x", sha: "abc"}, "R") + if err != nil { + t.Fatal(err) + } + if res.LocalPresent || res.Match { + t.Fatalf("no local key must report LocalPresent=false and Match=false: %+v", res) + } + if res.RecoveredSHA256 != "abc" { + t.Fatalf("the recovery itself succeeded and must be reported: %+v", res) + } +} + +// §8.5 — THE CHECK MUST NOT INSTALL. This is the assertion that keeps a diagnostic a diagnostic. +// RED-PROOF: add `m.InjectOffboxPassword(pw, true)` to CheckOffsiteKeyRecoverable → the on-disk +// password changes → this FAILS. +func TestCheckOffsiteKeyRecoverable_WritesNothing(t *testing.T) { + m, _ := newOffboxManager(t) + before, err := os.ReadFile(m.offboxPwPath()) + if err != nil { + t.Fatal(err) + } + dir := m.offboxDir() + beforeEntries, _ := os.ReadDir(dir) + + recovered := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + if _, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: recovered, sha: HashResticPassword(recovered)}, "R"); err != nil { + t.Fatal(err) + } + + after, err := os.ReadFile(m.offboxPwPath()) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) { + t.Fatal("the check INSTALLED the recovered password — it must compare and never write (§8.5); " + + "installing changes a live box on a path nobody has walked") + } + afterEntries, _ := os.ReadDir(dir) + if len(afterEntries) != len(beforeEntries) { + var names []string + for _, e := range afterEntries { + names = append(names, e.Name()) + } + t.Fatalf("the check created files in the offbox dir: %v", names) + } + // And nothing leaked into the data dir either. + _ = filepath.Walk(m.cfg.Paths.DataDir, func(p string, info os.FileInfo, werr error) error { + if werr != nil || info == nil || info.IsDir() { + return nil + } + body, rerr := os.ReadFile(p) + if rerr == nil && strings.Contains(string(body), recovered) { + t.Errorf("the recovered password was written to %s", p) + } + return nil + }) +} + +// R goes to the agent verbatim and is not mangled or retained by this layer. +func TestCheckOffsiteKeyRecoverable_PassesRThrough(t *testing.T) { + m, _ := newOffboxManager(t) + const code = "correct horse battery staple sedative anaconda wobbly kingdom placard yodel" + f := &fakeRecoverer{pw: "x", sha: "abc"} + if _, err := m.CheckOffsiteKeyRecoverable(context.Background(), f, code); err != nil { + t.Fatal(err) + } + if f.gotCode != code { + t.Fatalf("the recovery code reached the agent as %q — a 10-word code must not be re-split or trimmed internally", f.gotCode) + } +} + +// An agent-side failure surfaces as an error, and the verdict is NOT reported as a mismatch. +func TestCheckOffsiteKeyRecoverable_AgentFailure(t *testing.T) { + m, _ := newOffboxManager(t) + _, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{err: errors.New("the recovery code did not open the sealed bundle")}, "R") + if err == nil { + t.Fatal("an agent failure must be an error, never a silent mismatch") + } +} + +// The CLI's exit codes are load-bearing: 0 match, 2 clean mismatch, 1 a step failed. "It failed" and +// "it worked and disagreed" must never share a status, because only one of them is a finding. +func TestRunRecoveryCheck_ExitCodes(t *testing.T) { + m, _ := newOffboxManager(t) + localHash, _ := m.OffboxRepoPasswordHash() + + cases := []struct { + name string + rec OffsiteKeyRecoverer + in string + want int + }{ + {"match", &fakeRecoverer{pw: "x", sha: localHash}, "some recovery code\n", 0}, + {"mismatch", &fakeRecoverer{pw: "x", sha: "0000000000000000000000000000000000000000000000000000000000000000"}, "some recovery code\n", 2}, + {"agent failure", &fakeRecoverer{err: errors.New("wrong code")}, "some recovery code\n", 1}, + {"no code on stdin", &fakeRecoverer{pw: "x", sha: localHash}, "", 1}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var out, errb bytes.Buffer + got := RunRecoveryCheck(RecoveryCheckDeps{ + Manager: m, Recoverer: tc.rec, In: strings.NewReader(tc.in), Out: &out, Err: &errb, + }) + if got != tc.want { + t.Fatalf("exit = %d, want %d (out=%q err=%q)", got, tc.want, out.String(), errb.String()) + } + // No printed stream may ever carry a password or a recovery code. + combined := out.String() + errb.String() + for _, secret := range []string{"PRIVATE-KEY-MATERIAL", "some recovery code"} { + if strings.Contains(combined, secret) { + t.Errorf("the diagnostic printed a secret (%s): %s", secret, combined) + } + } + }) + } +} + +// newBareManager is an offbox manager with a data dir and NO repo password — the freshly rebuilt-box +// shape, which the no-local-password case needs and newOffboxManager deliberately does not produce. +func newBareManager(t *testing.T) *Manager { + t.Helper() + logger := log.New(os.Stderr, "", 0) + dataDir := t.TempDir() + sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger) + if err != nil { + t.Fatal(err) + } + cfg := &config.Config{} + cfg.Paths.DataDir = dataDir + cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys") + return NewManager(cfg, sett, logger) +} diff --git a/controller/internal/backup/offbox_recovery_cli.go b/controller/internal/backup/offbox_recovery_cli.go new file mode 100644 index 0000000..02fcebf --- /dev/null +++ b/controller/internal/backup/offbox_recovery_cli.go @@ -0,0 +1,118 @@ +package backup + +import ( + "bufio" + "context" + "fmt" + "io" + "os" + "strings" + "time" +) + +// R-200 — the operator-facing entry point for the recovery check, and the ONLY one this session +// ships. Deliberately a `docker exec` escape hatch in the shape of `--print-reset-code`, not a page, +// a card or an API a browser can reach: the customer-facing flow is designed on top of a chain that +// has been walked, and this is the walk. +// +// WHY R COMES FROM STDIN AND NOT A FLAG. A flag value is visible in `ps`, in the shell history, in a +// container's command line and in any transcript of the session that ran it. R is the one secret in +// this system that cannot be rotated, re-issued or recovered. It is read from stdin, held in one +// string, and cleared before the function returns — on the success path and on every failure path. +// +// docker exec -i felhom-controller /app/felhom-controller --recover-offsite-check < /root/r.txt +// +// WHAT IT PRINTS: two sha256 hashes and a verdict. Never a password, never R, never a blob. The +// hashes are of 256-bit random secrets and are non-reversible — the same value the hub already stores +// and serves in report ACKs. + +// RecoveryCheckDeps is what the CLI needs; injected so the entry point is testable without a live +// agent, a live hub or real crypto. +type RecoveryCheckDeps struct { + // Manager owns the on-disk repo password hash. + Manager *Manager + // Recoverer is the agent seam (agentapi.Client satisfies it). + Recoverer OffsiteKeyRecoverer + // In is where R is read from (os.Stdin in production). + In io.Reader + // Out / Err are the report streams (os.Stdout / os.Stderr in production). + Out, Err io.Writer + // Timeout bounds the whole check. 0 → 90s (an unseal shells out to age and a fetch crosses the WAN). + Timeout time.Duration +} + +// RunRecoveryCheck reads R from stdin, recovers the offsite repository password through the agent, +// and reports whether it matches the one on disk — BY HASH. Returns a process exit code: +// +// 0 = the hashes matched (the key is recoverable) +// 1 = a step failed (fetch, unseal, or no local password to compare against) +// 2 = the check ran cleanly and the hashes DIFFER — the loud case, and the one that would mean the +// sealed bundle does not carry what four weeks of documents say it carries +// +// A distinct code for the mismatch on purpose: "it failed" and "it worked and disagreed" must never +// share an exit status, because only one of them is a finding about the system rather than about the +// run. +func RunRecoveryCheck(d RecoveryCheckDeps) int { + out, errw := d.Out, d.Err + if out == nil { + out = os.Stdout + } + if errw == nil { + errw = os.Stderr + } + if d.Manager == nil || d.Recoverer == nil { + fmt.Fprintln(errw, "recover-offsite-check: not configured (no backup manager or no agent channel)") + return 1 + } + in := d.In + if in == nil { + in = os.Stdin + } + // Read R: the first line of stdin, trimmed. A 10-word EFF code contains spaces, so only the + // line ending is stripped — never internal whitespace. + br := bufio.NewReader(io.LimitReader(in, 4096)) + line, rerr := br.ReadString('\n') + R := strings.TrimRight(line, "\r\n") + if R == "" { + fmt.Fprintln(errw, "recover-offsite-check: no recovery code on stdin. Pipe it in:") + fmt.Fprintln(errw, " docker exec -i felhom-controller /app/felhom-controller --recover-offsite-check < /path/to/code") + if rerr != nil && rerr != io.EOF { + fmt.Fprintf(errw, " (read error: %v)\n", rerr) + } + return 1 + } + + timeout := d.Timeout + if timeout == 0 { + timeout = 90 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + fmt.Fprintln(out, "=== offsite key recovery check (R-200) — compares, never installs ===") + res, err := d.Manager.CheckOffsiteKeyRecoverable(ctx, d.Recoverer, R) + R = "" // cleared before anything else, on every path below + if err != nil { + fmt.Fprintf(errw, " [FAIL] %v\n", err) // the agent's message names the step; it carries no secret + fmt.Fprintln(errw, " nothing was written.") + return 1 + } + if !res.LocalPresent { + fmt.Fprintln(errw, " [FAIL] there is no repository password on this box to compare against") + fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256) + fmt.Fprintln(errw, " (the recovery itself SUCCEEDED — this box simply has no local key. That is the") + fmt.Fprintln(errw, " rebuilt-box shape, where the next step is to INSTALL rather than compare.)") + return 1 + } + fmt.Fprintf(out, " on-disk sha256: %s\n", res.LocalSHA256) + fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256) + if !res.Match { + fmt.Fprintln(errw, " [MISMATCH] the recovered key is NOT the key this box uses.") + fmt.Fprintln(errw, " This is a finding about the system, not about the run: the sealed bundle does not") + fmt.Fprintln(errw, " carry the repository password this box's off-site history is encrypted under.") + return 2 + } + fmt.Fprintln(out, " [MATCH] the offsite repository password IS recoverable from the sealed escrow.") + fmt.Fprintln(out, " Nothing was written: this check compares and never installs.") + return 0 +}