435f4a5229
gates / gates (push) Successful in 7s
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.
204 lines
8.7 KiB
Go
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)
|
|
}
|
|
}
|