1b1366bb6e
gates / gates (push) Successful in 8s
--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.
226 lines
9.7 KiB
Go
226 lines
9.7 KiB
Go
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
|
|
}
|