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:
@@ -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 (E — already escrowed or offbox not configured).
|
||||
// onboarding); escrowed → the v0.127.0 stale-blob re-check (F — warn-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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user