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 } // 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. // RecoverInstallOutcome names the terminal states of a recovery+install. Distinct values because // "it did nothing", "it refused" and "it installed" are different facts and a caller — CLI or web — // must be able to say which happened without parsing prose. type RecoverInstallOutcome string const ( // RecoverInstalled — the box had NO repository password; the recovered one is now in place. RecoverInstalled RecoverInstallOutcome = "installed" // RecoverUnchanged — a password was present and is byte-identical to the recovered one. RecoverUnchanged RecoverInstallOutcome = "unchanged" // RecoverRefused — a DIFFERENT password is present; installing would clobber the key the box's // current repository is encrypted under. RecoverRefused RecoverInstallOutcome = "refused" // RecoverDryRun — nothing was written because confirm was false. RecoverDryRun RecoverInstallOutcome = "dry_run" ) // RecoverInstallResult is the non-secret outcome of a recovery. It carries HASHES ONLY — never the // password, never R. The hashes are of 256-bit random secrets, non-reversible, and are the same // values the hub already stores and serves in report ACKs. type RecoverInstallResult struct { Outcome RecoverInstallOutcome LocalPresent bool LocalSHA256 string RecoveredSHA256 string } // RecoverInstallCore is THE recovery+install path in this codebase — fetch the sealed bundle through // the agent, unseal it with R, compare against what is on disk, and place it when that is the right // thing to do. // // ONE FUNCTION, TWO CALLERS (R-193). The CLI (`--recover-offsite-install`) and the customer's recovery // page both call this. They must not each carry a copy: two implementations of the one operation that // can permanently lose a customer's data would drift, and only one of them would ever be tested. // `RecoverAndInstall` below is a thin wrapper that maps this result onto the CLI's exit codes and // printed lines; the web handler maps it onto Hungarian copy. Neither contains recovery logic. // // R IS THE CALLER'S TO CLEAR. This function does not retain it: it is passed to the agent seam and // never stored, logged or returned. The password recovered from the bundle IS cleared here, on every // path, before returning — it never leaves this function in any form. // // The three outcomes and their reasoning are unchanged from the CLI's original implementation; see // RecoverAndInstall's header, which remains the authority on WHY a differing local password is // refused rather than forced. func RecoverInstallCore(ctx context.Context, m *Manager, rec OffsiteKeyRecoverer, R string, confirm bool) (RecoverInstallResult, error) { var res RecoverInstallResult if m == nil || rec == nil { return res, fmt.Errorf("recovery not configured (no backup manager or no agent channel)") } pw, recoveredHash, err := rec.RecoverOffsiteRepoPassword(ctx, R) if err != nil { return res, err // the agent's message names the step; it carries no secret } res.RecoveredSHA256 = recoveredHash res.LocalSHA256, res.LocalPresent = m.OffboxRepoPasswordHash() switch { case res.LocalPresent && res.LocalSHA256 == recoveredHash: pw = "" res.Outcome = RecoverUnchanged return res, nil case res.LocalPresent: pw = "" res.Outcome = RecoverRefused return res, nil } if !confirm { pw = "" res.Outcome = RecoverDryRun return res, nil } if err := m.InjectOffboxPassword(pw, false); err != nil { pw = "" return res, fmt.Errorf("placing the recovered password: %w", err) } pw = "" // Re-read from disk rather than trusting what we just wrote — the observable is the file's state. afterHash, ok := m.OffboxRepoPasswordHash() if !ok || afterHash != recoveredHash { return res, fmt.Errorf("the password was written but does not read back as expected (on-disk %q)", afterHash) } res.Outcome = RecoverInstalled return res, nil } 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 RecoverInstallCore — the same function the customer's recovery page // drives (R-193). This wrapper adds the CLI's stdin handling, its printed lines and its exit // codes, and NOTHING else; there is exactly one fetch→unseal→compare→install path in this // codebase and no chance of the two callers drifting. Pinned by // TestRecoverAndInstall_DrivesTheSharedCore and by the AST wiring test. res, err := RecoverInstallCore(ctx, d.Manager, d.Recoverer, R, confirm) 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 } if res.LocalPresent { fmt.Fprintf(out, " on-disk sha256: %s\n", res.LocalSHA256) } else { fmt.Fprintln(out, " on-disk sha256: (none — this box has no repository password)") } fmt.Fprintf(out, " recovered sha256: %s\n", res.RecoveredSHA256) switch res.Outcome { case RecoverUnchanged: fmt.Fprintln(out, " [UNCHANGED] the box already holds exactly this key. Nothing written.") return 0 case RecoverRefused: 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 case RecoverDryRun: 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 } 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 }