controller v0.195.0: prove the offsite key comes back (R-200 plumbing half) -- MinAgent 0.125.0
gates / gates (push) Successful in 10s

--recover-offsite-check is 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, and reports whether the recovered key matches the one on disk BY SHA256.
Two hashes and a verdict; never a password, never R, never a blob.

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.

IT COMPARES; IT DOES NOT INSTALL. The recovered password is never written to
offbox/repo_password -- installing changes a live box on a path nobody has walked, and that
link is next session's, with the drill around it. A test asserts the data dir is byte-unchanged
after a check; its red-proof (adding the install call) fails it.

Exit codes: 0 match, 2 clean MISMATCH, 1 a step failed -- "it failed" and "it worked and
disagreed" must never share a status. A box with no local password reports distinctly: that is
the rebuilt-box shape, where the next step is to install rather than compare.

Nothing customer-reachable ships here: no card, no form, no preview.
This commit is contained in:
2026-08-04 13:42:50 +02:00
parent 0887fd676d
commit 9640e51321
6 changed files with 484 additions and 0 deletions
+30
View File
@@ -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
}
@@ -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
}
@@ -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)
}
@@ -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
}