package api import ( "encoding/base64" "encoding/json" "net/http" "testing" "time" "gitea.dooplex.hu/admin/felhom-hub/internal/notify" "gitea.dooplex.hu/admin/felhom-hub/internal/store" ) // R-199 (hub v0.94.0) — the box-authenticated retrieval of a host's OWN sealed identity blob. // This is a new surface on the most sensitive object in the system; these tests exist to pin the // three properties that make it defensible: it is self-scoped, it is honest when there is nothing to // serve, and it is never silent. func seedEscrowedHost(t *testing.T, st *store.Store, hostID, customerID, apiKey string, identity []byte) { t.Helper() if err := st.UpsertHost(&store.Host{HostID: hostID, CustomerID: customerID, APIKey: apiKey}); err != nil { t.Fatal(err) } if _, _, err := st.SaveHostEscrow(hostID, []byte("k-escrow"), "fp", "zero_knowledge", "2026-08-04T11:00:00Z", "SHA"); err != nil { t.Fatal(err) } if len(identity) > 0 { if err := st.SaveHostDRBundle(hostID, identity, `{}`); err != nil { t.Fatal(err) } } } // The happy path: a host reads back exactly the bytes it uploaded, verbatim. func TestEscrowGet_ServesOwnBlobVerbatim(t *testing.T) { h, st, _ := newTestHandler(t) identity := []byte("\x00\x01age-wrapped-identity-bundle\xff") seedEscrowedHost(t, st, "h1", "c1", "HKEY", identity) rr := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", "") if rr.Code != http.StatusOK { t.Fatalf("GET escrow = %d, want 200 (%s)", rr.Code, rr.Body.String()) } var out struct { HostID string `json:"host_id"` Present bool `json:"present"` B64 string `json:"identity_escrow_b64"` } if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { t.Fatal(err) } if !out.Present || out.HostID != "h1" { t.Fatalf("unexpected envelope: %+v", out) } got, err := base64.StdEncoding.DecodeString(out.B64) if err != nil { t.Fatal(err) } if string(got) != string(identity) { t.Fatal("the served blob is not the stored blob — the hub must return ciphertext verbatim") } } // Scenario C — a box can fetch ONLY its own blob. // RED-PROOF: delete the `!isGlobal && authHostID != pathHostID` check in handleHostEscrowGet → // cross-host retrieval succeeds → this FAILS. Without that line, any host key is a fleet-wide reader // of every customer's sealed bundle. func TestEscrowGet_CrossHostRefused(t *testing.T) { h, st, _ := newTestHandler(t) seedEscrowedHost(t, st, "hostA", "custA", "KEY-A", []byte("A-identity")) seedEscrowedHost(t, st, "hostB", "custB", "KEY-B", []byte("B-identity-SECRET")) rr := do(h, http.MethodGet, "/hosts/hostB/escrow", "KEY-A", "") if rr.Code != http.StatusForbidden { t.Fatalf("host A reading host B's blob = %d, want 403 — a cross-host read of a sealed bundle "+ "must be impossible on every code path (body: %s)", rr.Code, rr.Body.String()) } if b := rr.Body.String(); len(b) > 0 && (contains(b, "B-identity") || contains(b, base64.StdEncoding.EncodeToString([]byte("B-identity-SECRET")))) { t.Fatal("the refusal body leaked the other host's blob") } // Unauthenticated is refused too, and does not leak which hosts exist. if rr := do(h, http.MethodGet, "/hosts/hostB/escrow", "", ""); rr.Code != http.StatusUnauthorized { t.Fatalf("unauthenticated GET = %d, want 401", rr.Code) } // The global operator key MAY read any — the same asymmetry the PUT has. if rr := do(h, http.MethodGet, "/hosts/hostB/escrow", globalKey, ""); rr.Code != http.StatusOK { t.Fatalf("global key GET = %d, want 200", rr.Code) } } func contains(hay, needle string) bool { return len(needle) > 0 && len(hay) >= len(needle) && (func() bool { for i := 0; i+len(needle) <= len(hay); i++ { if hay[i:i+len(needle)] == needle { return true } } return false })() } // Scenario D — a host with no sealed bundle gets a CLEAN answer: 200 with present=false. Not a 404 // (indistinguishable from an unknown host), not an empty 200 without the flag (indistinguishable // from a zero-length blob). Both of those read as a fault to a caller whose situation is simply // "no ceremony has run yet". func TestEscrowGet_NoBlobIsCleanNone(t *testing.T) { h, st, _ := newTestHandler(t) seedEscrowedHost(t, st, "h1", "c1", "HKEY", nil) // K-escrow only, no identity blob rr := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", "") if rr.Code != http.StatusOK { t.Fatalf("no-blob GET = %d, want 200 with present=false", rr.Code) } var out struct { Present bool `json:"present"` B64 string `json:"identity_escrow_b64"` } json.Unmarshal(rr.Body.Bytes(), &out) if out.Present || out.B64 != "" { t.Fatalf("a host with no bundle must report present=false and no bytes, got %+v", out) } // An unknown host is a DIFFERENT answer — the two must not collapse into one. if rr := do(h, http.MethodGet, "/hosts/nope/escrow", globalKey, ""); rr.Code != http.StatusNotFound { t.Fatalf("unknown host = %d, want 404 (distinct from a known host with no bundle)", rr.Code) } } // Scenario E — every successful retrieval leaves a durable record naming the host. // RED-PROOF: remove the SaveEvent call in handleHostEscrowGet → no event → this FAILS. A silent // capability on this object is the whole reason §8.2's trade is acceptable at all. func TestEscrowGet_LeavesARecord(t *testing.T) { h, st, _ := newTestHandler(t) seedEscrowedHost(t, st, "h1", "c1", "HKEY", []byte("age-wrapped")) if rr := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", ""); rr.Code != http.StatusOK { t.Fatalf("GET = %d", rr.Code) } ev, err := st.GetLatestEventByType("c1", eventEscrowBlobServed) if err != nil { t.Fatal(err) } if ev == nil { t.Fatal("R-199: a sealed recovery blob was served and NOTHING recorded it — the audit row is " + "the mitigation that makes this endpoint's trade acceptable") } if ev.Severity != "warning" { t.Errorf("severity = %q, want warning: info is an intentional non-notify, so the operator would "+ "never hear that the recovery path was used", ev.Severity) } if !contains(ev.Message, "h1") { t.Errorf("the record must name the host, got %q", ev.Message) } // The record must not carry the blob itself — it is an audit row, not a second copy. if contains(ev.Message, "age-wrapped") || contains(ev.DetailsJSON, "age-wrapped") || contains(ev.DetailsJSON, base64.StdEncoding.EncodeToString([]byte("age-wrapped"))) { t.Errorf("the audit record embedded the blob: msg=%q details=%q", ev.Message, ev.DetailsJSON) } // A "no blob" answer is NOT a retrieval and must not raise one. seedEscrowedHost(t, st, "h2", "c2", "KEY2", nil) do(h, http.MethodGet, "/hosts/h2/escrow", "KEY2", "") if ev, _ := st.GetLatestEventByType("c2", eventEscrowBlobServed); ev != nil { t.Fatal("a present=false answer served no blob and must raise no retrieval record") } } // Operator-tier by construction, registered in the same commit that mints the type. func TestEscrowBlobServed_IsOperatorOnly(t *testing.T) { if !notify.IsOperatorOnly(eventEscrowBlobServed) { t.Fatalf("%s is not registered operator-only — a customer would receive operator-grade English "+ "about their sealed recovery bundle being handed out", eventEscrowBlobServed) } } // Scenario G — the operator-driven DR path is UNTOUCHED: same gate, same behaviour. The new sibling // must not loosen it, and must not share it. func TestEscrowGet_OperatorDRPathUnchanged(t *testing.T) { h, st, _ := newTestHandler(t) seedEscrowedHost(t, st, "h1", "c1", "HKEY", []byte("age-wrapped")) // re-enroll without recovery mode: still refused. if rr := do(h, http.MethodPost, "/hosts/h1/re-enroll", "HKEY", `{"new_api_key":"x"}`); rr.Code != http.StatusForbidden { t.Fatalf("re-enroll without recovery mode = %d, want 403 — the new endpoint must not loosen it", rr.Code) } // restore-directive without recovery mode: still refused. if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusForbidden { t.Fatalf("restore-directive without recovery mode = %d, want 403", rr.Code) } // …and arming it still works, i.e. the old path is functional, not merely closed. if err := st.SetRecoveryMode("h1", time.Now().UTC().Add(10*time.Minute)); err != nil { t.Fatal(err) } if rr := do(h, http.MethodGet, "/hosts/h1/restore-directive", "HKEY", ""); rr.Code != http.StatusOK { t.Fatalf("restore-directive WITH recovery mode = %d, want 200", rr.Code) } // The new endpoint does NOT depend on recovery mode (that is the §8.2 trade, made explicit here so // a future change to escrowSelfServiceRetrieval is visible as a test change). if err := st.ClearRecoveryMode("h1"); err != nil { t.Fatal(err) } if rr := do(h, http.MethodGet, "/hosts/h1/escrow", "HKEY", ""); rr.Code != http.StatusOK { t.Fatalf("self-service escrow GET with recovery mode OFF = %d, want 200 (escrowSelfServiceRetrieval=true)", rr.Code) } }