v0.127.0: customer-facing escrow ceremony wizard (/backup/escrow) + Scenario-F stale-blob re-check — one-shot R reveal (no-store, typed-back), re-stage-first start order, agent version gate (MinAgent 0.88.0), escrowed-state hash re-check with card warning (never flips, never blocks); manual-confirm button removed (endpoint stays deprecated)

This commit is contained in:
2026-07-13 19:01:31 +02:00
parent 51c871ad9b
commit 08a966b92f
13 changed files with 1419 additions and 16 deletions
+79 -6
View File
@@ -25,9 +25,15 @@ type EscrowStatus struct {
// EscrowAutoConfirmer runs the auto-confirm check on each report ACK. Long-lived (one per process) so
// the mismatch warning dedupes per distinct hash instead of firing every 15-minute cycle.
type EscrowAutoConfirmer struct {
// Pending reports whether the offbox target is configured AND EscrowState=="pending" — the ONLY
// state this confirmer acts on. "escrowed" is never revisited (auto-UN-confirm does not exist).
// Pending reports whether the offbox target is configured AND EscrowState=="pending" — the
// confirm-flip state. "escrowed" is never flipped back (auto-UN-confirm does not exist), but
// since v0.127.0 it IS re-checked: see Escrowed + the stale-blob branch (Scenario F).
Pending func() bool
// Escrowed reports whether the offbox target is configured AND EscrowState=="escrowed" — the
// v0.127.0 stale-blob re-check state (Scenario F: a superseding ceremony that did NOT cover
// the current password — e.g. a CLI run without the staged secret — must be surfaced, not
// silently ignored; the spike left the drill box in exactly that state). nil → no re-check.
Escrowed func() bool
// LocalHash returns the canonical hash of the local repo password (ok=false → no password file).
LocalHash func() (hash string, ok bool)
// Flip transitions EscrowState pending→escrowed (settings.UpdateOffboxStatus).
@@ -37,7 +43,23 @@ type EscrowAutoConfirmer struct {
Logger *log.Logger
mu sync.Mutex
warnedHash string // last mismatched hub hash we warned about (dedupe)
warnedHash string // last mismatched hub hash we warned about (dedupe; shared by both branches)
stale bool // Scenario F: the hub blob does not cover the CURRENT password (display-only)
}
// staleHashlessMarker is the warnedHash dedupe sentinel for the hash-less supersession case
// (the hub hash is EMPTY there, which must still warn exactly once, and must not collide with
// the zero value of warnedHash).
const staleHashlessMarker = "(hashless)"
// StaleBlob reports the Scenario-F display flag: EscrowState is escrowed but the hub's CURRENT
// blob does not cover the current repo password. In-memory only (recomputed from ACKs after a
// restart); NEVER blocks runs and NEVER flips state — the web card renders the warning + the
// re-ceremony CTA from it.
func (c *EscrowAutoConfirmer) StaleBlob() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.stale
}
func (c *EscrowAutoConfirmer) logf(f string, a ...any) {
@@ -48,9 +70,19 @@ func (c *EscrowAutoConfirmer) logf(f string, a ...any) {
// Reconcile applies one ACK's escrow status. Scenarios: match → flip+wipe (A); mismatch → stay pending
// + warn once per hash (B); no status / no hash / no local file → stay pending silently (C, normal
// onboarding); not pending → no-op (Ealready escrowed or offbox not configured).
// onboarding); escrowed → the v0.127.0 stale-blob re-check (Fwarn-only, never a state change);
// otherwise → no-op (E — offbox not configured).
func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
if es == nil || !c.Pending() {
if es == nil {
return
}
if !c.Pending() {
// Scenario F (v0.127.0): an ESCROWED box re-checks the hash on every ACK — a superseding
// blob that does not cover the current password must be surfaced (warn + card flag), while
// runs continue and the state stays escrowed (no auto-UN-confirm, ever).
if c.Escrowed != nil && c.Escrowed() {
c.reconcileEscrowed(es)
}
return
}
// Fail-closed: the hash must exist AND ride a present identity blob (the hash-bearing container).
@@ -69,7 +101,7 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
c.warnedHash = es.ResticPwSHA256
c.mu.Unlock()
if !warned {
c.logf("[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — run the escrow ceremony (felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash)
c.logf("[WARN] [escrow-confirm] the hub's escrow blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — run the escrow ceremony (wizard /backup/escrow, or felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash)
}
return
}
@@ -85,4 +117,45 @@ func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) {
c.logf("[ERROR] [escrow-confirm] escrowed but the agent-staged secret was NOT wiped: %v", err)
}
}
c.mu.Lock()
c.stale = false // a fresh hub-verified confirm clears any earlier stale flag
c.mu.Unlock()
}
// reconcileEscrowed is the Scenario-F branch (§8 truth table, escrowed rows): compare the ACK's
// hash exactly as the pending branch does; a mismatch OR a present blob with an EMPTY hash (the
// hash-less supersession — the spike's exact case) raises the stale flag + ONE warn per distinct
// hub hash (warnedHash reuse); a match clears the flag. State is never flipped; runs never block
// (offsite backups still protect against non-total loss).
func (c *EscrowAutoConfirmer) reconcileEscrowed(es *EscrowStatus) {
localHash, ok := c.LocalHash()
if !ok {
return // no local repo password file — nothing to compare against
}
hubHash := es.ResticPwSHA256
if hubHash != "" && hubHash == localHash {
c.mu.Lock()
c.stale = false
c.mu.Unlock()
return
}
// Stale: hash mismatch, or a blob whose hash is empty (hash-less supersession). Dedupe the
// warn per distinct hub hash; the empty hash dedupes under a sentinel so it still fires once.
dedupeKey := hubHash
if dedupeKey == "" {
dedupeKey = staleHashlessMarker
}
c.mu.Lock()
warned := c.warnedHash == dedupeKey
c.warnedHash = dedupeKey
c.stale = true
c.mu.Unlock()
if warned {
return
}
if hubHash == "" {
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob carries NO password hash (hash-less supersession) — the stored recovery bundle does not cover the offsite password; create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue")
return
}
c.logf("[WARN] [escrow-confirm] STALE escrow: the hub's current blob does not cover the CURRENT repo password (hub hash %.12s… != local %.12s…) — create a new recovery code (wizard /backup/escrow). State stays escrowed; runs continue", hubHash, localHash)
}
@@ -0,0 +1,149 @@
package report
import (
"context"
"log"
"strings"
"testing"
"bytes"
)
// Scenario F (v0.127.0) — the escrowed-state stale-blob re-check. The §8 truth table's NEW rows:
// an ESCROWED box whose hub blob does not cover the current password (hash mismatch, or a present
// blob with an EMPTY hash — the spike's hash-less supersession) raises a display-only stale flag
// + ONE warn per distinct hub hash. State never flips; nothing blocks.
type staleHarness struct {
*confirmerHarness
escrowed bool
}
func newStaleHarness(t *testing.T) *staleHarness {
t.Helper()
h := &staleHarness{confirmerHarness: &confirmerHarness{local: hubHash, localOK: true, logbuf: &bytes.Buffer{}}}
h.escrowed = true // the box state under test
h.c = &EscrowAutoConfirmer{
Pending: func() bool { return h.pending },
Escrowed: func() bool { return h.escrowed },
LocalHash: func() (string, bool) { return h.local, h.localOK },
Flip: func() error { h.flips++; return nil },
Wipe: func(context.Context) error { h.wipes++; return nil },
Logger: log.New(h.logbuf, "", 0),
}
return h
}
// escrowed + hash mismatch → stale flag + ONE warn (deduped per distinct hub hash), no flip.
func TestEscrowStale_MismatchWarnsOnceAndFlags(t *testing.T) {
h := newStaleHarness(t)
h.local = otherHash
h.c.Reconcile(matchStatus(hubHash))
if !h.c.StaleBlob() {
t.Fatal("mismatch on an escrowed box must raise the stale flag")
}
if h.flips != 0 {
t.Fatal("the stale re-check must NEVER flip state (no auto-UN-confirm)")
}
if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 1 {
t.Fatalf("want exactly 1 STALE warn, got %d: %s", got, h.logbuf.String())
}
// Dedupe: the same hub hash again → still exactly one warn; the flag stays up.
h.c.Reconcile(matchStatus(hubHash))
if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 1 {
t.Fatalf("same stale hash must warn ONCE, got %d", got)
}
if !h.c.StaleBlob() {
t.Fatal("flag must persist across deduped ACKs")
}
// A NEW distinct stale hash → warns again.
h.c.Reconcile(matchStatus("2222222222222222222222222222222222222222222222222222222222222222"))
if got := strings.Count(h.logbuf.String(), "STALE escrow"); got != 2 {
t.Fatalf("a new distinct stale hash must warn again, got %d", got)
}
}
// escrowed + blob present with an EMPTY hash (the spike's exact hash-less supersession) → stale
// + one warn under the hashless dedupe sentinel.
func TestEscrowStale_HashlessBlobWarnsOnce(t *testing.T) {
h := newStaleHarness(t)
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: true}) // blob present, hash empty
if !h.c.StaleBlob() {
t.Fatal("a hash-less superseding blob must raise the stale flag")
}
if got := strings.Count(h.logbuf.String(), "NO password hash"); got != 1 {
t.Fatalf("want the hash-less warn once, got %d: %s", got, h.logbuf.String())
}
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: false}) // K-only legacy shape — still hash-less
if got := strings.Count(h.logbuf.String(), "NO password hash"); got != 1 {
t.Fatalf("hash-less must dedupe under its sentinel, got %d warns", got)
}
}
// escrowed + hash MATCHES → clears an earlier stale flag; no warn on the clean path.
func TestEscrowStale_MatchClearsFlag(t *testing.T) {
h := newStaleHarness(t)
h.local = otherHash
h.c.Reconcile(matchStatus(hubHash)) // go stale
if !h.c.StaleBlob() {
t.Fatal("setup: expected stale")
}
h.local = hubHash
h.c.Reconcile(matchStatus(hubHash)) // a covering blob arrives (re-ceremony ran)
if h.c.StaleBlob() {
t.Fatal("a matching hash must CLEAR the stale flag")
}
// And a clean box never warns.
h2 := newStaleHarness(t)
h2.c.Reconcile(matchStatus(hubHash))
if h2.c.StaleBlob() || strings.Contains(h2.logbuf.String(), "STALE") {
t.Fatalf("match must be silent: %s", h2.logbuf.String())
}
}
// Not-escrowed / no-local-password / nil-status rows: the re-check never runs (silent).
func TestEscrowStale_SilentRows(t *testing.T) {
h := newStaleHarness(t)
h.escrowed = false // offbox not configured (or any non-escrowed state)
h.c.Reconcile(matchStatus(otherHash))
if h.c.StaleBlob() || h.logbuf.Len() != 0 {
t.Fatalf("non-escrowed must be silent: %s", h.logbuf.String())
}
h2 := newStaleHarness(t)
h2.localOK = false // no local repo password file
h2.c.Reconcile(matchStatus(otherHash))
if h2.c.StaleBlob() || h2.logbuf.Len() != 0 {
t.Fatal("no local password → nothing to compare → silent")
}
h3 := newStaleHarness(t)
h3.c.Reconcile(nil) // no escrow row at all (blob absent — out of the truth table)
if h3.c.StaleBlob() || h3.logbuf.Len() != 0 {
t.Fatal("nil status must be silent")
}
}
// A fresh pending→escrowed auto-confirm clears any stale leftovers (the flag must not survive a
// successful re-ceremony's confirm).
func TestEscrowStale_AutoConfirmClears(t *testing.T) {
h := newStaleHarness(t)
h.local = otherHash
h.c.Reconcile(matchStatus(hubHash)) // stale while escrowed
if !h.c.StaleBlob() {
t.Fatal("setup: expected stale")
}
// The re-ceremony re-staged + re-uploaded; the box re-enters pending (edit flow) and the new
// blob covers the local password → auto-confirm path runs and must clear the flag.
h.escrowed = false
h.pending = true
h.c.Reconcile(matchStatus(otherHash))
if h.flips != 1 {
t.Fatal("setup: auto-confirm should have flipped")
}
if h.c.StaleBlob() {
t.Fatal("a hub-verified auto-confirm must clear the stale flag")
}
}