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. 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 }