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:
@@ -1,5 +1,26 @@
|
|||||||
## Changelog
|
## Changelog
|
||||||
|
|
||||||
|
### v0.108.0 — SLICE 3: hub-verified escrow auto-confirm (current-password hash match) (2026-07-09)
|
||||||
|
|
||||||
|
Replaces operator trust with a verified fact (pairs with agent v0.79.0 + hub v0.40.0): the report ACK now
|
||||||
|
carries `escrow:{identity_blob_present, restic_pw_sha256, created_at}` and the controller flips offbox
|
||||||
|
`EscrowState` pending→escrowed ONLY when `sha256(local repo_password) == restic_pw_sha256` — i.e. the
|
||||||
|
stored escrow provably covers the CURRENT key, not merely "a blob exists" (a stale blob would re-open the
|
||||||
|
un-recoverable-ciphertext gap fork-4 closed).
|
||||||
|
|
||||||
|
- **`internal/report`:** `PushResponse.Escrow` + `EscrowAutoConfirmer` (long-lived; runs on every ACK):
|
||||||
|
match → flip (`UpdateOffboxStatus`) + wipe the agent-staged secret (the v0.107.0 DELETE path, best-effort
|
||||||
|
loud); mismatch → stays pending + a LOUD warn naming the fix ("run the escrow ceremony"), **deduped per
|
||||||
|
distinct hash** (not per 15-min cycle); no row / NULL hash / hash-without-identity-blob / no local
|
||||||
|
password file → stays pending silently (fail-closed); non-pending → total no-op (**never un-confirms**).
|
||||||
|
**Companion red-proof:** modeled the blob-present-only check → the stale-blob and hash-less scenarios
|
||||||
|
flipped when they must not → tests FAILED. Reverted — hash-match is the load-bearing core.
|
||||||
|
- **`internal/backup`:** `HashResticPassword` (canonical: sha256 hex over the TRIMMED string — **pinned
|
||||||
|
cross-repo test vector**, same vector asserted in felhom-agent) + `Manager.OffboxRepoPasswordHash`.
|
||||||
|
- **`internal/web`:** the manual `POST /backup/offbox/confirm-escrow` is now a documented **deprecated
|
||||||
|
fallback** for legacy hash-less blobs (e.g. the demo's) — auto-confirm is primary.
|
||||||
|
- Hashes are safe to log (non-reversible over a 256-bit random secret); passwords never appear in logs.
|
||||||
|
|
||||||
### v0.107.0 — offsite hardening: key-auth-first bridge + staged-secret wipe on confirm (2026-07-09)
|
### v0.107.0 — offsite hardening: key-auth-first bridge + staged-secret wipe on confirm (2026-07-09)
|
||||||
|
|
||||||
Part of the offsite-provisioning hardening bundle (pairs with hub v0.39.0 + agent v0.78.0).
|
Part of the offsite-provisioning hardening bundle (pairs with hub v0.39.0 + agent v0.78.0).
|
||||||
|
|||||||
@@ -384,6 +384,32 @@ func main() {
|
|||||||
var hubPusher *report.Pusher
|
var hubPusher *report.Pusher
|
||||||
if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
|
if cfg.Hub.URL != "" && cfg.Hub.APIKey != "" {
|
||||||
hubPusher = report.NewPusher(&cfg.Hub, logger, cfg.Logging.Level == "debug")
|
hubPusher = report.NewPusher(&cfg.Hub, logger, cfg.Logging.Level == "debug")
|
||||||
|
// SLICE 3 — hub-verified escrow auto-confirm (long-lived: the mismatch warn dedupes per hash,
|
||||||
|
// not per 15-min cycle). Flips offbox pending→escrowed ONLY when the hub-recorded hash of the
|
||||||
|
// escrowed password matches the local repo password's hash; never un-confirms.
|
||||||
|
escrowConfirmer := &report.EscrowAutoConfirmer{
|
||||||
|
Pending: func() bool {
|
||||||
|
return backupMgr != nil && backupMgr.OffboxConfigured() &&
|
||||||
|
sett.GetOffboxTarget() != nil && sett.GetOffboxTarget().EscrowState == "pending"
|
||||||
|
},
|
||||||
|
LocalHash: func() (string, bool) {
|
||||||
|
if backupMgr == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return backupMgr.OffboxRepoPasswordHash()
|
||||||
|
},
|
||||||
|
Flip: func() error {
|
||||||
|
return sett.UpdateOffboxStatus(func(o *settings.OffboxTarget) { o.EscrowState = "escrowed" })
|
||||||
|
},
|
||||||
|
Wipe: func(ctx context.Context) error {
|
||||||
|
ac, err := agentapi.New(cfg.LocalAPI.Endpoint, cfg.LocalAPI.Token, cfg.LocalAPI.Fingerprint)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return ac.WipeStagedEscrowSecret(ctx)
|
||||||
|
},
|
||||||
|
Logger: logger,
|
||||||
|
}
|
||||||
// Wire hub verification: update settings when hub reports customer status
|
// Wire hub verification: update settings when hub reports customer status
|
||||||
hubPusher.OnPushResponse = func(resp *report.PushResponse) {
|
hubPusher.OnPushResponse = func(resp *report.PushResponse) {
|
||||||
if resp.CustomerBlocked {
|
if resp.CustomerBlocked {
|
||||||
@@ -414,6 +440,9 @@ func main() {
|
|||||||
Logger: logger,
|
Logger: logger,
|
||||||
}
|
}
|
||||||
cr.Reconcile(resp.ConfigVersion)
|
cr.Reconcile(resp.ConfigVersion)
|
||||||
|
// SLICE 3: run the escrow auto-confirm on the same ACK (after the config refresh decision —
|
||||||
|
// a refresh-restart re-enters here anyway on the next cycle).
|
||||||
|
escrowConfirmer.Reconcile(resp.Escrow)
|
||||||
}
|
}
|
||||||
// Wire hub push status into alert manager for dashboard alerts
|
// Wire hub push status into alert manager for dashboard alerts
|
||||||
alertMgr.SetHubPushStatus(func() web.HubPushStatusData {
|
alertMgr.SetHubPushStatus(func() web.HubPushStatusData {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package backup
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -188,6 +189,26 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HashResticPassword is the CANONICAL hasher for the offsite repo password (SLICE 3 hub-verified escrow
|
||||||
|
// auto-confirm): sha256 hex of the TRIMMED password string — the SAME convention as the agent's
|
||||||
|
// escrow.HashResticPassword (both sides TrimSpace their file reads; pinned by the SAME cross-repo test
|
||||||
|
// vector in felhom-agent). The hash of a 256-bit random secret is non-reversible and non-brute-forceable —
|
||||||
|
// safe to log/compare; the PASSWORD itself is never logged.
|
||||||
|
func HashResticPassword(pw string) string {
|
||||||
|
sum := sha256.Sum256([]byte(strings.TrimSpace(pw)))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// OffboxRepoPasswordHash returns the canonical hash of the local repo password (false when no password
|
||||||
|
// file exists — nothing to match; the auto-confirm check skips).
|
||||||
|
func (m *Manager) OffboxRepoPasswordHash() (string, bool) {
|
||||||
|
pw, err := os.ReadFile(m.offboxPwPath())
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
return HashResticPassword(string(pw)), true
|
||||||
|
}
|
||||||
|
|
||||||
// PushOffboxPasswordForEscrow reads the 0600 repo password and hands it to `stage` (the agent push), so
|
// PushOffboxPasswordForEscrow reads the 0600 repo password and hands it to `stage` (the agent push), so
|
||||||
// the web/handler caller never sees the value — used by the enable flow to escrow-stage the offsite key.
|
// the web/handler caller never sees the value — used by the enable flow to escrow-stage the offsite key.
|
||||||
func (m *Manager) PushOffboxPasswordForEscrow(ctx context.Context, stage func(ctx context.Context, pw string) error) error {
|
func (m *Manager) PushOffboxPasswordForEscrow(ctx context.Context, stage func(ctx context.Context, pw string) error) error {
|
||||||
|
|||||||
@@ -47,6 +47,45 @@ func argsContainTimeout(args []string) bool {
|
|||||||
return strings.Contains(strings.Join(args, " "), "-oConnectTimeout=")
|
return strings.Contains(strings.Join(args, " "), "-oConnectTimeout=")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PINNED CROSS-REPO TEST VECTOR (SLICE 3): the same vector is asserted in felhom-agent's
|
||||||
|
// escrow.HashResticPassword test — if either hasher drifts (newline, encoding, trim), its half fails and
|
||||||
|
// the escrow auto-confirm can never silently mismatch. Convention: sha256 hex over the TRIMMED string.
|
||||||
|
func TestHashResticPassword_PinnedVector(t *testing.T) {
|
||||||
|
const vector = "cafef00ddeadbeef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
const want = "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4"
|
||||||
|
if got := HashResticPassword(vector); got != want {
|
||||||
|
t.Fatalf("pinned vector drift: got %s want %s", got, want)
|
||||||
|
}
|
||||||
|
if got := HashResticPassword(" " + vector + "\n"); got != want {
|
||||||
|
t.Fatalf("whitespace must not change the hash (trim convention), got %s", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OffboxRepoPasswordHash: hashes the on-disk password file (the auto-confirm's local side); absent → ok=false.
|
||||||
|
func TestOffboxRepoPasswordHash(t *testing.T) {
|
||||||
|
// bare manager (no secrets written yet) → no password file → ok=false
|
||||||
|
logger := log.New(os.Stderr, "", 0)
|
||||||
|
dataDir := t.TempDir()
|
||||||
|
sett, err := settings.Load(filepath.Join(dataDir, "settings.json"), logger)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
cfg := &config.Config{}
|
||||||
|
cfg.Paths.DataDir = dataDir
|
||||||
|
m := NewManager(cfg, sett, logger)
|
||||||
|
if _, ok := m.OffboxRepoPasswordHash(); ok {
|
||||||
|
t.Fatal("no password file → ok must be false")
|
||||||
|
}
|
||||||
|
const pw = "cafef00ddeadbeef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
if err := m.InjectOffboxPassword(pw, false); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, ok := m.OffboxRepoPasswordHash()
|
||||||
|
if !ok || got != "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4" {
|
||||||
|
t.Fatalf("hash of the injected password wrong: ok=%v got=%s", ok, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestOffbox_BaseArgsCarryConnectTimeout asserts the mandatory fail-fast + hardening args are present.
|
// TestOffbox_BaseArgsCarryConnectTimeout asserts the mandatory fail-fast + hardening args are present.
|
||||||
func TestOffbox_BaseArgsCarryConnectTimeout(t *testing.T) {
|
func TestOffbox_BaseArgsCarryConnectTimeout(t *testing.T) {
|
||||||
m, sett := newOffboxManager(t)
|
m, sett := newOffboxManager(t)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// 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.
|
// report-only customer with no config row) → the controller does nothing.
|
||||||
ConfigVersion int `json:"config_version"`
|
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.
|
// Pusher sends reports to the central hub.
|
||||||
|
|||||||
@@ -108,10 +108,11 @@ func (s *Server) offboxConfigHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
offboxRedirect(w, r, "A NAS mentési cél elmentve."+stageErr, stageErr != "")
|
offboxRedirect(w, r, "A NAS mentési cél elmentve."+stageErr, stageErr != "")
|
||||||
}
|
}
|
||||||
|
|
||||||
// offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4). The operator
|
// offboxConfirmEscrowHandler marks the offsite repo password as escrowed under R (fork-4).
|
||||||
// calls this after a successful escrow-create ceremony; offsite runs stay gated until then. (The
|
// DEPRECATED FALLBACK (SLICE 3): the PRIMARY path is the hub-verified auto-confirm
|
||||||
// provisioning task should replace this with a hub-verified auto-confirm to remove the operator-forgets/
|
// (report.EscrowAutoConfirmer — flips on a hash match in the report ACK, no operator involved). This
|
||||||
// operator-lies footgun.)
|
// manual endpoint stays for LEGACY blobs recorded before the hash existed (e.g. the demo's) — they have
|
||||||
|
// no restic_pw_sha256 and can never auto-confirm; the operator vouches by hand after a verified ceremony.
|
||||||
func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) offboxConfirmEscrowHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
if s.backupMgr == nil || !s.backupMgr.OffboxConfigured() {
|
||||||
offboxRedirect(w, r, "A NAS mentési cél nincs beállítva.", true)
|
offboxRedirect(w, r, "A NAS mentési cél nincs beállítva.", true)
|
||||||
|
|||||||
Reference in New Issue
Block a user