Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CONFORMANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
12 changes: 10 additions & 2 deletions cmd/orchestrator/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
26 changes: 17 additions & 9 deletions internal/api/nodes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions internal/api/operator_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
74 changes: 74 additions & 0 deletions internal/api/operator_selector_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
17 changes: 16 additions & 1 deletion internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
8 changes: 4 additions & 4 deletions internal/protocoladapter/adapter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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)
Expand All @@ -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)
Expand Down
18 changes: 16 additions & 2 deletions internal/protocoladapter/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
}