Files
felhom-controller/controller/internal/backup/offbox_recovery_check.go
T
admin 9640e51321
gates / gates (push) Successful in 10s
controller v0.195.0: prove the offsite key comes back (R-200 plumbing half) -- MinAgent 0.125.0
--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.
2026-08-04 13:42:50 +02:00

75 lines
3.6 KiB
Go

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
}