diff --git a/CHANGELOG.md b/CHANGELOG.md index 65ffabd..75a35bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,19 @@ +## v0.79.0 — SLICE 3: escrow upload carries sha256 of the sealed restic password (2026-07-09) + +The hub-verified escrow auto-confirm chain, agent third: the escrow-create ceremony now records WHICH +offsite repo password the blob covers — as a non-reversible sha256 (a 256-bit random secret's hash is safe +to store/serve; the password itself is never logged or uploaded). + +- `internal/escrow.HashResticPassword` — the CANONICAL hasher: sha256 hex over the TRIMMED password string + (exactly the value `AttachResticPassword` seals into the blob). **Pinned cross-repo test vector** + (`TestHashResticPassword_PinnedVector`, same vector asserted in felhom-controller) so the two hashers can + never drift silently. +- `cmd/felhom-agent`: `escrowUploadRequest` gains `restic_pw_sha256,omitempty` — set only when a staged + password was folded in (no staged file → field OMITTED → the hub stores NULL → the controller stays + pending; correct, the blob doesn't cover the key). `TestEscrowUploadContract` updated (the hub mirrors it + in the same commit-pair) + asserts the omitted-when-unstaged behavior. +- Ceremony flow (create / self-verify / R-banner / staged-file wipe) otherwise untouched. + ## v0.78.0 — fork-4 hygiene: DELETE /escrow/stage-secret (staged-secret wipe) (2026-07-09) Part of the offsite-provisioning hardening bundle (pairs with controller v0.107.0 + hub v0.39.0). The staged diff --git a/cmd/felhom-agent/escrow_contract_test.go b/cmd/felhom-agent/escrow_contract_test.go index 6f137ce..45cc492 100644 --- a/cmd/felhom-agent/escrow_contract_test.go +++ b/cmd/felhom-agent/escrow_contract_test.go @@ -11,7 +11,7 @@ import ( // struct (felhom-hub api.escrowUploadRequest). Cross-repo, no shared module — this is the agent // half of the contract guard; the hub has the mirror in its own test. func TestEscrowUploadContract(t *testing.T) { - b, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t"}) + b, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t", ResticPwSHA256: "h"}) var m map[string]any if err := json.Unmarshal(b, &m); err != nil { t.Fatal(err) @@ -21,8 +21,16 @@ func TestEscrowUploadContract(t *testing.T) { got = append(got, k) } sort.Strings(got) - want := []string{"blob_b64", "created_at", "key_fingerprint", "posture"} + want := []string{"blob_b64", "created_at", "key_fingerprint", "posture", "restic_pw_sha256"} if !reflect.DeepEqual(got, want) { t.Fatalf("escrow wire contract drift: got %v want %v (must match the hub ingest struct)", got, want) } + // SLICE 3: no staged password folded in → the hash field is OMITTED on the wire (the hub stores NULL → + // the controller never matches → stays pending; correct — the blob doesn't cover the key). + b2, _ := json.Marshal(escrowUploadRequest{BlobB64: "x", KeyFingerprint: "y", Posture: "z", CreatedAt: "t"}) + var m2 map[string]any + _ = json.Unmarshal(b2, &m2) + if _, present := m2["restic_pw_sha256"]; present { + t.Fatal("restic_pw_sha256 must be omitted when no staged password was sealed") + } } diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index fae88b8..194432f 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -1714,6 +1714,7 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo // into the escrowed identity, so DR can recover the offsite DATA key with the one recovery code R. // Field NAME only in logs. No staged file → clean no-attach (pre-fork-4 behavior). Wiped after create. resticStaged := false + resticPwSHA256 := "" // SLICE 3: sha256 of the sealed password (safe to upload/serve; value never logged) { probe := identity if probe == nil { @@ -1727,6 +1728,9 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo if attached { identity = probe resticStaged = true + // Hash EXACTLY the value sealed into the blob — the controller matches this against + // sha256(its local repo_password) to auto-confirm the escrow (hub-verified, SLICE 3). + resticPwSHA256 = escrow.HashResticPassword(probe.ResticRepoPassword) logger.Info("escrow: identity bundle: +restic_repo_password") } } @@ -1781,7 +1785,7 @@ func runSelftestEscrowCreate(ctx context.Context, cfg config.Config, logger *slo fmt.Printf(" identity escrow: %d bytes (age-wrapped {tunnel,pbs} under the same R) · self-verify OK\n", len(res.IdentityBlob)) } if upload { - if err := uploadEscrowBlob(ctx, cfg, res, directive); err != nil { + if err := uploadEscrowBlob(ctx, cfg, res, directive, resticPwSHA256); err != nil { fmt.Fprintln(os.Stderr, " [FAIL] upload escrow to hub:", err) return 1 } @@ -1896,12 +1900,16 @@ type escrowUploadRequest struct { IdentityBlobB64 string `json:"identity_blob_b64,omitempty"` DirectiveJSON json.RawMessage `json:"directive,omitempty"` CreatedAt string `json:"created_at"` // RFC3339 + // SLICE 3 — sha256 hex of the offsite restic repo password sealed in the identity blob (present only + // when a staged password was folded in). Non-reversible hash of a 256-bit random secret — safe to + // store/serve; lets the controller VERIFY "the escrow covers the CURRENT key" and auto-confirm. + ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"` } // uploadEscrowBlob PUTs the opaque blob (and, for 10D, the identity blob + non-secret directive) to // the hub, authed with the per-host key. The hub stores ciphertext + non-secret fields; no usable // secret leaves the agent. -func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, directive json.RawMessage) error { +func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateResult, directive json.RawMessage, resticPwSHA256 string) error { if cfg.Hub.URL == "" || cfg.Hub.HostID == "" || cfg.Hub.APIKey == "" { return fmt.Errorf("hub not configured (url/host_id/api_key)") } @@ -1910,6 +1918,7 @@ func uploadEscrowBlob(ctx context.Context, cfg config.Config, res escrow.CreateR KeyFingerprint: res.KeyFingerprint, Posture: string(res.Posture), CreatedAt: time.Now().UTC().Format(time.RFC3339), + ResticPwSHA256: resticPwSHA256, // "" when no staged password was folded in → omitted on the wire } if len(res.IdentityBlob) > 0 { upReq.IdentityBlobB64 = base64.StdEncoding.EncodeToString(res.IdentityBlob) diff --git a/internal/escrow/identity.go b/internal/escrow/identity.go index 5a7f05a..16530b0 100644 --- a/internal/escrow/identity.go +++ b/internal/escrow/identity.go @@ -2,7 +2,9 @@ package escrow import ( "context" + "crypto/sha256" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "os" @@ -53,6 +55,17 @@ func WipeStagedResticPassword() error { return nil } +// HashResticPassword is the CANONICAL hasher for the offsite restic repo password (SLICE 3 hub-verified +// escrow auto-confirm): sha256 hex of the TRIMMED password string — exactly the value AttachResticPassword +// seals into the blob and the value the controller uses (both sides TrimSpace their file reads, so the +// trimmed string is the drift-free convention; pinned by the SAME test vector in felhom-agent and +// felhom-controller). The hash of a 256-bit random secret is non-reversible and non-brute-forceable — +// safe to store on the hub and serve in report ACKs; the PASSWORD itself is never logged or served. +func HashResticPassword(pw string) string { + sum := sha256.Sum256([]byte(strings.TrimSpace(pw))) + return hex.EncodeToString(sum[:]) +} + // AttachResticPassword injects the offsite restic repo password from the staged 0600 file into the bundle // when it exists (fork-4 escrow-create auto-inject). Returns whether it attached. The VALUE is validated // (non-empty) but NEVER logged by callers — log the field NAME only (mirrors AttachWGKey). A missing file diff --git a/internal/escrow/identity_test.go b/internal/escrow/identity_test.go index 9b48909..3bb9c3d 100644 --- a/internal/escrow/identity_test.go +++ b/internal/escrow/identity_test.go @@ -97,6 +97,21 @@ func TestIdentity_RoundTrip_CarriesResticPassword(t *testing.T) { } } +// PINNED CROSS-REPO TEST VECTOR (SLICE 3): the same vector is asserted in felhom-controller — if either +// side drifts (trailing newline, encoding, trim behavior), its half of this test fails and auto-confirm +// can never silently mismatch. Convention: sha256 hex over the TRIMMED password 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) + } + // trim convention: surrounding whitespace/newlines do not change the hash (both sides trim) + if got := HashResticPassword(" " + vector + "\n"); got != want { + t.Fatalf("whitespace must not change the hash (trim convention), got %s", got) + } +} + // AttachResticPassword: missing file → clean no-attach; staged file → trimmed value attached; empty → error. func TestAttachResticPassword(t *testing.T) { b := &IdentityBundle{}