febf6757dc
EscrowAutoConfirmer flips pending->escrowed ONLY when sha256(local repo password) matches the ACK's restic_pw_sha256 (blob-presence alone never confirms — red-proofed). Mismatch warns once per hash naming the ceremony; never un-confirms; wipes the staged secret on flip. Pinned cross-repo hash vector; manual confirm deprecated to a legacy-blob fallback. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
122 lines
4.7 KiB
Go
122 lines
4.7 KiB
Go
package report
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"log"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
const (
|
|
hubHash = "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4"
|
|
otherHash = "1111111111111111111111111111111111111111111111111111111111111111"
|
|
)
|
|
|
|
type confirmerHarness struct {
|
|
c *EscrowAutoConfirmer
|
|
pending bool
|
|
local string
|
|
localOK bool
|
|
flips int
|
|
wipes int
|
|
logbuf *bytes.Buffer
|
|
}
|
|
|
|
func newConfirmer(t *testing.T) *confirmerHarness {
|
|
t.Helper()
|
|
h := &confirmerHarness{pending: true, local: hubHash, localOK: true, logbuf: &bytes.Buffer{}}
|
|
h.c = &EscrowAutoConfirmer{
|
|
Pending: func() bool { return h.pending },
|
|
LocalHash: func() (string, bool) { return h.local, h.localOK },
|
|
Flip: func() error { h.flips++; h.pending = false; return nil },
|
|
Wipe: func(context.Context) error { h.wipes++; return nil },
|
|
Logger: log.New(h.logbuf, "", 0),
|
|
}
|
|
return h
|
|
}
|
|
|
|
func matchStatus(hash string) *EscrowStatus {
|
|
return &EscrowStatus{IdentityBlobPresent: true, ResticPwSHA256: hash, CreatedAt: "2026-07-09T20:00:00Z"}
|
|
}
|
|
|
|
// Scenario A — hash match → flip to escrowed + wipe the staged secret; and a repeat ACK is a no-op
|
|
// (state is no longer pending).
|
|
func TestEscrowConfirm_AutoConfirmsOnMatch(t *testing.T) {
|
|
h := newConfirmer(t)
|
|
h.c.Reconcile(matchStatus(hubHash))
|
|
if h.flips != 1 || h.wipes != 1 {
|
|
t.Fatalf("match must flip once + wipe once, got flips=%d wipes=%d", h.flips, h.wipes)
|
|
}
|
|
if !strings.Contains(h.logbuf.String(), "auto-confirmed") {
|
|
t.Fatal("the flip must log the hub-verified auto-confirm")
|
|
}
|
|
h.c.Reconcile(matchStatus(hubHash)) // now escrowed → no-op
|
|
if h.flips != 1 {
|
|
t.Fatal("an already-escrowed target must never be re-flipped")
|
|
}
|
|
}
|
|
|
|
// Scenario B — blob present but the hash does NOT cover the current password → stay pending + a LOUD
|
|
// warn naming the ceremony; deduped per distinct hash (not per 15-min cycle).
|
|
func TestEscrowConfirm_StaleBlobStaysPending(t *testing.T) {
|
|
h := newConfirmer(t)
|
|
h.local = otherHash // local password differs from what the blob covers
|
|
h.c.Reconcile(matchStatus(hubHash))
|
|
if h.flips != 0 || h.wipes != 0 {
|
|
t.Fatalf("a mismatched hash must NEVER flip (false custody claim): flips=%d", h.flips)
|
|
}
|
|
if !strings.Contains(h.logbuf.String(), "does not cover the CURRENT repo password") ||
|
|
!strings.Contains(h.logbuf.String(), "escrow ceremony") {
|
|
t.Fatalf("mismatch must warn loudly naming the fix, got: %s", h.logbuf.String())
|
|
}
|
|
// dedupe: the same hash again → no second warn
|
|
before := strings.Count(h.logbuf.String(), "does not cover")
|
|
h.c.Reconcile(matchStatus(hubHash))
|
|
if strings.Count(h.logbuf.String(), "does not cover") != before {
|
|
t.Fatal("repeated ACKs with the same mismatched hash must warn ONCE (dedupe)")
|
|
}
|
|
// a NEW distinct hash → warns again
|
|
h.c.Reconcile(matchStatus("2222222222222222222222222222222222222222222222222222222222222222"))
|
|
if strings.Count(h.logbuf.String(), "does not cover") != before+1 {
|
|
t.Fatal("a new distinct mismatched hash must warn again")
|
|
}
|
|
}
|
|
|
|
// Scenario C — no status / hash-less blob / no local password file → stay pending SILENTLY.
|
|
func TestEscrowConfirm_SilentPendingCases(t *testing.T) {
|
|
h := newConfirmer(t)
|
|
h.c.Reconcile(nil) // no escrow row on the hub
|
|
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: true}) // hash NULL (legacy blob)
|
|
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: false, ResticPwSHA256: hubHash}) // hash without identity blob — fail-closed
|
|
h.localOK = false
|
|
h.c.Reconcile(matchStatus(hubHash)) // no local password file
|
|
if h.flips != 0 || h.logbuf.Len() != 0 {
|
|
t.Fatalf("all no-verify cases must stay pending SILENTLY: flips=%d log=%q", h.flips, h.logbuf.String())
|
|
}
|
|
}
|
|
|
|
// Scenario E — not pending (already escrowed / offbox unconfigured) → no-op; NEVER un-confirms.
|
|
func TestEscrowConfirm_NeverActsOutsidePending(t *testing.T) {
|
|
h := newConfirmer(t)
|
|
h.pending = false // already escrowed (the demo's live state)
|
|
h.c.Reconcile(matchStatus(hubHash))
|
|
h.c.Reconcile(matchStatus(otherHash)) // even a MISMATCH on an escrowed target must not warn/touch
|
|
if h.flips != 0 || h.wipes != 0 || h.logbuf.Len() != 0 {
|
|
t.Fatalf("non-pending must be a total no-op: flips=%d wipes=%d log=%q", h.flips, h.wipes, h.logbuf.String())
|
|
}
|
|
}
|
|
|
|
// A wipe failure after the flip is loud but does not undo the confirm (the flip is primary).
|
|
func TestEscrowConfirm_WipeFailureKeepsConfirm(t *testing.T) {
|
|
h := newConfirmer(t)
|
|
h.c.Wipe = func(context.Context) error { return context.DeadlineExceeded }
|
|
h.c.Reconcile(matchStatus(hubHash))
|
|
if h.flips != 1 {
|
|
t.Fatal("the flip must land even when the wipe fails")
|
|
}
|
|
if !strings.Contains(h.logbuf.String(), "NOT wiped") {
|
|
t.Fatal("a failed wipe must log the loud NOT-wiped signal")
|
|
}
|
|
}
|