1214bae0a2
An absent off-site object has four meanings — never configured, mid-restart, a transient config read failure, and rebuilt-and-stranded — and the hub cannot tell them apart. The box can, from two local facts it holds with certainty, so it says so instead of leaving the hub to deduce it from a silence (operator ruling). The ACK's identity_blob_present is now recorded on EVERY ACK, before the gates that used to discard it: on a box with no off-site target the auto-confirm returns immediately, which is exactly a rebuilt box, so the one fact distinguishing it from a box that never had off-site backups was thrown away every cycle. The declaration needs BOTH halves — a fresh data area AND a hub-held recovery package. Freshness alone is a box that never had off-site backups; dropping that condition makes the whole fleet ask for credentials, which is what the Scenario B test exists to catch. The object carries enabled:false and zero sizes, which is what makes it inert to the hub's existing fill and staleness checkers and to a pre-upgrade hub. A configured box's JSON is byte-identical to v0.198.0's.
185 lines
9.0 KiB
Go
185 lines
9.0 KiB
Go
package report
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// SLICE 3 — hub-verified escrow auto-confirm. Replaces operator trust ("I ran the ceremony, click
|
|
// confirm") with a verified fact: the hub's report ACK carries the sha256 of the repo password the
|
|
// stored escrow blob COVERS (recorded at ceremony time); the controller flips pending→escrowed ONLY
|
|
// when that hash matches sha256 of its CURRENT local repo password. Blob-presence alone must never
|
|
// confirm — a blob can predate the current password (re-provision, inject, drive history) and a
|
|
// truthful-looking claim on a stale blob would re-open the exact un-recoverable-ciphertext gap fork-4
|
|
// closed. Hashes are non-reversible (256-bit random secrets) and safe to log; passwords never are.
|
|
|
|
// EscrowStatus mirrors the hub ACK's `escrow` object (nil when the hub has no escrow row).
|
|
type EscrowStatus struct {
|
|
IdentityBlobPresent bool `json:"identity_blob_present"`
|
|
ResticPwSHA256 string `json:"restic_pw_sha256"`
|
|
CreatedAt string `json:"created_at"`
|
|
}
|
|
|
|
// 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
|
|
// 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).
|
|
Flip func() error
|
|
// Wipe removes the agent-staged secret (best-effort — the flip is the primary effect).
|
|
Wipe func(ctx context.Context) error
|
|
// RecordPresence persists the ACK's `identity_blob_present` — whether the HUB holds a sealed
|
|
// recovery package for this box (v0.199.0, R-204 item 4 / R-193).
|
|
//
|
|
// WHY IT LIVES HERE, in the auto-confirmer, rather than in its own ACK consumer: this is already
|
|
// the ONE place the ACK's escrow object arrives, and it is already wired. A second Reconcile call
|
|
// in main.go would be a second wiring point, and this project's count of features built but never
|
|
// wired is six. Pinned by TestEscrowConfirm_RecordsPresenceEvenWhenOffboxUnconfigured and by the
|
|
// wiring test.
|
|
//
|
|
// It is called FIRST, before every gate below, and that ordering is the whole fix: on a box with
|
|
// no off-site target `Pending()` and `Escrowed()` are both false and Reconcile returned
|
|
// immediately, so the one fact that distinguishes a REBUILT box from a box that never had
|
|
// off-site backups was thrown away on every cycle. nil → not recorded (older wiring, tests).
|
|
RecordPresence func(present bool) error
|
|
Logger *log.Logger
|
|
|
|
mu sync.Mutex
|
|
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) {
|
|
if c.Logger != nil {
|
|
c.Logger.Printf(f, a...)
|
|
}
|
|
}
|
|
|
|
// 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); 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 {
|
|
return
|
|
}
|
|
// FIRST, unconditionally — see RecordPresence. Every gate below is allowed to skip the
|
|
// auto-confirm; none of them may skip this, because an unconfigured box is exactly the case that
|
|
// needs the fact. A record failure is logged and does NOT stop the auto-confirm: the two are
|
|
// independent, and swallowing it silently is the shape this project keeps removing.
|
|
if c.RecordPresence != nil {
|
|
if err := c.RecordPresence(es.IdentityBlobPresent); err != nil {
|
|
c.logf("[WARN] [escrow-confirm] could not record the hub's identity-blob presence (present=%v): %v", es.IdentityBlobPresent, err)
|
|
}
|
|
}
|
|
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).
|
|
// A hash-less blob is a legacy/password-less escrow — the deprecated manual confirm covers those.
|
|
if es.ResticPwSHA256 == "" || !es.IdentityBlobPresent {
|
|
return
|
|
}
|
|
localHash, ok := c.LocalHash()
|
|
if !ok {
|
|
return // no local repo password file — nothing to verify against
|
|
}
|
|
if localHash != es.ResticPwSHA256 {
|
|
// The stored escrow does NOT cover the current key — flipping would be a false custody claim.
|
|
c.mu.Lock()
|
|
warned := c.warnedHash == es.ResticPwSHA256
|
|
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 (wizard /backup/escrow, or felhom-agent --selftest=escrow-create --upload); staying pending", es.ResticPwSHA256, localHash)
|
|
}
|
|
return
|
|
}
|
|
if err := c.Flip(); err != nil {
|
|
c.logf("[ERROR] [escrow-confirm] hash matched but the escrowed flip failed (retries next cycle): %v", err)
|
|
return
|
|
}
|
|
c.logf("[INFO] [escrow-confirm] hub-verified: the escrow covers the current repo password (hash %.12s…) — EscrowState auto-confirmed escrowed; offsite runs enabled", es.ResticPwSHA256)
|
|
if c.Wipe != nil {
|
|
wctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
if err := c.Wipe(wctx); err != nil {
|
|
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)
|
|
}
|