Files
felhom.eu/hub/internal/api/escrow_test.go
T
admin 7eb3772000 hub: opaque PBS recovery-code escrow storage (v0.8.0) + doc 03 §8a posture model
Slice-7 close-out (hub half). PUT /api/v1/hosts/{host_id}/escrow (per-host key)
stores the agent's OPAQUE R-wrapped blob verbatim against the host; the hub never
decrypts it (no recovery code, no decrypt path). host_escrow table + Save/GetHostEscrow.
Tests: verbatim store, rotation last-write-wins, 401/403/400 auth+body, wire contract.

doc 03 §8a rewritten into the key-custody posture model: separation principle,
topology matrix, default + anti-lockout ladder, SSH-vs-key, breach/legal, integrity
caveat. Corrected: hub opaque storage is slice 7 (this task); serving is slice 10.
Slice table + §13 updated.

No secrets committed (R/K never appear; spike findings + docs use placeholders).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 07:46:33 +02:00

112 lines
4.2 KiB
Go

package api
import (
"encoding/base64"
"encoding/json"
"net/http"
"reflect"
"sort"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// a stand-in for the opaque R-wrapped blob — the hub treats it as ciphertext it cannot read.
var opaqueBlob = []byte("\x00\x01OPAQUE-pbs-scrypt-keyfile-bytes\xff\xfe")
func escrowBody(blob []byte) string {
b, _ := json.Marshal(map[string]string{
"blob_b64": base64.StdEncoding.EncodeToString(blob),
"key_fingerprint": "ab:cd:ef",
"posture": "zero_knowledge",
"created_at": "2026-06-10T05:00:00Z",
})
return string(b)
}
func TestHandleHostEscrow_StoresOpaqueBlobVerbatim(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody(opaqueBlob))
if rr.Code != http.StatusOK {
t.Fatalf("PUT escrow = %d, want 200 (%s)", rr.Code, rr.Body.String())
}
got, err := st.GetHostEscrow("h1")
if err != nil || got == nil {
t.Fatalf("GetHostEscrow: %v / %v", got, err)
}
// the hub stored the OPAQUE bytes verbatim (it never decrypts / transforms them).
if !reflect.DeepEqual(got.Blob, opaqueBlob) {
t.Fatalf("stored blob != uploaded blob (hub must keep ciphertext verbatim)")
}
if got.KeyFingerprint != "ab:cd:ef" || got.Posture != "zero_knowledge" {
t.Errorf("metadata not stored: %+v", got)
}
}
func TestHandleHostEscrow_LastWriteWins(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("first")))
rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", escrowBody([]byte("second-rotated")))
if rr.Code != http.StatusOK {
t.Fatalf("rotation PUT = %d", rr.Code)
}
got, _ := st.GetHostEscrow("h1")
if string(got.Blob) != "second-rotated" {
t.Fatalf("rotation must be last-write-wins, got %q", got.Blob)
}
}
func TestHandleHostEscrow_AuthRejected(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
st.UpsertHost(&store.Host{HostID: "h2", CustomerID: "c2", APIKey: "HKEY2"})
// absent / wrong key → 401
if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "", escrowBody(opaqueBlob)); rr.Code != http.StatusUnauthorized {
t.Errorf("no key: got %d want 401", rr.Code)
}
if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "WRONG", escrowBody(opaqueBlob)); rr.Code != http.StatusUnauthorized {
t.Errorf("wrong key: got %d want 401", rr.Code)
}
// h2's key writing h1's escrow → 403 (a host may only write its own)
if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY2", escrowBody(opaqueBlob)); rr.Code != http.StatusForbidden {
t.Errorf("host_id mismatch: got %d want 403", rr.Code)
}
// and nothing was stored for h1 by the rejected attempts.
if got, _ := st.GetHostEscrow("h1"); got != nil {
t.Errorf("rejected attempts must not store anything, got %+v", got)
}
}
func TestHandleHostEscrow_BadBody(t *testing.T) {
h, st, _ := newTestHandler(t)
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", `{"blob_b64":""}`); rr.Code != http.StatusBadRequest {
t.Errorf("empty blob: got %d want 400", rr.Code)
}
if rr := do(h, http.MethodPut, "/hosts/h1/escrow", "HKEY", `{"blob_b64":"!!!not base64!!!"}`); rr.Code != http.StatusBadRequest {
t.Errorf("bad base64: got %d want 400", rr.Code)
}
}
// TestEscrowUploadContract pins the wire shape that MUST match the agent's emit struct
// (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"})
var m map[string]any
json.Unmarshal(b, &m)
got := make([]string, 0, len(m))
for k := range m {
got = append(got, k)
}
sort.Strings(got)
want := []string{"blob_b64", "created_at", "key_fingerprint", "posture"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("escrow wire contract drift: got %v want %v (must match the agent emit struct)", got, want)
}
}