Files
felhom.eu/hub/internal/api/escrow_get_test.go
T
admin 435f4a5229
gates / gates (push) Successful in 7s
hub v0.94.0: a box can fetch its own sealed recovery package (R-199 link 6)
Link 6 of the recovery chain had no client. The hub has served the identity blob since
slice 10D from handleReEnroll / handleGetRestoreDirective, gated on operator-armed recovery
mode and the global key -- and nothing in the agent, the hub UI, any script or any runbook
ever called either. The only documented retrieval was sqlite3 writefile() by hand on a
kubectl cp-ed database.

GET /api/v1/hosts/{host_id}/escrow is the box-authenticated mirror of the PUT that put the
blob there. Self-scoped (a per-host key reads only its own; global may read any). A host with
no bundle gets 200 {present:false} -- a 404 is indistinguishable from an unknown host and a
bare empty 200 from a zero-length blob.

THE TRADE IS RECORDED IN THE HANDLER, not inferred: obtaining the blob used to require the
operator to arm recovery mode; now whoever controls a rebuilt box can obtain it with that
box's own credential. They still cannot open it -- the hub has never held R and a wrong code
fails closed at age's scrypt KDF. The mitigation is that every retrieval raises
escrow_blob_served (warning, operator-only), recorded before the bytes leave.

escrowSelfServiceRetrieval is the single decision point: flip it to false and the endpoint
additionally requires recovery mode, changing nothing else.

The operator-driven DR path is untouched, pinned by a test. Red-proofs observed: removing the
ownership check serves host B's blob to host A; removing the record makes it silent.
2026-08-04 13:39:27 +02:00

204 lines
8.7 KiB
Go

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)
}
}