diff --git a/hub/CHANGELOG.md b/hub/CHANGELOG.md index ff2ce49..65ce71e 100644 --- a/hub/CHANGELOG.md +++ b/hub/CHANGELOG.md @@ -1,5 +1,23 @@ # Felhom Hub — Changelog +## v0.40.0 — SLICE 3: store the escrow password-hash + serve escrow status in the report ACK (2026-07-09) + +The hub-verified escrow auto-confirm chain, hub third (pairs with agent v0.79.0 + controller v0.108.0). +The controller must verify the RIGHT fact — not "a blob exists" but "the blob covers the CURRENT repo +password" — so the hub records WHICH password each escrow covers, as a non-reversible sha256 (a 256-bit +random secret's hash is safe to store/serve; the password itself never reaches the hub). + +- `internal/store`: additive migration `ALTER TABLE host_escrow ADD COLUMN restic_pw_sha256 TEXT` + (NULL on legacy rows — e.g. the demo's — which therefore never auto-confirm; the deprecated manual + confirm covers them). `HostEscrow.ResticPwSHA256` + `SaveHostEscrow` gains the param (last-write-wins); + NULL-safe reads via COALESCE. New `GetEscrowStatusForCustomer` (hosts⋈host_escrow; latest-updated wins). +- `internal/api`: `escrowUploadRequest.restic_pw_sha256,omitempty` (the agent emit struct's mirror — + `TestEscrowUploadContract` updated in lockstep with the agent's half); stored on upload. The **report ACK** + gains `escrow: {identity_blob_present, restic_pw_sha256, created_at}` — omitted entirely when the customer + has no escrow row (a fresh customer stays pending silently). +- Tests: hash stored + legacy-upload reads back NULL-safe as ""; ACK carries the object / omits it without a + row; contract mirror. + ## v0.39.0 — offsite hardening: F4 credential re-issue + F2 scan retry + F5 save UX (2026-07-09) Part of the offsite-provisioning hardening bundle (pairs with controller v0.107.0 + agent v0.78.0); the diff --git a/hub/internal/api/dr_test.go b/hub/internal/api/dr_test.go index 7ad131c..5bdcfde 100644 --- a/hub/internal/api/dr_test.go +++ b/hub/internal/api/dr_test.go @@ -71,7 +71,7 @@ func TestRestoreDirective_GatedAndExpires(t *testing.T) { h, st, _ := newTestHandler(t) seedHost(t, st, "h1", "c1", "HKEY") // Seed a DR bundle: K-escrow row + identity blob + directive. - st.SaveHostEscrow("h1", []byte("opaque-K-escrow"), "01:36:e9:…", "zero_knowledge", time.Now().UTC().Format(time.RFC3339)) + st.SaveHostEscrow("h1", []byte("opaque-K-escrow"), "01:36:e9:…", "zero_knowledge", time.Now().UTC().Format(time.RFC3339), "") st.SaveHostDRBundle("h1", []byte("opaque-identity"), `{"pbs_repo":"r","tunnel_id":"t","expected_key_fingerprint":"01:36:e9:…"}`) // Not in recovery mode → 403. diff --git a/hub/internal/api/escrow_test.go b/hub/internal/api/escrow_test.go index 6aa6fec..966bda9 100644 --- a/hub/internal/api/escrow_test.go +++ b/hub/internal/api/escrow_test.go @@ -96,7 +96,7 @@ func TestHandleHostEscrow_BadBody(t *testing.T) { // (felhom-agent escrowUploadRequest). Cross-repo, no shared module — this is the hub half of the // contract guard; the agent 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 json.Unmarshal(b, &m) got := make([]string, 0, len(m)) @@ -104,8 +104,75 @@ 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 agent emit struct)", got, want) } } + +// SLICE 3 — the upload's restic_pw_sha256 is stored (and "" stays NULL-safe on legacy rows). +func TestHandleHostEscrow_StoresResticPwHash(t *testing.T) { + h, st, _ := newTestHandler(t) + st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"}) + body, _ := json.Marshal(map[string]any{ + "blob_b64": base64.StdEncoding.EncodeToString(opaqueBlob), + "key_fingerprint": "fp", + "posture": "zero_knowledge", + "created_at": "2026-07-09T20:00:00Z", + "restic_pw_sha256": "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4", + }) + if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", string(body)); rr.Code != http.StatusOK { + t.Fatalf("PUT = %d", rr.Code) + } + got, _ := st.GetHostEscrow("h1") + if got.ResticPwSHA256 != "dbfc02f987e1ac0c91911d5761267089b1144628745a3343e4d96194e43c08e4" { + t.Fatalf("hash not stored: %q", got.ResticPwSHA256) + } + // legacy upload without the field → stored empty, read back NULL-safe as "" + if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("legacy"))); rr.Code != http.StatusOK { + t.Fatalf("legacy PUT = %d", rr.Code) + } + got2, err := st.GetHostEscrow("h1") + if err != nil || got2.ResticPwSHA256 != "" { + t.Fatalf("legacy upload must read back with an empty hash: %q err=%v", got2.ResticPwSHA256, err) + } +} + +// SLICE 3 — the report ACK carries the escrow status object for the customer; omitted when no escrow row. +func TestReportACK_EscrowStatus(t *testing.T) { + h, st, _ := newTestHandler(t) + st.UpsertHost(&store.Host{HostID: "hv1", CustomerID: "cust-e", APIKey: "HK"}) + + // no escrow row → the ACK has NO escrow key (Scenario C: stays pending silently controller-side) + rr := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"cust-e"}`) + if rr.Code != http.StatusOK { + t.Fatalf("report = %d", rr.Code) + } + var ack map[string]any + json.Unmarshal(rr.Body.Bytes(), &ack) + if _, present := ack["escrow"]; present { + t.Fatal("no escrow row → the ACK must omit the escrow object") + } + + // escrow row with identity blob + hash → the ACK carries all three fields + if err := st.SaveHostEscrow("hv1", []byte("k-blob"), "fp", "zero_knowledge", "2026-07-09T20:00:00Z", "abc123"); err != nil { + t.Fatal(err) + } + if err := st.SaveHostDRBundle("hv1", []byte("identity-blob"), "{}"); err != nil { + t.Fatal(err) + } + rr2 := do(h, http.MethodPost, "/report", globalKey, `{"customer_id":"cust-e"}`) + var ack2 struct { + Escrow struct { + IdentityBlobPresent bool `json:"identity_blob_present"` + ResticPwSHA256 string `json:"restic_pw_sha256"` + CreatedAt string `json:"created_at"` + } `json:"escrow"` + } + if err := json.Unmarshal(rr2.Body.Bytes(), &ack2); err != nil { + t.Fatal(err) + } + if !ack2.Escrow.IdentityBlobPresent || ack2.Escrow.ResticPwSHA256 != "abc123" || ack2.Escrow.CreatedAt == "" { + t.Fatalf("ACK escrow status wrong: %+v (body=%s)", ack2.Escrow, rr2.Body.String()) + } +} diff --git a/hub/internal/api/handler.go b/hub/internal/api/handler.go index 0fab63d..9b2eb7d 100644 --- a/hub/internal/api/handler.go +++ b/hub/internal/api/handler.go @@ -334,6 +334,14 @@ func (h *Handler) handleReport(w http.ResponseWriter, r *http.Request) { resp["config_version"] = custCfg.ConfigVersion } + // SLICE 3 — escrow status for the hub-verified auto-confirm: the controller flips its offbox + // EscrowState pending→escrowed ONLY when sha256(its local repo password) matches restic_pw_sha256 + // (blob-presence alone must never confirm — a stale blob may not cover the current key). The hash is + // non-reversible (256-bit random secret) — safe to serve; omitted entirely when no escrow row exists. + if es, err := h.store.GetEscrowStatusForCustomer(payload.CustomerID); err == nil && es != nil { + resp["escrow"] = es + } + // Phase 2 managed updates: advertise the effective controller-version FLOOR (per-customer override // else global default) and the latest available version. The controller compares its current // version against the floor and auto-updates when below it (latest stays the customer's opt-in @@ -828,6 +836,9 @@ type escrowUploadRequest struct { // Slice 10D.1 — optional DR bundle, stored alongside the K-escrow (both opaque/non-secret). IdentityBlobB64 string `json:"identity_blob_b64,omitempty"` // age-wrapped {tunnel_token, pbs_token} DirectiveJSON json.RawMessage `json:"directive,omitempty"` // non-secret directive (pbs repo/ns, expected fp, tunnel id) + // SLICE 3 — sha256 hex of the restic repo password sealed in the identity blob (non-reversible hash + // of a 256-bit random secret — safe to store/serve; present only when a staged password was folded in). + ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"` } // handleHostEscrowPut stores a host's opaque escrow blob (doc 03 §8a). Authed with the PER-HOST key @@ -868,7 +879,7 @@ func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pa createdAt = time.Now().UTC().Format(time.RFC3339) } // Store the OPAQUE bytes. No decrypt path exists — the hub cannot open this. - if err := h.store.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt); err != nil { + if err := h.store.SaveHostEscrow(pathHostID, blob, req.KeyFingerprint, req.Posture, createdAt, req.ResticPwSHA256); err != nil { h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, err) http.Error(w, "Internal error", http.StatusInternalServerError) return diff --git a/hub/internal/store/store.go b/hub/internal/store/store.go index 177bde0..4c124ec 100644 --- a/hub/internal/store/store.go +++ b/hub/internal/store/store.go @@ -347,6 +347,12 @@ func (s *Store) migrate() error { s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN identity_blob BLOB`) s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN directive_json TEXT NOT NULL DEFAULT '{}'`) + // SLICE 3 (escrow auto-confirm) — sha256 hex of the offsite restic repo password sealed in the + // identity blob. The hash of a 256-bit random secret is non-reversible/non-brute-forceable — safe to + // store and serve; it lets the controller VERIFY "the escrow covers the CURRENT repo password" + // instead of trusting blob-presence. NULL/'' = a legacy or password-less blob (never auto-confirms). + s.db.Exec(`ALTER TABLE host_escrow ADD COLUMN restic_pw_sha256 TEXT`) + // dr_recipe (SPIKE-dr-recipe-2026-06-16): the secret-free DR reconstruction recipe, stored // PLAINTEXT (it has NO secrets — the clean inverse of the retired infra_backup). Two halves keyed // by customer: the agent's storage/guest/PBS half (host_half_json, from the host-report) and the @@ -1473,21 +1479,26 @@ type HostEscrow struct { Posture string CreatedAt string UpdatedAt string + // ResticPwSHA256 (SLICE 3) — the non-reversible hash of the offsite repo password the identity blob + // covers ("" = legacy/password-less blob). Safe to store/serve; the password itself never reaches the hub. + ResticPwSHA256 string } // SaveHostEscrow stores (last-write-wins) the OPAQUE escrow blob for a host. The hub keeps the // bytes and NEVER decrypts them — there is no decrypt path. createdAt is the agent's timestamp. -func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt string) error { +// resticPwSHA256 is "" when the ceremony sealed no staged password (stored as-is; never auto-confirms). +func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, posture, createdAt, resticPwSHA256 string) error { _, err := s.db.Exec(` - INSERT INTO host_escrow (host_id, blob, key_fingerprint, posture, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, datetime('now')) + INSERT INTO host_escrow (host_id, blob, key_fingerprint, posture, created_at, restic_pw_sha256, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(host_id) DO UPDATE SET blob = excluded.blob, key_fingerprint = excluded.key_fingerprint, posture = excluded.posture, created_at = excluded.created_at, + restic_pw_sha256 = excluded.restic_pw_sha256, updated_at = datetime('now')`, - hostID, blob, keyFingerprint, posture, createdAt, + hostID, blob, keyFingerprint, posture, createdAt, resticPwSHA256, ) return err } @@ -1497,9 +1508,9 @@ func (s *Store) SaveHostEscrow(hostID string, blob []byte, keyFingerprint, postu func (s *Store) GetHostEscrow(hostID string) (*HostEscrow, error) { var e HostEscrow err := s.db.QueryRow(` - SELECT host_id, blob, key_fingerprint, posture, created_at, updated_at + SELECT host_id, blob, key_fingerprint, posture, created_at, updated_at, COALESCE(restic_pw_sha256, '') FROM host_escrow WHERE host_id = ?`, hostID). - Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt) + Scan(&e.HostID, &e.Blob, &e.KeyFingerprint, &e.Posture, &e.CreatedAt, &e.UpdatedAt, &e.ResticPwSHA256) if err == sql.ErrNoRows { return nil, nil } @@ -1509,6 +1520,35 @@ func (s *Store) GetHostEscrow(hostID string) (*HostEscrow, error) { return &e, nil } +// EscrowStatus (SLICE 3) is the non-secret escrow summary served in the report ACK so the controller can +// VERIFY-and-auto-confirm: not "a blob exists" but "the blob covers the CURRENT repo password" (hash match). +type EscrowStatus struct { + IdentityBlobPresent bool `json:"identity_blob_present"` + ResticPwSHA256 string `json:"restic_pw_sha256,omitempty"` + CreatedAt string `json:"created_at,omitempty"` +} + +// GetEscrowStatusForCustomer returns the escrow status of the customer's host (nil if the customer has no +// escrow row). With multiple hosts (not the current model), the most recently updated escrow wins. +func (s *Store) GetEscrowStatusForCustomer(customerID string) (*EscrowStatus, error) { + var st EscrowStatus + var identityPresent int + err := s.db.QueryRow(` + SELECT (e.identity_blob IS NOT NULL), COALESCE(e.restic_pw_sha256, ''), e.created_at + FROM host_escrow e JOIN hosts h ON h.host_id = e.host_id + WHERE h.customer_id = ? + ORDER BY e.updated_at DESC LIMIT 1`, customerID). + Scan(&identityPresent, &st.ResticPwSHA256, &st.CreatedAt) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, err + } + st.IdentityBlobPresent = identityPresent == 1 + return &st, nil +} + // SetHostDesired sets a host's desired-state JSON and ATOMICALLY bumps its desired_generation // (slice 10A — the operator "admin-set" write). Returns the NEW generation. The generation is // the cheap change-signal carried on every heartbeat envelope; the agent re-fetches the full diff --git a/hub/internal/web/hosts_test.go b/hub/internal/web/hosts_test.go index 514341d..f7eafd0 100644 --- a/hub/internal/web/hosts_test.go +++ b/hub/internal/web/hosts_test.go @@ -130,7 +130,7 @@ func TestHandleHostDetail(t *testing.T) { t.Fatal(err) } // DR + escrow present (escrow row must exist before the DR bundle UPDATE). - if err := st.SaveHostEscrow("demo-felhom-01", []byte("opaque-escrow"), "fp", "posture", "2026-06-01T00:00:00Z"); err != nil { + if err := st.SaveHostEscrow("demo-felhom-01", []byte("opaque-escrow"), "fp", "posture", "2026-06-01T00:00:00Z", ""); err != nil { t.Fatal(err) } if err := st.SaveHostDRBundle("demo-felhom-01", []byte("opaque-identity"), `{"v":1}`); err != nil {