From 1b1366bb6eecfb9135da431cfb8dfebced81e41b Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 4 Aug 2026 14:27:38 +0200 Subject: [PATCH] controller v0.196.0: the recovered key installs itself (R-200 plumbing half) -- MinAgent 0.125.0 --recover-offsite-install is the sibling of --recover-offsite-check: same fetch/unseal path through the agent, same STDIN discipline for R, but it PLACES the recovered repository password via InjectOffboxPassword so a rebuilt box reopens the history it inherited. Doing this by hand would put the offsite DATA key through a terminal, a clipboard and shell history. In-process the value goes agent -> this process -> the 0600 file and is rendered nowhere. The confirmation is a SECOND invocation: without --confirm-install it prints both hashes and writes nothing, so the operator sees the comparison before any write is possible. Three outcomes, named distinctly: installed (no local password -- the rebuilt-box shape), unchanged (identical key already present, nothing written), refused (a DIFFERENT key present; installing would clobber the key the current repository is encrypted under, and no force option is offered). Exit 2 for the refusal, distinct from 1 for a failed step. Red-proof: removing the confirmation gate makes the dry run write, failing the test. The R-persistence test carries a positive control -- a planted copy is found, then removed and not found -- because an absence check is worth only what its sensitivity is. --- CHANGELOG.md | 32 ++++ controller/cmd/controller/main.go | 31 ++++ .../backup/offbox_recovery_check_test.go | 152 ++++++++++++++++++ .../internal/backup/offbox_recovery_cli.go | 107 ++++++++++++ 4 files changed, 322 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c5d7a93..d5e24da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,35 @@ +## v0.196.0 — the recovered key installs itself (2026-08-04, R-200 plumbing half) — MinAgent 0.125.0 + +`--recover-offsite-install` is the sibling of `--recover-offsite-check`: same fetch → unseal → extract +through the agent, same STDIN discipline for R, but it **places** the recovered repository password via +`InjectOffboxPassword` so a rebuilt box reopens the off-site history it inherited. + +**Why this is code and not a manual step.** The alternative is recovering the password, reading it off +a terminal and pasting it into the injection endpoint by hand — which puts the offsite DATA key through +a human's screen, clipboard and shell history. In-process, the value goes agent → this process → the +0600 file and is rendered nowhere. + +**The confirmation is a second invocation, on purpose.** Without `--confirm-install` it prints both +hashes and writes nothing, so the operator sees the comparison before any write is possible. A single +interactive prompt would have had to share stdin with R. + +**Three outcomes, named distinctly**, because "it did nothing" and "it refused" are different facts: +**installed** (no local password — the rebuilt-box shape), **unchanged** (identical key already present, +nothing written), **refused** (a DIFFERENT key present — installing would clobber the key the current +repository is encrypted under, and which history to keep is not this command's decision; no force +option is offered). Exit `2` for the refusal, distinct from `1` for a step that failed. + +The install re-reads the file afterwards rather than trusting the write — the observable is the file's +state, not the call's return. + +**Red-proof observed:** removing the confirmation gate makes the dry run write the password and fails +`TestRecoverAndInstall_InstallsOnABareBox`. The R-persistence test carries a **positive control** — a +planted copy of the code is found by the sweep, then removed and not found — because an absence check +is worth only what its sensitivity is. + +**Nothing customer-facing:** no page, no card, no form. `ensureOffboxRepo` and the orphan classifier +are untouched. + ## 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 diff --git a/controller/cmd/controller/main.go b/controller/cmd/controller/main.go index 85c21c9..acbaef4 100644 --- a/controller/cmd/controller/main.go +++ b/controller/cmd/controller/main.go @@ -76,6 +76,8 @@ func main() { 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.") + recoverOffsiteInstall := flag.Bool("recover-offsite-install", false, "R-200: read the customer recovery code from STDIN, recover the offsite repository password, print both hashes, and — with --confirm-install — PLACE it so the existing repository reopens. Without --confirm-install it is a dry run that writes nothing.") + confirmInstall := flag.Bool("confirm-install", false, "Required alongside --recover-offsite-install to actually write the recovered repository password. Deliberately a second invocation so the hashes are seen before any write is possible.") flag.Parse() if *showVersion { @@ -92,6 +94,35 @@ func main() { os.Exit(0) } + // R-200 (v0.196.0) — the INSTALL sibling. Same fetch/unseal path, same stdin discipline for R; + // the difference is that it places the recovered password so a rebuilt box reopens the off-site + // history it inherited. Two invocations by design: without --confirm-install it prints the hashes + // and writes nothing. + if *recoverOffsiteInstall { + cfg, err := config.LoadPermissive(*configPath) + if err != nil { + fmt.Fprintf(os.Stderr, "recover-offsite-install: 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-install: 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-install: agent channel: %v\n", err) + os.Exit(1) + } + os.Exit(backup.RecoverAndInstall(backup.RecoveryCheckDeps{ + Manager: backup.NewManager(cfg, sett, log.New(os.Stderr, "", 0)), + Recoverer: ac, + In: os.Stdin, + Out: os.Stdout, + Err: os.Stderr, + }, *confirmInstall)) + } + // 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. diff --git a/controller/internal/backup/offbox_recovery_check_test.go b/controller/internal/backup/offbox_recovery_check_test.go index d0b4c4f..c0dc25e 100644 --- a/controller/internal/backup/offbox_recovery_check_test.go +++ b/controller/internal/backup/offbox_recovery_check_test.go @@ -195,3 +195,155 @@ func newBareManager(t *testing.T) *Manager { cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys") return NewManager(cfg, sett, logger) } + +// R-200 Part 0 — the INSTALL sibling. What is asserted is the three outcomes, the confirmation gate, +// and that R does not survive either path. + +// An install on a box with NO local password writes it — the rebuilt-box shape, which is the only +// situation this command exists for. +// RED-PROOF: drop the `confirm` check so an unconfirmed run installs → the dry-run case below FAILS. +func TestRecoverAndInstall_InstallsOnABareBox(t *testing.T) { + m := newBareManager(t) + pw := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + rec := &fakeRecoverer{pw: pw, sha: HashResticPassword(pw)} + + // 1) DRY RUN — prints the hashes, writes nothing. + var out, errb bytes.Buffer + if got := RecoverAndInstall(RecoveryCheckDeps{Manager: m, Recoverer: rec, In: strings.NewReader("code\n"), Out: &out, Err: &errb}, false); got != 0 { + t.Fatalf("dry run exit = %d, want 0 (%s / %s)", got, out.String(), errb.String()) + } + if _, present := m.OffboxRepoPasswordHash(); present { + t.Fatal("the DRY RUN wrote the password — the confirmation gate does not hold, which is the " + + "whole reason the operator gets to see the hashes before anything exists to undo") + } + if !strings.Contains(out.String(), "DRY RUN") { + t.Errorf("the dry run must say so, got %q", out.String()) + } + + // 2) CONFIRMED — writes it, and it reads back identical. + out.Reset() + errb.Reset() + if got := RecoverAndInstall(RecoveryCheckDeps{Manager: m, Recoverer: rec, In: strings.NewReader("code\n"), Out: &out, Err: &errb}, true); got != 0 { + t.Fatalf("confirmed exit = %d, want 0 (%s / %s)", got, out.String(), errb.String()) + } + got, present := m.OffboxRepoPasswordHash() + if !present || got != HashResticPassword(pw) { + t.Fatalf("the recovered password was not placed (present=%v hash=%q)", present, got) + } + if !strings.Contains(out.String(), "INSTALLED") { + t.Errorf("a successful install must say so, got %q", out.String()) + } + // The VALUE must not have been printed on either stream. + if strings.Contains(out.String()+errb.String(), pw) { + t.Fatal("the repository password was printed") + } +} + +// An identical key already present is "unchanged", not "installed" and not an error — and nothing is +// written, so a re-run is harmless. +func TestRecoverAndInstall_UnchangedWhenIdentical(t *testing.T) { + m, _ := newOffboxManager(t) + localHash, _ := m.OffboxRepoPasswordHash() + before, err := os.ReadFile(m.offboxPwPath()) + if err != nil { + t.Fatal(err) + } + var out, errb bytes.Buffer + got := RecoverAndInstall(RecoveryCheckDeps{ + Manager: m, Recoverer: &fakeRecoverer{pw: "x", sha: localHash}, + In: strings.NewReader("code\n"), Out: &out, Err: &errb, + }, true) + if got != 0 { + t.Fatalf("exit = %d, want 0", got) + } + if !strings.Contains(out.String(), "UNCHANGED") { + t.Errorf("an identical key must report UNCHANGED, got %q", out.String()) + } + after, _ := os.ReadFile(m.offboxPwPath()) + if !bytes.Equal(before, after) { + t.Fatal("an UNCHANGED outcome rewrote the file") + } +} + +// A DIFFERENT key already present is REFUSED — installing would clobber the key the box's current +// repository is encrypted under, and which history to keep is not this command's decision. +func TestRecoverAndInstall_RefusesToClobberADifferentKey(t *testing.T) { + m, _ := newOffboxManager(t) + before, err := os.ReadFile(m.offboxPwPath()) + if err != nil { + t.Fatal(err) + } + var out, errb bytes.Buffer + got := RecoverAndInstall(RecoveryCheckDeps{ + Manager: m, + Recoverer: &fakeRecoverer{pw: "y", sha: "0000000000000000000000000000000000000000000000000000000000000000"}, + In: strings.NewReader("code\n"), Out: &out, Err: &errb, + }, true) + if got != 2 { + t.Fatalf("exit = %d, want 2 (a refusal is its own outcome, not a generic failure)", got) + } + if !strings.Contains(errb.String(), "REFUSED") { + t.Errorf("the refusal must say so, got %q", errb.String()) + } + after, _ := os.ReadFile(m.offboxPwPath()) + if !bytes.Equal(before, after) { + t.Fatal("a REFUSED install clobbered the existing key — the exact outcome the refusal exists to prevent") + } +} + +// R must not survive either path, and the recovery code must never be printed. +func TestRecoverAndInstall_RLeavesNoTrace(t *testing.T) { + const code = "correct horse battery staple sedative anaconda wobbly kingdom placard yodel" + for _, tc := range []struct { + name string + confirm bool + }{{"dry run", false}, {"confirmed", true}} { + t.Run(tc.name, func(t *testing.T) { + m := newBareManager(t) + pw := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + var out, errb bytes.Buffer + RecoverAndInstall(RecoveryCheckDeps{ + Manager: m, Recoverer: &fakeRecoverer{pw: pw, sha: HashResticPassword(pw)}, + In: strings.NewReader(code + "\n"), Out: &out, Err: &errb, + }, tc.confirm) + combined := out.String() + errb.String() + if strings.Contains(combined, code) { + t.Errorf("the recovery code was printed: %s", combined) + } + if strings.Contains(combined, pw) { + t.Errorf("the repository password was printed: %s", combined) + } + // POSITIVE CONTROL for the sweep below: plant R in the data dir, prove the walk finds it, + // remove it. An absence check is worth only what its sensitivity is. + ctrl := filepath.Join(m.cfg.Paths.DataDir, ".planted-control") + if err := os.WriteFile(ctrl, []byte(code), 0o600); err != nil { + t.Fatal(err) + } + if n := countFilesContaining(t, m.cfg.Paths.DataDir, code); n != 1 { + t.Fatalf("positive control: the sweep found %d planted copies, want 1 — the sweep is not sensitive", n) + } + if err := os.Remove(ctrl); err != nil { + t.Fatal(err) + } + if n := countFilesContaining(t, m.cfg.Paths.DataDir, code); n != 0 { + t.Fatalf("the recovery code survived in %d file(s) under the data dir", n) + } + }) + } +} + +func countFilesContaining(t *testing.T, root, needle string) int { + t.Helper() + n := 0 + _ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil || info.IsDir() { + return nil + } + body, rerr := os.ReadFile(p) + if rerr == nil && strings.Contains(string(body), needle) { + n++ + } + return nil + }) + return n +} diff --git a/controller/internal/backup/offbox_recovery_cli.go b/controller/internal/backup/offbox_recovery_cli.go index 02fcebf..0472c3d 100644 --- a/controller/internal/backup/offbox_recovery_cli.go +++ b/controller/internal/backup/offbox_recovery_cli.go @@ -116,3 +116,110 @@ func RunRecoveryCheck(d RecoveryCheckDeps) int { fmt.Fprintln(out, " Nothing was written: this check compares and never installs.") return 0 } + +// RecoverAndInstall is the sibling of RunRecoveryCheck that PLACES the recovered repository password, +// so a rebuilt box can reopen the off-site history it inherited (R-200's remaining plumbing half). +// +// WHY THIS IS CODE AND NOT A MANUAL STEP. The alternative — recover the password, read it off a +// terminal, and paste it into the injection endpoint by hand — puts the offsite DATA key through a +// human's screen, clipboard and shell history. Doing it in-process is both simpler and strictly +// safer: the value goes agent → this process → the 0600 file and is never rendered anywhere. +// +// THE CONFIRMATION IS A SEPARATE INVOCATION, ON PURPOSE. Without `confirm` this prints the two hashes +// and writes nothing — the operator sees the comparison BEFORE any write exists as a possibility. +// With `confirm` it prints the same hashes and then installs. A single interactive prompt would have +// had to share stdin with R, which is where R must not be competing for attention. +// +// THREE OUTCOMES, NAMED DISTINCTLY, because "it did nothing" and "it refused" are different facts: +// +// installed — this box had NO repository password (the rebuilt-box shape). The recovered one is placed. +// unchanged — a password is present and is byte-identical to the recovered one. Nothing is written. +// refused — a password is present and DIFFERS. Installing would clobber the key this box's CURRENT +// repository is encrypted under, so it is refused. No force option is offered here: that +// decision needs a human who knows which history they intend to keep. +func RecoverAndInstall(d RecoveryCheckDeps, confirm bool) 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-install: not configured (no backup manager or no agent channel)") + return 1 + } + in := d.In + if in == nil { + in = os.Stdin + } + 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-install: no recovery code on stdin. Pipe it in:") + fmt.Fprintln(errw, " docker exec -i felhom-controller /usr/local/bin/felhom-controller --recover-offsite-install [--confirm-install] < /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 INSTALL (R-200) ===") + // The recovery itself is the same call the check makes, so there is exactly one fetch+unseal path + // in this codebase and no chance of the two drifting. + pw, recoveredHash, err := d.Recoverer.RecoverOffsiteRepoPassword(ctx, R) + R = "" // cleared immediately, on every path below + if err != nil { + fmt.Fprintf(errw, " [FAIL] %v\n", err) + fmt.Fprintln(errw, " nothing was written.") + return 1 + } + localHash, localPresent := d.Manager.OffboxRepoPasswordHash() + if localPresent { + fmt.Fprintf(out, " on-disk sha256: %s\n", localHash) + } else { + fmt.Fprintln(out, " on-disk sha256: (none — this box has no repository password)") + } + fmt.Fprintf(out, " recovered sha256: %s\n", recoveredHash) + + switch { + case localPresent && localHash == recoveredHash: + pw = "" + fmt.Fprintln(out, " [UNCHANGED] the box already holds exactly this key. Nothing written.") + return 0 + case localPresent: + pw = "" + fmt.Fprintln(errw, " [REFUSED] a DIFFERENT repository password is already present.") + fmt.Fprintln(errw, " Installing would clobber the key this box's current repository is encrypted under,") + fmt.Fprintln(errw, " and which history to keep is not a decision this command may take. Nothing written.") + return 2 + } + if !confirm { + pw = "" + fmt.Fprintln(out, " [DRY RUN] nothing written. The recovered key is ready to install.") + fmt.Fprintln(out, " Re-run with --confirm-install to place it.") + return 0 + } + if err := d.Manager.InjectOffboxPassword(pw, false); err != nil { + pw = "" + fmt.Fprintf(errw, " [FAIL] placing the recovered password: %v\n", err) + return 1 + } + pw = "" + // Re-read from disk rather than trusting what we just wrote — the observable is the file's state. + afterHash, ok := d.Manager.OffboxRepoPasswordHash() + if !ok || afterHash != recoveredHash { + fmt.Fprintf(errw, " [FAIL] the password was written but does not read back as expected (on-disk %q)\n", afterHash) + return 1 + } + fmt.Fprintln(out, " [INSTALLED] the recovered repository password is in place and reads back identical.") + fmt.Fprintln(out, " Re-apply the offsite target and run a backup: the existing repository should open.") + return 0 +}