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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user