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 ONLY // state this confirmer acts on. "escrowed" is never revisited (auto-UN-confirm does not exist). Pending 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 Logger *log.Logger mu sync.Mutex warnedHash string // last mismatched hub hash we warned about (dedupe) } 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); not pending → no-op (E — already escrowed or offbox not configured). func (c *EscrowAutoConfirmer) Reconcile(es *EscrowStatus) { if es == nil || !c.Pending() { 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 (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) } } }