9640e51321
gates / gates (push) Successful in 10s
--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.
198 lines
7.1 KiB
Go
198 lines
7.1 KiB
Go
package backup
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"log"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
|
|
"gitea.dooplex.hu/admin/felhom-controller/internal/settings"
|
|
)
|
|
|
|
// R-200 — the diagnostic half. What is asserted here is the VERDICT and the NON-WRITE, because those
|
|
// are the two things that make this a proof rather than a change to a live box.
|
|
|
|
type fakeRecoverer struct {
|
|
pw, sha string
|
|
err error
|
|
gotCode string
|
|
callable bool
|
|
}
|
|
|
|
func (f *fakeRecoverer) RecoverOffsiteRepoPassword(_ context.Context, code string) (string, string, error) {
|
|
f.callable = true
|
|
f.gotCode = code
|
|
return f.pw, f.sha, f.err
|
|
}
|
|
|
|
// Scenario A at this layer — the recovered key's hash is compared against the on-disk one and the
|
|
// verdict is the equality, not "no error".
|
|
func TestCheckOffsiteKeyRecoverable_MatchAndMismatch(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
localHash, ok := m.OffboxRepoPasswordHash()
|
|
if !ok {
|
|
t.Fatal("precondition: no local repo password")
|
|
}
|
|
|
|
// The key came back identical.
|
|
res, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "irrelevant", sha: localHash}, "R")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !res.Match || !res.LocalPresent || res.RecoveredSHA256 != localHash || res.LocalSHA256 != localHash {
|
|
t.Fatalf("identical keys must report Match: %+v", res)
|
|
}
|
|
|
|
// A DIFFERENT key must report a mismatch, not an error — "it worked and disagreed" is a finding
|
|
// about the system and must be distinguishable from "a step failed".
|
|
res, err = m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "x", sha: "0000000000000000000000000000000000000000000000000000000000000000"}, "R")
|
|
if err != nil {
|
|
t.Fatalf("a mismatch is a verdict, not an error: %v", err)
|
|
}
|
|
if res.Match {
|
|
t.Fatal("a different recovered key must NOT report Match")
|
|
}
|
|
}
|
|
|
|
// A box with no local password reports that distinctly — it is the rebuilt-box shape, where the next
|
|
// step is to install rather than to compare, and reading it as a mismatch would be wrong.
|
|
func TestCheckOffsiteKeyRecoverable_NoLocalPassword(t *testing.T) {
|
|
m := newBareManager(t)
|
|
res, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: "x", sha: "abc"}, "R")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if res.LocalPresent || res.Match {
|
|
t.Fatalf("no local key must report LocalPresent=false and Match=false: %+v", res)
|
|
}
|
|
if res.RecoveredSHA256 != "abc" {
|
|
t.Fatalf("the recovery itself succeeded and must be reported: %+v", res)
|
|
}
|
|
}
|
|
|
|
// §8.5 — THE CHECK MUST NOT INSTALL. This is the assertion that keeps a diagnostic a diagnostic.
|
|
// RED-PROOF: add `m.InjectOffboxPassword(pw, true)` to CheckOffsiteKeyRecoverable → the on-disk
|
|
// password changes → this FAILS.
|
|
func TestCheckOffsiteKeyRecoverable_WritesNothing(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
before, err := os.ReadFile(m.offboxPwPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
dir := m.offboxDir()
|
|
beforeEntries, _ := os.ReadDir(dir)
|
|
|
|
recovered := "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff"
|
|
if _, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{pw: recovered, sha: HashResticPassword(recovered)}, "R"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
after, err := os.ReadFile(m.offboxPwPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !bytes.Equal(before, after) {
|
|
t.Fatal("the check INSTALLED the recovered password — it must compare and never write (§8.5); " +
|
|
"installing changes a live box on a path nobody has walked")
|
|
}
|
|
afterEntries, _ := os.ReadDir(dir)
|
|
if len(afterEntries) != len(beforeEntries) {
|
|
var names []string
|
|
for _, e := range afterEntries {
|
|
names = append(names, e.Name())
|
|
}
|
|
t.Fatalf("the check created files in the offbox dir: %v", names)
|
|
}
|
|
// And nothing leaked into the data dir either.
|
|
_ = filepath.Walk(m.cfg.Paths.DataDir, func(p string, info os.FileInfo, werr error) error {
|
|
if werr != nil || info == nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
body, rerr := os.ReadFile(p)
|
|
if rerr == nil && strings.Contains(string(body), recovered) {
|
|
t.Errorf("the recovered password was written to %s", p)
|
|
}
|
|
return nil
|
|
})
|
|
}
|
|
|
|
// R goes to the agent verbatim and is not mangled or retained by this layer.
|
|
func TestCheckOffsiteKeyRecoverable_PassesRThrough(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
const code = "correct horse battery staple sedative anaconda wobbly kingdom placard yodel"
|
|
f := &fakeRecoverer{pw: "x", sha: "abc"}
|
|
if _, err := m.CheckOffsiteKeyRecoverable(context.Background(), f, code); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if f.gotCode != code {
|
|
t.Fatalf("the recovery code reached the agent as %q — a 10-word code must not be re-split or trimmed internally", f.gotCode)
|
|
}
|
|
}
|
|
|
|
// An agent-side failure surfaces as an error, and the verdict is NOT reported as a mismatch.
|
|
func TestCheckOffsiteKeyRecoverable_AgentFailure(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
_, err := m.CheckOffsiteKeyRecoverable(context.Background(), &fakeRecoverer{err: errors.New("the recovery code did not open the sealed bundle")}, "R")
|
|
if err == nil {
|
|
t.Fatal("an agent failure must be an error, never a silent mismatch")
|
|
}
|
|
}
|
|
|
|
// The CLI's exit codes are load-bearing: 0 match, 2 clean mismatch, 1 a step failed. "It failed" and
|
|
// "it worked and disagreed" must never share a status, because only one of them is a finding.
|
|
func TestRunRecoveryCheck_ExitCodes(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
localHash, _ := m.OffboxRepoPasswordHash()
|
|
|
|
cases := []struct {
|
|
name string
|
|
rec OffsiteKeyRecoverer
|
|
in string
|
|
want int
|
|
}{
|
|
{"match", &fakeRecoverer{pw: "x", sha: localHash}, "some recovery code\n", 0},
|
|
{"mismatch", &fakeRecoverer{pw: "x", sha: "0000000000000000000000000000000000000000000000000000000000000000"}, "some recovery code\n", 2},
|
|
{"agent failure", &fakeRecoverer{err: errors.New("wrong code")}, "some recovery code\n", 1},
|
|
{"no code on stdin", &fakeRecoverer{pw: "x", sha: localHash}, "", 1},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
var out, errb bytes.Buffer
|
|
got := RunRecoveryCheck(RecoveryCheckDeps{
|
|
Manager: m, Recoverer: tc.rec, In: strings.NewReader(tc.in), Out: &out, Err: &errb,
|
|
})
|
|
if got != tc.want {
|
|
t.Fatalf("exit = %d, want %d (out=%q err=%q)", got, tc.want, out.String(), errb.String())
|
|
}
|
|
// No printed stream may ever carry a password or a recovery code.
|
|
combined := out.String() + errb.String()
|
|
for _, secret := range []string{"PRIVATE-KEY-MATERIAL", "some recovery code"} {
|
|
if strings.Contains(combined, secret) {
|
|
t.Errorf("the diagnostic printed a secret (%s): %s", secret, combined)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// newBareManager is an offbox manager with a data dir and NO repo password — the freshly rebuilt-box
|
|
// shape, which the no-local-password case needs and newOffboxManager deliberately does not produce.
|
|
func newBareManager(t *testing.T) *Manager {
|
|
t.Helper()
|
|
logger := log.New(os.Stderr, "", 0)
|
|
dataDir := t.TempDir()
|
|
sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cfg := &config.Config{}
|
|
cfg.Paths.DataDir = dataDir
|
|
cfg.Paths.SystemDataPath = filepath.Join(dataDir, "sys")
|
|
return NewManager(cfg, sett, logger)
|
|
}
|