v0.108.0: hub-verified escrow auto-confirm on current-password hash match (SLICE 3)
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
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user