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>
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package api
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/subtle"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -124,6 +125,9 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.handleHostReport(w, r)
|
||||
case r.Method == http.MethodPost && path == "/admin/hosts":
|
||||
h.handleAdminCreateHost(w, r)
|
||||
case r.Method == http.MethodPut && strings.HasPrefix(path, "/hosts/") && strings.HasSuffix(path, "/escrow"):
|
||||
hostID := strings.TrimSuffix(strings.TrimPrefix(path, "/hosts/"), "/escrow")
|
||||
h.handleHostEscrowPut(w, r, hostID)
|
||||
case r.Method == http.MethodPost && path == "/event":
|
||||
h.handleEvent(w, r)
|
||||
case r.Method == http.MethodPost && path == "/notify":
|
||||
@@ -258,9 +262,9 @@ type hostReportPayload struct {
|
||||
ControllerVersion string `json:"controller_version"`
|
||||
} `json:"guests"`
|
||||
StorageTargets []hostStorageTarget `json:"storage_targets"`
|
||||
Backups []hostBackup `json:"backups"` // slice 6
|
||||
RestoreTests []hostRestoreTest `json:"restore_tests"` // slice 6
|
||||
PBSSnapshots []hostPBSSnapshot `json:"pbs_snapshots"` // slice 6 Phase B
|
||||
Backups []hostBackup `json:"backups"` // slice 6
|
||||
RestoreTests []hostRestoreTest `json:"restore_tests"` // slice 6
|
||||
PBSSnapshots []hostPBSSnapshot `json:"pbs_snapshots"` // slice 6 Phase B
|
||||
Cloudflared struct {
|
||||
Status string `json:"status"`
|
||||
} `json:"cloudflared"`
|
||||
@@ -569,6 +573,66 @@ func (h *Handler) handleAdminCreateHost(w http.ResponseWriter, r *http.Request)
|
||||
json.NewEncoder(w).Encode(map[string]string{"host_id": hostID, "api_key": apiKey})
|
||||
}
|
||||
|
||||
// escrowUploadRequest is the agent→hub wire shape for the OPAQUE PBS recovery-code escrow blob
|
||||
// (slice 7, doc 03 §8a). It MUST stay in lockstep with the agent's emit struct
|
||||
// (felhom-agent cmd/felhom-agent escrowUploadRequest). The hub stores the bytes and NEVER decrypts
|
||||
// them (it has no recovery code).
|
||||
type escrowUploadRequest struct {
|
||||
BlobB64 string `json:"blob_b64"` // base64 of the opaque R-wrapped blob (ciphertext)
|
||||
KeyFingerprint string `json:"key_fingerprint"` // for operator display only
|
||||
Posture string `json:"posture"` // e.g. "zero_knowledge"
|
||||
CreatedAt string `json:"created_at"` // RFC3339
|
||||
}
|
||||
|
||||
// handleHostEscrowPut stores a host's opaque escrow blob (doc 03 §8a). Authed with the PER-HOST key
|
||||
// (a host may only write its own escrow; the global operator key is also accepted). The hub keeps
|
||||
// the ciphertext and never opens it. Last-write-wins (rotation). No serving this slice (slice 10).
|
||||
func (h *Handler) handleHostEscrowPut(w http.ResponseWriter, r *http.Request, pathHostID string) {
|
||||
authHostID, _, isGlobal, ok := h.checkAuthHost(r)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
if pathHostID == "" {
|
||||
http.Error(w, "Missing host_id", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// A per-host key may only write ITS OWN escrow; the global key may write any.
|
||||
if !isGlobal && authHostID != pathHostID {
|
||||
http.Error(w, "Forbidden: host_id mismatch", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB cap; the blob is ~hundreds of bytes
|
||||
if err != nil {
|
||||
http.Error(w, "Bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
var req escrowUploadRequest
|
||||
if err := json.Unmarshal(body, &req); err != nil || req.BlobB64 == "" {
|
||||
http.Error(w, "Invalid payload: blob_b64 required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
blob, err := base64.StdEncoding.DecodeString(req.BlobB64)
|
||||
if err != nil || len(blob) == 0 {
|
||||
http.Error(w, "Invalid payload: blob_b64 not valid base64", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
createdAt := req.CreatedAt
|
||||
if createdAt == "" {
|
||||
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 {
|
||||
h.logger.Printf("[ERROR] Failed to store escrow for host %s: %v", pathHostID, err)
|
||||
http.Error(w, "Internal error", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
h.logger.Printf("[INFO] stored opaque escrow blob for host %s (%d bytes, posture=%s, fp=%s)",
|
||||
pathHostID, len(blob), req.Posture, req.KeyFingerprint)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"status":"ok"}`))
|
||||
}
|
||||
|
||||
// allowedEventTypes lists all valid event_type values the Hub accepts.
|
||||
var allowedEventTypes = map[string]bool{
|
||||
// Controller-pushed events
|
||||
|
||||
Reference in New Issue
Block a user