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
+21
View File
@@ -3,6 +3,7 @@ package backup
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
@@ -188,6 +189,26 @@ func (m *Manager) ApplyOffsiteTarget(ctx context.Context, tgt *settings.OffboxTa
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
// 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 {
+39
View File
@@ -47,6 +47,45 @@ func argsContainTimeout(args []string) bool {
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.
func TestOffbox_BaseArgsCarryConnectTimeout(t *testing.T) {
m, sett := newOffboxManager(t)