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:
2026-07-09 23:19:17 +02:00
parent fec2e8fd84
commit febf6757dc
8 changed files with 327 additions and 4 deletions
@@ -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)
}
}
}
@@ -0,0 +1,121 @@
package report
import (
"bytes"
"context"
"log"
"strings"
"testing"
)
const (
hubHash = "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4"
otherHash = "1111111111111111111111111111111111111111111111111111111111111111"
)
type confirmerHarness struct {
c *EscrowAutoConfirmer
pending bool
local string
localOK bool
flips int
wipes int
logbuf *bytes.Buffer
}
func newConfirmer(t *testing.T) *confirmerHarness {
t.Helper()
h := &confirmerHarness{pending: true, local: hubHash, localOK: true, logbuf: &bytes.Buffer{}}
h.c = &EscrowAutoConfirmer{
Pending: func() bool { return h.pending },
LocalHash: func() (string, bool) { return h.local, h.localOK },
Flip: func() error { h.flips++; h.pending = false; return nil },
Wipe: func(context.Context) error { h.wipes++; return nil },
Logger: log.New(h.logbuf, "", 0),
}
return h
}
func matchStatus(hash string) *EscrowStatus {
return &EscrowStatus{IdentityBlobPresent: true, ResticPwSHA256: hash, CreatedAt: "2026-07-09T20:00:00Z"}
}
// Scenario A — hash match → flip to escrowed + wipe the staged secret; and a repeat ACK is a no-op
// (state is no longer pending).
func TestEscrowConfirm_AutoConfirmsOnMatch(t *testing.T) {
h := newConfirmer(t)
h.c.Reconcile(matchStatus(hubHash))
if h.flips != 1 || h.wipes != 1 {
t.Fatalf("match must flip once + wipe once, got flips=%d wipes=%d", h.flips, h.wipes)
}
if !strings.Contains(h.logbuf.String(), "auto-confirmed") {
t.Fatal("the flip must log the hub-verified auto-confirm")
}
h.c.Reconcile(matchStatus(hubHash)) // now escrowed → no-op
if h.flips != 1 {
t.Fatal("an already-escrowed target must never be re-flipped")
}
}
// Scenario B — blob present but the hash does NOT cover the current password → stay pending + a LOUD
// warn naming the ceremony; deduped per distinct hash (not per 15-min cycle).
func TestEscrowConfirm_StaleBlobStaysPending(t *testing.T) {
h := newConfirmer(t)
h.local = otherHash // local password differs from what the blob covers
h.c.Reconcile(matchStatus(hubHash))
if h.flips != 0 || h.wipes != 0 {
t.Fatalf("a mismatched hash must NEVER flip (false custody claim): flips=%d", h.flips)
}
if !strings.Contains(h.logbuf.String(), "does not cover the CURRENT repo password") ||
!strings.Contains(h.logbuf.String(), "escrow ceremony") {
t.Fatalf("mismatch must warn loudly naming the fix, got: %s", h.logbuf.String())
}
// dedupe: the same hash again → no second warn
before := strings.Count(h.logbuf.String(), "does not cover")
h.c.Reconcile(matchStatus(hubHash))
if strings.Count(h.logbuf.String(), "does not cover") != before {
t.Fatal("repeated ACKs with the same mismatched hash must warn ONCE (dedupe)")
}
// a NEW distinct hash → warns again
h.c.Reconcile(matchStatus("2222222222222222222222222222222222222222222222222222222222222222"))
if strings.Count(h.logbuf.String(), "does not cover") != before+1 {
t.Fatal("a new distinct mismatched hash must warn again")
}
}
// Scenario C — no status / hash-less blob / no local password file → stay pending SILENTLY.
func TestEscrowConfirm_SilentPendingCases(t *testing.T) {
h := newConfirmer(t)
h.c.Reconcile(nil) // no escrow row on the hub
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: true}) // hash NULL (legacy blob)
h.c.Reconcile(&EscrowStatus{IdentityBlobPresent: false, ResticPwSHA256: hubHash}) // hash without identity blob — fail-closed
h.localOK = false
h.c.Reconcile(matchStatus(hubHash)) // no local password file
if h.flips != 0 || h.logbuf.Len() != 0 {
t.Fatalf("all no-verify cases must stay pending SILENTLY: flips=%d log=%q", h.flips, h.logbuf.String())
}
}
// Scenario E — not pending (already escrowed / offbox unconfigured) → no-op; NEVER un-confirms.
func TestEscrowConfirm_NeverActsOutsidePending(t *testing.T) {
h := newConfirmer(t)
h.pending = false // already escrowed (the demo's live state)
h.c.Reconcile(matchStatus(hubHash))
h.c.Reconcile(matchStatus(otherHash)) // even a MISMATCH on an escrowed target must not warn/touch
if h.flips != 0 || h.wipes != 0 || h.logbuf.Len() != 0 {
t.Fatalf("non-pending must be a total no-op: flips=%d wipes=%d log=%q", h.flips, h.wipes, h.logbuf.String())
}
}
// A wipe failure after the flip is loud but does not undo the confirm (the flip is primary).
func TestEscrowConfirm_WipeFailureKeepsConfirm(t *testing.T) {
h := newConfirmer(t)
h.c.Wipe = func(context.Context) error { return context.DeadlineExceeded }
h.c.Reconcile(matchStatus(hubHash))
if h.flips != 1 {
t.Fatal("the flip must land even when the wipe fails")
}
if !strings.Contains(h.logbuf.String(), "NOT wiped") {
t.Fatal("a failed wipe must log the loud NOT-wiped signal")
}
}
+3
View File
@@ -37,6 +37,9 @@ type PushResponse struct {
// delivery — the hub never connects into the box). 0 = the hub didn't advertise it (old hub, or a
// report-only customer with no config row) → the controller does nothing.
ConfigVersion int `json:"config_version"`
// Escrow (SLICE 3) is the hub's escrow status for this customer — the input to the hub-verified
// auto-confirm (EscrowAutoConfirmer). nil = no escrow row on the hub (or an old hub) → stays pending.
Escrow *EscrowStatus `json:"escrow"`
}
// Pusher sends reports to the central hub.