From 78ee33a36d59da21f541674e99afcd0dfe2b1846 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Mon, 13 Jul 2026 12:36:54 -0400 Subject: [PATCH] feat(api): operator-or-SPIFFE auth on /v0 + node-pubkey enrollment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The operator credential (2-of-7 sohocloud/operator/v0 transmission) was verified nowhere on the coordination wire: OperatorAuth was built but mounted on no route, and /v0 was gated only by RequireSPIFFE+bindNodeID — which requires the caller to hold a per-node SVID matching each message's node_id. That is incompatible with the Cloudy-as-operator model, where one front end relays many members' nodes as a single authenticated operator. Add api.OperatorOrSPIFFE, a selector chosen by the presence of the X-SohoCloud-Operator header: - header present -> OperatorAuth (2-of-7 transmission); the operator-relay path, no per-node SVID. - header absent -> the existing RequireSPIFFE+bindNodeID path, for direct/satellite nodes. Neither path is downgradable to the other. Node authenticity in BOTH paths comes from each message's own ed25519 signature, which the adapter already verifies against node_protocol_keys — so dropping SPIFFE from the operator path loses no node identity. - /v0 node-side subtree: selector injected from cmd/orchestrator (protocoladapter gains a param so it needs no api import; nil gate = prior SPIFFE-only). - POST /nodes/pubkey: an admitted operator may enroll its members' node keys (first-write-wins still prevents overwrite); a direct node still binds its own SVID. Handler branches on OperatorFromContext. - api.New gains (opVerifier, coordinatorID); both nil/absent => SPIFFE-only. Signature changes swept: api.New (1 caller), protocoladapter.NewHandler (1 caller + 4 tests). Build + vet clean with and without the integration tag. New api unit tests cover the selector's three arms (no header -> SPIFFE; valid header -> operator; malformed header -> hard 401, no downgrade). CONFORMANCE.md updated: frontend-as-operator auth moves from design-target to built-on-the-wire, with the honest residual that no operator has enrolled end-to-end yet (blocked on the 2FA mail transport). Signed-off-by: Jodson Graves --- CONFORMANCE.md | 2 +- cmd/orchestrator/main.go | 12 +++- internal/api/nodes.go | 26 ++++++--- internal/api/operator_auth.go | 27 +++++++++ internal/api/operator_selector_test.go | 74 ++++++++++++++++++++++++ internal/api/server.go | 17 +++++- internal/protocoladapter/adapter_test.go | 8 +-- internal/protocoladapter/handler.go | 18 +++++- 8 files changed, 165 insertions(+), 19 deletions(-) create mode 100644 internal/api/operator_selector_test.go diff --git a/CONFORMANCE.md b/CONFORMANCE.md index 19141b5..9af52ce 100644 --- a/CONFORMANCE.md +++ b/CONFORMANCE.md @@ -31,7 +31,7 @@ SoHoLINK is the **coordinator** of a JFA **substrate**: node-side orchestration - **Transitional member surfaces.** The node agent, member portal, `participants` table, and installer are front-end-owned capabilities hosted here from the dual-role era, pending migration. They are labeled, kept working, and never described as the coordinator's long-term role. - **Coordinator federation is a design target, not built.** Witnessed checkpoints (the certificate-transparency model reserved in the record layer and the protocol's `anchor/` stub) are the named target for cross-coordinator non-equivocation. Until then, this is a single-coordinator deployment: the architecture's open problem 7 — sovereign compute buys mechanical, not economic, exit — applies in full and is named, not solved. -- **Frontend-as-operator authentication is a design target** (rotating key set, signed transmissions); not implemented here yet. +- **Frontend-as-operator authentication — BUILT on the coordination wire.** The `/v0` node-side surface and node-pubkey enrollment now accept a 2-of-7 operator transmission (rotating seven-key set, `X-SohoCloud-Operator` header, per-`(operator,coordinator)` replay window) via an operator-or-SPIFFE selector: the header selects the operator path; its absence keeps the existing SPIFFE path for direct/satellite nodes. Node authenticity in both paths comes from each message's own ed25519 signature (verified against `node_protocol_keys`), so the operator-relay path adds no per-node SVID. Check: `OperatorAuth`/`OperatorOrSPIFFE` unit tests; conformance harness Suite B. Residual: no operator has completed enrollment end-to-end yet (blocked on the 2FA mail transport), so the path is verified in test, not yet in production. - **Fiat settlement counterparty** (member's payout identity vs. front end) is a deliberately open question; either answer stays fiat-side, never conflated with member credit. - **DCO enforcement is suspended as a named interim (2026-07-12).** The `dco.yml` workflow is disabled — not deleted — for the stack-establishment push, by decision of the steward. Per the architecture's provenance standard: the covenant itself never suspends (contributions still enter the AGPL commons inbound = outbound, Contributor Covenant unaffected); reinstatement is committed and is one action (`gh workflow enable DCO`); and the gap is honest — commits made during the window cannot be retroactively certified, so the window is kept short and its start and end are matters of record. Agent-authored commits continue to carry `Signed-off-by` throughout the window regardless. diff --git a/cmd/orchestrator/main.go b/cmd/orchestrator/main.go index 1950de6..a80cf73 100644 --- a/cmd/orchestrator/main.go +++ b/cmd/orchestrator/main.go @@ -154,9 +154,17 @@ func main() { // carries its own SPIFFE middleware and mirrors degraded mode. repo := operator.NewRepository(db.Pool) adapter := protocoladapter.New(db, registry, repo, coordinatorID, coordKey, tokenSecret) - protocolV0 := protocoladapter.NewHandler(adapter, idSource, idSource == nil) + // /v0 accepts EITHER an operator transmission (Cloudy-as-operator relays its + // members' nodes) OR a direct node's SPIFFE SVID — selected by the operator + // header. The selector lives in the api package; injected here so + // protocoladapter needs no api import. Node authenticity in both paths comes + // from each message's own signature (the adapter verifies it). + v0Gate := func(bare, spiffeGated http.Handler) http.Handler { + return api.OperatorOrSPIFFE(repo, coordinatorID, spiffeGated, bare) + } + protocolV0 := protocoladapter.NewHandler(adapter, idSource, idSource == nil, v0Gate) - srv := api.New(db, registry, idSource, apiAddr, metricsAddr, allowlistPath, protocolV0) + srv := api.New(db, registry, idSource, apiAddr, metricsAddr, allowlistPath, protocolV0, repo, coordinatorID) internalSrv := api.NewInternal(orch, internalAddr) go func() { diff --git a/internal/api/nodes.go b/internal/api/nodes.go index f88650c..bc16c3e 100644 --- a/internal/api/nodes.go +++ b/internal/api/nodes.go @@ -742,15 +742,23 @@ func handleRegisterNodePubkey(db *store.DB) http.HandlerFunc { writeError(w, http.StatusBadRequest, "node_id is required") return } - // Canonical SPIFFE binding guard (TODO 34/35 form). - spiffeID, ok := identity.SPIFFEIDFromContext(r.Context()) - if !ok { - writeError(w, http.StatusUnauthorized, "no SPIFFE identity in context") - return - } - if spiffeID.Path() != "/node/"+req.NodeID { - writeError(w, http.StatusForbidden, "SPIFFE identity does not match node") - return + // Auth is one of two shapes, selected by OperatorOrSPIFFE upstream: + // - operator-authenticated (VerifiedOperator in context): an admitted + // operator enrolls keys on behalf of its members' nodes (operator-relay + // model). No per-node SPIFFE SVID exists; the operator vouches, and the + // FIRST-WRITE-WINS insert below still prevents any silent overwrite. + // - SPIFFE (direct/satellite node): enforce the canonical TODO 34/35 + // binding — the caller's SVID must be exactly this node_id. + if _, viaOperator := OperatorFromContext(r.Context()); !viaOperator { + spiffeID, ok := identity.SPIFFEIDFromContext(r.Context()) + if !ok { + writeError(w, http.StatusUnauthorized, "no SPIFFE identity in context") + return + } + if spiffeID.Path() != "/node/"+req.NodeID { + writeError(w, http.StatusForbidden, "SPIFFE identity does not match node") + return + } } algo := req.Algo diff --git a/internal/api/operator_auth.go b/internal/api/operator_auth.go index d9f2efd..c52ed6f 100644 --- a/internal/api/operator_auth.go +++ b/internal/api/operator_auth.go @@ -221,3 +221,30 @@ func EncodeOperatorHeader(tx operator.OperatorTransmission) string { jsonBytes, _ := json.Marshal(wire) //nolint:errcheck // wire has no unmarshalable fields return base64.StdEncoding.EncodeToString(jsonBytes) } + +// OperatorOrSPIFFE gates a route by EITHER a valid operator transmission OR a +// SPIFFE SVID, selected solely by the presence of the OperatorHeader: +// +// - header present -> OperatorAuth(operatorNext): the caller authenticates +// as an OPERATOR (2-of-7 transmission). This is the operator-relay model, +// where one front end relays many members' nodes and per-node SPIFFE SVIDs +// do not exist; each message's OWN ed25519 signature carries node +// authenticity downstream (the adapter verifies it against node_protocol_keys). +// - header absent -> spiffeGated: the existing RequireSPIFFE path, for +// direct/satellite nodes that hold their own SVID. +// +// Neither path is downgradable to the other. Presence of the header alone +// selects the operator path, and OperatorAuth then HARD-rejects a +// malformed/invalid/expired/replayed transmission (401) — it never falls back +// to SPIFFE. Absence never invokes OperatorAuth. An attacker therefore cannot +// strip the header to reach a weaker authenticator, nor add one to bypass SPIFFE. +func OperatorOrSPIFFE(repo operatorVerifier, coordinatorID string, spiffeGated, operatorNext http.Handler) http.Handler { + operatorPath := OperatorAuth(repo, coordinatorID, operatorNext) + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get(OperatorHeader) != "" { + operatorPath.ServeHTTP(w, r) + return + } + spiffeGated.ServeHTTP(w, r) + }) +} diff --git a/internal/api/operator_selector_test.go b/internal/api/operator_selector_test.go new file mode 100644 index 0000000..f14d548 --- /dev/null +++ b/internal/api/operator_selector_test.go @@ -0,0 +1,74 @@ +package api + +import ( + "net/http" + "testing" +) + +// sentinel flips *ran and writes 200, so a test can tell which arm of the +// selector handled the request. +func sentinel(ran *bool) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *ran = true + w.WriteHeader(http.StatusOK) + }) +} + +// No operator header => the SPIFFE arm handles it and the operator verifier is +// never touched. +func TestOperatorOrSPIFFE_NoHeader_UsesSPIFFEPath(t *testing.T) { + fv := &fakeVerifier{} + var opRan, spiffeRan bool + h := OperatorOrSPIFFE(fv, "soholink", sentinel(&spiffeRan), sentinel(&opRan)) + + rec := doRequest(t, h, "") + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d", rec.Code) + } + if !spiffeRan { + t.Fatal("header-less request must take the SPIFFE arm") + } + if opRan { + t.Fatal("operator arm must not run without the header") + } + if fv.getKeyMapCalls != 0 { + t.Errorf("operator verifier must not be consulted on the SPIFFE arm, got %d calls", fv.getKeyMapCalls) + } +} + +// A valid operator header => the operator arm handles it; the SPIFFE arm is not +// consulted. +func TestOperatorOrSPIFFE_ValidHeader_UsesOperatorPath(t *testing.T) { + privs, km := testKeyset(t) + fv := &fakeVerifier{km: km} + var opRan, spiffeRan bool + h := OperatorOrSPIFFE(fv, "soholink", sentinel(&spiffeRan), sentinel(&opRan)) + + tx := signedTransmission(t, privs, "cloudy", 1, 0, 3) + rec := doRequest(t, h, EncodeOperatorHeader(tx)) + if rec.Code != http.StatusOK { + t.Fatalf("want 200, got %d (%s)", rec.Code, rec.Body.String()) + } + if !opRan { + t.Fatal("valid transmission must take the operator arm") + } + if spiffeRan { + t.Fatal("SPIFFE arm must not run when the operator header is present") + } +} + +// A present-but-malformed header => hard 401. It must NOT fall through to +// either arm — no downgrade to SPIFFE, no reaching the protected handler. +func TestOperatorOrSPIFFE_InvalidHeader_RejectsNoDowngrade(t *testing.T) { + fv := &fakeVerifier{} + var opRan, spiffeRan bool + h := OperatorOrSPIFFE(fv, "soholink", sentinel(&spiffeRan), sentinel(&opRan)) + + rec := doRequest(t, h, "!!!not-base64-json!!!") + if rec.Code != http.StatusUnauthorized { + t.Fatalf("want 401 for a malformed operator header, got %d", rec.Code) + } + if opRan || spiffeRan { + t.Fatal("present-but-invalid header must reject, never fall through to either arm") + } +} diff --git a/internal/api/server.go b/internal/api/server.go index caf177e..660c282 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -41,7 +41,11 @@ type APIServer struct { // SPIFFE-protected routes return 503, and /health reports // "identity":"unavailable". This happens when the SPIRE Workload API socket // cannot be reached at startup. See TODO 12. -func New(db *store.DB, registry *orchestrator.NodeRegistry, idSource *identity.Source, addr string, metricsAddr string, allowlistPath string, protocolV0 http.Handler) *APIServer { +// opVerifier + coordinatorID enable operator-authenticated access to routes +// that also accept SPIFFE (currently node-pubkey enrollment). Both nil (or a +// degraded idSource) leaves those routes SPIFFE-only — the pre-operator +// behavior — so the parameters are additive and backward compatible. +func New(db *store.DB, registry *orchestrator.NodeRegistry, idSource *identity.Source, addr string, metricsAddr string, allowlistPath string, protocolV0 http.Handler, opVerifier operatorVerifier, coordinatorID string) *APIServer { // authMux: all routes that require a valid SPIFFE SVID. authMux := http.NewServeMux() registerNodeRoutes(authMux, db, registry) @@ -54,6 +58,17 @@ func New(db *store.DB, registry *orchestrator.NodeRegistry, idSource *identity.S if protocolV0 != nil { top.Handle("/v0/", protocolV0) } + // Node-pubkey enrollment accepts EITHER an operator transmission (an admitted + // operator enrolling its members' node keys) OR a node's own SPIFFE SVID. + // The exact method+path pattern is more specific than the "/" subtree below, + // so it shadows the SPIFFE-only registration in authMux. Wired only when an + // operator verifier and a live SPIFFE source both exist; otherwise pubkey + // enrollment stays SPIFFE-only via authMux (backward compatible). + if opVerifier != nil && idSource != nil { + pubkey := http.HandlerFunc(handleRegisterNodePubkey(db)) + spiffePubkey := identity.RequireSPIFFE(idSource.BundleSource(), pubkey) + top.Handle("POST /nodes/pubkey", OperatorOrSPIFFE(opVerifier, coordinatorID, spiffePubkey, pubkey)) + } if idSource != nil { top.Handle("/", identity.RequireSPIFFE(idSource.BundleSource(), authMux)) } else { diff --git a/internal/protocoladapter/adapter_test.go b/internal/protocoladapter/adapter_test.go index 4203055..936573c 100644 --- a/internal/protocoladapter/adapter_test.go +++ b/internal/protocoladapter/adapter_test.go @@ -156,7 +156,7 @@ func TestAdapter_Bind(t *testing.T) { func TestHandler_Fees_NotPublished404(t *testing.T) { a := New(nil, orchestrator.NewNodeRegistry(), stubFees{err: operator.ErrNoFeeDeclaration}, "soholink", nil, nil) - h := NewHandler(a, nil, true) // degraded: exercises only the plain /v0/fees route (no bundle needed) + h := NewHandler(a, nil, true, nil) // degraded: exercises only the plain /v0/fees route (no bundle needed) r := httptest.NewRequest(http.MethodGet, "/v0/fees", nil) w := httptest.NewRecorder() @@ -177,7 +177,7 @@ func TestHandler_Fees_ServesSignedDeclaration(t *testing.T) { decl.Sign(priv) a := New(nil, orchestrator.NewNodeRegistry(), stubFees{decl: decl}, "soholink", nil, nil) - h := NewHandler(a, nil, true) // degraded: exercises only the plain /v0/fees route (no bundle needed) + h := NewHandler(a, nil, true, nil) // degraded: exercises only the plain /v0/fees route (no bundle needed) r := httptest.NewRequest(http.MethodGet, "/v0/fees", nil) w := httptest.NewRecorder() @@ -197,7 +197,7 @@ func TestHandler_Fees_ServesSignedDeclaration(t *testing.T) { func TestHandler_Fees_ServedPlainInDegradedMode(t *testing.T) { a := New(nil, orchestrator.NewNodeRegistry(), stubFees{err: operator.ErrNoFeeDeclaration}, "soholink", nil, nil) - h := NewHandler(a, nil, true) + h := NewHandler(a, nil, true, nil) // Fees stays reachable (404 = honest "nothing published", not 503)... r := httptest.NewRequest(http.MethodGet, "/v0/fees", nil) @@ -221,7 +221,7 @@ func TestHandler_Fees_ServedPlainInDegradedMode(t *testing.T) { // bundle is ever consulted). func TestHandler_ProtectedRoutesRequireTLS(t *testing.T) { a := New(nil, orchestrator.NewNodeRegistry(), stubFees{}, "soholink", nil, nil) - h := NewHandler(a, nil, false) // NON-degraded: the protected subtree must return 401 (not 503) without mTLS + h := NewHandler(a, nil, false, nil) // NON-degraded: the protected subtree must return 401 (not 503) without mTLS // Plain HTTP request (no TLS peer certificate) → RequireSPIFFE 401. r := httptest.NewRequest(http.MethodPost, "/v0/heartbeat", nil) diff --git a/internal/protocoladapter/handler.go b/internal/protocoladapter/handler.go index ae1d065..a824ece 100644 --- a/internal/protocoladapter/handler.go +++ b/internal/protocoladapter/handler.go @@ -26,7 +26,15 @@ import ( // degraded (SPIRE Workload API unreachable at startup, nil idSource) serves // 503 on the protected subtree while fees stay reachable — mirroring the // bespoke server's degraded posture. -func NewHandler(a *Adapter, idSource *identity.Source, degraded bool) http.Handler { +// gate, when non-nil, composes the transport authenticator for the node-side +// /v0 subtree from its two ingredients: `bare` (the reference handler with NO +// transport auth — node authenticity comes from each message's own signature, +// which the adapter verifies) and `spiffeGated` (the RequireSPIFFE+bindNodeID +// path for direct/satellite nodes). It exists so the operator-or-SPIFFE +// selection (which lives in the api package) is injected from cmd/orchestrator, +// keeping this package free of an api import. A nil gate preserves the +// pre-operator behavior: SPIFFE-only. +func NewHandler(a *Adapter, idSource *identity.Source, degraded bool, gate func(bare, spiffeGated http.Handler) http.Handler) http.Handler { top := http.NewServeMux() top.HandleFunc("GET /v0/fees", func(w http.ResponseWriter, r *http.Request) { @@ -55,6 +63,12 @@ func NewHandler(a *Adapter, idSource *identity.Source, degraded bool) http.Handl if idSource != nil { bundle = idSource.BundleSource() } - top.Handle("/v0/", identity.RequireSPIFFE(bundle, bindNodeID(httpjson.Handler(a)))) + bare := httpjson.Handler(a) + spiffeGated := identity.RequireSPIFFE(bundle, bindNodeID(bare)) + if gate != nil { + top.Handle("/v0/", gate(bare, spiffeGated)) + } else { + top.Handle("/v0/", spiffeGated) + } return top }