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.
350 lines
13 KiB
Go
350 lines
13 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)
|
|
}
|
|
|
|
// R-200 Part 0 — the INSTALL sibling. What is asserted is the three outcomes, the confirmation gate,
|
|
// and that R does not survive either path.
|
|
|
|
// An install on a box with NO local password writes it — the rebuilt-box shape, which is the only
|
|
// situation this command exists for.
|
|
// RED-PROOF: drop the `confirm` check so an unconfirmed run installs → the dry-run case below FAILS.
|
|
func TestRecoverAndInstall_InstallsOnABareBox(t *testing.T) {
|
|
m := newBareManager(t)
|
|
pw := "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
rec := &fakeRecoverer{pw: pw, sha: HashResticPassword(pw)}
|
|
|
|
// 1) DRY RUN — prints the hashes, writes nothing.
|
|
var out, errb bytes.Buffer
|
|
if got := RecoverAndInstall(RecoveryCheckDeps{Manager: m, Recoverer: rec, In: strings.NewReader("code\n"), Out: &out, Err: &errb}, false); got != 0 {
|
|
t.Fatalf("dry run exit = %d, want 0 (%s / %s)", got, out.String(), errb.String())
|
|
}
|
|
if _, present := m.OffboxRepoPasswordHash(); present {
|
|
t.Fatal("the DRY RUN wrote the password — the confirmation gate does not hold, which is the " +
|
|
"whole reason the operator gets to see the hashes before anything exists to undo")
|
|
}
|
|
if !strings.Contains(out.String(), "DRY RUN") {
|
|
t.Errorf("the dry run must say so, got %q", out.String())
|
|
}
|
|
|
|
// 2) CONFIRMED — writes it, and it reads back identical.
|
|
out.Reset()
|
|
errb.Reset()
|
|
if got := RecoverAndInstall(RecoveryCheckDeps{Manager: m, Recoverer: rec, In: strings.NewReader("code\n"), Out: &out, Err: &errb}, true); got != 0 {
|
|
t.Fatalf("confirmed exit = %d, want 0 (%s / %s)", got, out.String(), errb.String())
|
|
}
|
|
got, present := m.OffboxRepoPasswordHash()
|
|
if !present || got != HashResticPassword(pw) {
|
|
t.Fatalf("the recovered password was not placed (present=%v hash=%q)", present, got)
|
|
}
|
|
if !strings.Contains(out.String(), "INSTALLED") {
|
|
t.Errorf("a successful install must say so, got %q", out.String())
|
|
}
|
|
// The VALUE must not have been printed on either stream.
|
|
if strings.Contains(out.String()+errb.String(), pw) {
|
|
t.Fatal("the repository password was printed")
|
|
}
|
|
}
|
|
|
|
// An identical key already present is "unchanged", not "installed" and not an error — and nothing is
|
|
// written, so a re-run is harmless.
|
|
func TestRecoverAndInstall_UnchangedWhenIdentical(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
localHash, _ := m.OffboxRepoPasswordHash()
|
|
before, err := os.ReadFile(m.offboxPwPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out, errb bytes.Buffer
|
|
got := RecoverAndInstall(RecoveryCheckDeps{
|
|
Manager: m, Recoverer: &fakeRecoverer{pw: "x", sha: localHash},
|
|
In: strings.NewReader("code\n"), Out: &out, Err: &errb,
|
|
}, true)
|
|
if got != 0 {
|
|
t.Fatalf("exit = %d, want 0", got)
|
|
}
|
|
if !strings.Contains(out.String(), "UNCHANGED") {
|
|
t.Errorf("an identical key must report UNCHANGED, got %q", out.String())
|
|
}
|
|
after, _ := os.ReadFile(m.offboxPwPath())
|
|
if !bytes.Equal(before, after) {
|
|
t.Fatal("an UNCHANGED outcome rewrote the file")
|
|
}
|
|
}
|
|
|
|
// A DIFFERENT key already present is REFUSED — installing would clobber the key the box's current
|
|
// repository is encrypted under, and which history to keep is not this command's decision.
|
|
func TestRecoverAndInstall_RefusesToClobberADifferentKey(t *testing.T) {
|
|
m, _ := newOffboxManager(t)
|
|
before, err := os.ReadFile(m.offboxPwPath())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var out, errb bytes.Buffer
|
|
got := RecoverAndInstall(RecoveryCheckDeps{
|
|
Manager: m,
|
|
Recoverer: &fakeRecoverer{pw: "y", sha: "0000000000000000000000000000000000000000000000000000000000000000"},
|
|
In: strings.NewReader("code\n"), Out: &out, Err: &errb,
|
|
}, true)
|
|
if got != 2 {
|
|
t.Fatalf("exit = %d, want 2 (a refusal is its own outcome, not a generic failure)", got)
|
|
}
|
|
if !strings.Contains(errb.String(), "REFUSED") {
|
|
t.Errorf("the refusal must say so, got %q", errb.String())
|
|
}
|
|
after, _ := os.ReadFile(m.offboxPwPath())
|
|
if !bytes.Equal(before, after) {
|
|
t.Fatal("a REFUSED install clobbered the existing key — the exact outcome the refusal exists to prevent")
|
|
}
|
|
}
|
|
|
|
// R must not survive either path, and the recovery code must never be printed.
|
|
func TestRecoverAndInstall_RLeavesNoTrace(t *testing.T) {
|
|
const code = "correct horse battery staple sedative anaconda wobbly kingdom placard yodel"
|
|
for _, tc := range []struct {
|
|
name string
|
|
confirm bool
|
|
}{{"dry run", false}, {"confirmed", true}} {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
m := newBareManager(t)
|
|
pw := "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
|
|
var out, errb bytes.Buffer
|
|
RecoverAndInstall(RecoveryCheckDeps{
|
|
Manager: m, Recoverer: &fakeRecoverer{pw: pw, sha: HashResticPassword(pw)},
|
|
In: strings.NewReader(code + "\n"), Out: &out, Err: &errb,
|
|
}, tc.confirm)
|
|
combined := out.String() + errb.String()
|
|
if strings.Contains(combined, code) {
|
|
t.Errorf("the recovery code was printed: %s", combined)
|
|
}
|
|
if strings.Contains(combined, pw) {
|
|
t.Errorf("the repository password was printed: %s", combined)
|
|
}
|
|
// POSITIVE CONTROL for the sweep below: plant R in the data dir, prove the walk finds it,
|
|
// remove it. An absence check is worth only what its sensitivity is.
|
|
ctrl := filepath.Join(m.cfg.Paths.DataDir, ".planted-control")
|
|
if err := os.WriteFile(ctrl, []byte(code), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := countFilesContaining(t, m.cfg.Paths.DataDir, code); n != 1 {
|
|
t.Fatalf("positive control: the sweep found %d planted copies, want 1 — the sweep is not sensitive", n)
|
|
}
|
|
if err := os.Remove(ctrl); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n := countFilesContaining(t, m.cfg.Paths.DataDir, code); n != 0 {
|
|
t.Fatalf("the recovery code survived in %d file(s) under the data dir", n)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func countFilesContaining(t *testing.T, root, needle string) int {
|
|
t.Helper()
|
|
n := 0
|
|
_ = filepath.Walk(root, func(p string, info os.FileInfo, err error) error {
|
|
if err != nil || info == nil || info.IsDir() {
|
|
return nil
|
|
}
|
|
body, rerr := os.ReadFile(p)
|
|
if rerr == nil && strings.Contains(string(body), needle) {
|
|
n++
|
|
}
|
|
return nil
|
|
})
|
|
return n
|
|
}
|