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 }