hub v0.40.0: store escrow restic_pw_sha256 + serve escrow status in the report ACK (SLICE 3)

Additive host_escrow migration; SaveHostEscrow/HostEscrow gain the hash
(NULL-safe for legacy rows); GetEscrowStatusForCustomer joins hosts;
the report ACK gains escrow:{identity_blob_present,restic_pw_sha256,
created_at} (omitted without a row). Contract test mirrors the agent's
v0.79.0 emit struct.

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:10:29 +02:00
parent e0d1733b85
commit 49d1233391
6 changed files with 147 additions and 11 deletions
+1 -1
View File
@@ -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.
+69 -2
View File
@@ -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())
}
}
+12 -1
View File
@@ -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