From 49e85f025e046732a40ee8a3f62cdffbf0ccbfb4 Mon Sep 17 00:00:00 2001 From: Jodson Graves Date: Mon, 13 Jul 2026 20:51:47 -0400 Subject: [PATCH] feat(opcred,coord): operator-transmission header for /v0 auth First increment of the Cloudy operator-enrollment client: the client side of the operator-auth contract SoHoLINK PR #16 established on /v0. opcred already signs the 2-of-7 transmission (TransmissionSigner) but leaves the transport encoding to the client. Add it: - opcred.EncodeOperatorHeader + OperatorHeaderName: base64(JSON) of the transmission in the X-SohoCloud-Operator header, matching the coordinators decoder (SoHoLINK api.operatorTransmissionWire) field-for-field. - coord.DialAsOperator + a fail-closed RoundTripper that attaches a fresh signed header to every /v0 request; the credential wiring is injected by the caller so coord imports no credential package. The cross-implementation contract is held by test, not a shared type: a header Cloudy signs+encodes decodes (via the coordinators exact wire shape) to a transmission the protocols own Verify accepts against the keyset. If that passes, SoHoLINK OperatorAuth (same decode + same Verify) accepts it too. Also asserts the snake_case field names and the fail-closed path (a decoration failure never sends the request). gofmt/build/vet clean; full module + import tripwire green. Next increments: the HTTP enrollment driver (apply -> keys -> verify 2FA -> conformance A/B/C -> active) reusing opcred Keyset + ConformanceResponder. Signed-off-by: Jodson Graves --- internal/coord/coord.go | 35 +++++++++++ internal/coord/operator_test.go | 100 ++++++++++++++++++++++++++++++++ internal/opcred/header.go | 55 ++++++++++++++++++ internal/opcred/header_test.go | 92 +++++++++++++++++++++++++++++ 4 files changed, 282 insertions(+) create mode 100644 internal/coord/operator_test.go create mode 100644 internal/opcred/header.go create mode 100644 internal/opcred/header_test.go diff --git a/internal/coord/coord.go b/internal/coord/coord.go index 463201d..cd36e3e 100644 --- a/internal/coord/coord.go +++ b/internal/coord/coord.go @@ -34,3 +34,38 @@ func Dial(baseURL string) *Client { }, } } + +// DialAsOperator returns a client that authenticates every request as an +// operator: decorate mutates each outgoing request, typically setting +// opcred.OperatorHeaderName to a freshly signed+encoded operator transmission +// (the coordinator's /v0 operator-auth seam). The credential wiring lives at +// the caller, so this package stays free of the opcred/protocol operator +// packages and models no policy. +func DialAsOperator(baseURL string, decorate func(*http.Request) error) *Client { + return &Client{ + Coordinator: &httpjson.Client{ + BaseURL: baseURL, + HTTP: &http.Client{ + Timeout: 30 * time.Second, + Transport: &operatorRoundTripper{base: http.DefaultTransport, decorate: decorate}, + }, + }, + } +} + +// operatorRoundTripper attaches per-request operator authentication before +// delegating to the base transport. It clones the request (a RoundTripper must +// not mutate its argument) and is fail-closed: if decoration fails the request +// is never sent, so a request can never go out unauthenticated. +type operatorRoundTripper struct { + base http.RoundTripper + decorate func(*http.Request) error +} + +func (t *operatorRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + r2 := req.Clone(req.Context()) + if err := t.decorate(r2); err != nil { + return nil, err + } + return t.base.RoundTrip(r2) +} diff --git a/internal/coord/operator_test.go b/internal/coord/operator_test.go new file mode 100644 index 0000000..4c2d5a5 --- /dev/null +++ b/internal/coord/operator_test.go @@ -0,0 +1,100 @@ +package coord + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/NTARI-RAND/Cloudy/internal/opcred" + "github.com/NTARI-RAND/sohocloud-protocol/operator" +) + +// decodeHeaderForTest mirrors the coordinator's wire decode so the test can +// verify the attached header the way SoHoLINK will. +func decodeHeaderForTest(t *testing.T, raw string) operator.OperatorTransmission { + t.Helper() + jb, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + t.Fatalf("header not base64: %v", err) + } + var w struct { + OperatorID string `json:"operator_id"` + TsUnixNano int64 `json:"ts_unix_nano"` + Nonce string `json:"nonce"` + Seq uint64 `json:"seq"` + Algo string `json:"algo"` + Idx0 int `json:"idx0"` + Idx1 int `json:"idx1"` + Sig0 string `json:"sig0"` + Sig1 string `json:"sig1"` + } + if err := json.Unmarshal(jb, &w); err != nil { + t.Fatalf("header not JSON: %v", err) + } + d := func(s string) []byte { b, _ := base64.StdEncoding.DecodeString(s); return b } + return operator.OperatorTransmission{ + OperatorID: w.OperatorID, TsUnixNano: w.TsUnixNano, Nonce: d(w.Nonce), + Seq: w.Seq, Algo: w.Algo, Idx0: w.Idx0, Idx1: w.Idx1, + Sig0: d(w.Sig0), Sig1: d(w.Sig1), + } +} + +// A client built with DialAsOperator must carry a valid operator header on its +// /v0 requests, and that header must decode+verify against the keyset — the +// full client-side auth path, from signer to wire, end to end. +func TestDialAsOperator_AttachesVerifiableHeader(t *testing.T) { + ks, err := opcred.GenerateKeyset() + if err != nil { + t.Fatal(err) + } + signer := opcred.NewTransmissionSigner("cloudy", ks) + + var gotHeader string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get(opcred.OperatorHeaderName) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"CoordinatorID":"soholink","Terms":{"ContributorShareBps":6500,"PlatformFeeBps":3500}}`)) + })) + defer srv.Close() + + c := DialAsOperator(srv.URL, func(r *http.Request) error { + tx, err := signer.Next() + if err != nil { + return err + } + r.Header.Set(opcred.OperatorHeaderName, opcred.EncodeOperatorHeader(tx)) + return nil + }) + + if _, err := c.Fees(context.Background()); err != nil { + t.Fatalf("Fees call: %v", err) + } + if gotHeader == "" { + t.Fatal("coordinator received no operator header") + } + tx := decodeHeaderForTest(t, gotHeader) + if err := tx.Verify(ks.KeyMap()); err != nil { + t.Fatalf("attached header did not verify against the keyset: %v", err) + } + if tx.OperatorID != "cloudy" { + t.Errorf("operator id = %q, want cloudy", tx.OperatorID) + } +} + +// A decoration failure must fail the request closed (never sent unauthenticated). +func TestDialAsOperator_FailClosed(t *testing.T) { + reached := false + srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { reached = true })) + defer srv.Close() + + c := DialAsOperator(srv.URL, func(*http.Request) error { return context.DeadlineExceeded }) + if _, err := c.Fees(context.Background()); err == nil { + t.Fatal("expected the request to fail when decoration fails") + } + if reached { + t.Fatal("request reached the server despite a decoration failure — not fail-closed") + } +} diff --git a/internal/opcred/header.go b/internal/opcred/header.go new file mode 100644 index 0000000..79f1d7f --- /dev/null +++ b/internal/opcred/header.go @@ -0,0 +1,55 @@ +package opcred + +import ( + "encoding/base64" + "encoding/json" + + "github.com/NTARI-RAND/sohocloud-protocol/operator" +) + +// OperatorHeaderName is the HTTP header that carries an operator transmission on +// the coordinator's node-side /v0 seam. It MUST equal the coordinator's own +// constant (SoHoLINK internal/api.OperatorHeader = "X-SohoCloud-Operator"); the +// name and the value encoding below are the cross-implementation contract, held +// by the round-trip test against the protocol's own Verify. +const OperatorHeaderName = "X-SohoCloud-Operator" + +// transmissionWire is the JSON shape base64-encoded into the header value. It +// maps 1:1 onto operator.OperatorTransmission and MUST match the coordinator's +// decoder (SoHoLINK internal/api.operatorTransmissionWire): the snake_case +// field names and the standard-base64 encoding of the byte fields are load +// bearing. The protocol package does not define this transport shape (its +// canon is the SIGNED bytes, not the wire envelope), so the two ends agree on +// it here and by test, not by a shared type — a candidate to hoist into +// sohocloud-protocol later. +type transmissionWire struct { + OperatorID string `json:"operator_id"` + TsUnixNano int64 `json:"ts_unix_nano"` + Nonce string `json:"nonce"` // base64 std + Seq uint64 `json:"seq"` + Algo string `json:"algo"` + Idx0 int `json:"idx0"` + Idx1 int `json:"idx1"` + Sig0 string `json:"sig0"` // base64 std + Sig1 string `json:"sig1"` // base64 std +} + +// EncodeOperatorHeader serializes a signed transmission to the base64 header +// value the coordinator expects — the client counterpart to the coordinator's +// decodeOperatorHeader. Cloudy only ever encodes (it authenticates AS the +// operator); the coordinator decodes and verifies. +func EncodeOperatorHeader(tx operator.OperatorTransmission) string { + w := transmissionWire{ + OperatorID: tx.OperatorID, + TsUnixNano: tx.TsUnixNano, + Nonce: base64.StdEncoding.EncodeToString(tx.Nonce), + Seq: tx.Seq, + Algo: tx.Algo, + Idx0: tx.Idx0, + Idx1: tx.Idx1, + Sig0: base64.StdEncoding.EncodeToString(tx.Sig0), + Sig1: base64.StdEncoding.EncodeToString(tx.Sig1), + } + b, _ := json.Marshal(w) //nolint:errcheck // transmissionWire has no unmarshalable fields + return base64.StdEncoding.EncodeToString(b) +} diff --git a/internal/opcred/header_test.go b/internal/opcred/header_test.go new file mode 100644 index 0000000..b6f3b85 --- /dev/null +++ b/internal/opcred/header_test.go @@ -0,0 +1,92 @@ +package opcred + +import ( + "encoding/base64" + "encoding/json" + "testing" + + "github.com/NTARI-RAND/sohocloud-protocol/operator" +) + +// decodeOperatorHeader mirrors the coordinator's decoder (SoHoLINK +// internal/api.decodeOperatorHeader) so the test can prove Cloudy's encoding is +// exactly what the coordinator will decode and verify. It is intentionally +// test-only: at runtime Cloudy encodes, never decodes. +func decodeOperatorHeader(t *testing.T, raw string) operator.OperatorTransmission { + t.Helper() + jb, err := base64.StdEncoding.DecodeString(raw) + if err != nil { + t.Fatalf("header not base64: %v", err) + } + var w transmissionWire + if err := json.Unmarshal(jb, &w); err != nil { + t.Fatalf("header not JSON: %v", err) + } + dec := func(s string) []byte { + b, err := base64.StdEncoding.DecodeString(s) + if err != nil { + t.Fatalf("byte field not base64: %v", err) + } + return b + } + return operator.OperatorTransmission{ + OperatorID: w.OperatorID, + TsUnixNano: w.TsUnixNano, + Nonce: dec(w.Nonce), + Seq: w.Seq, + Algo: w.Algo, + Idx0: w.Idx0, + Idx1: w.Idx1, + Sig0: dec(w.Sig0), + Sig1: dec(w.Sig1), + } +} + +// The load-bearing cross-implementation test: a transmission Cloudy signs and +// encodes must decode — via the coordinator's exact wire shape — to a +// transmission the PROTOCOL's own Verify accepts against the keyset. If this +// passes, SoHoLINK's OperatorAuth (same decode + same Verify) accepts it too. +func TestEncodeOperatorHeader_DecodesAndVerifies(t *testing.T) { + ks, err := GenerateKeyset() + if err != nil { + t.Fatalf("keyset: %v", err) + } + signer := NewTransmissionSigner("cloudy", ks) + + tx, err := signer.Next() + if err != nil { + t.Fatalf("sign: %v", err) + } + hdr := EncodeOperatorHeader(tx) + + got := decodeOperatorHeader(t, hdr) + if err := got.Verify(ks.KeyMap()); err != nil { + t.Fatalf("coordinator-style decode+Verify rejected a Cloudy-encoded header: %v", err) + } + if got.OperatorID != "cloudy" || got.Algo != operator.AlgoEd25519 { + t.Errorf("decoded fields wrong: id=%q algo=%q", got.OperatorID, got.Algo) + } + if got.Idx0 == got.Idx1 { + t.Errorf("signing indices must differ, both %d", got.Idx0) + } +} + +// Field-name contract: the decoded JSON must use the exact snake_case keys the +// coordinator's decoder expects — a rename on either side is a silent auth break. +func TestOperatorHeaderWireFieldNames(t *testing.T) { + ks, _ := GenerateKeyset() + tx, _ := NewTransmissionSigner("cloudy", ks).Next() + jb, _ := base64.StdEncoding.DecodeString(EncodeOperatorHeader(tx)) + var m map[string]any + if err := json.Unmarshal(jb, &m); err != nil { + t.Fatal(err) + } + for _, k := range []string{"operator_id", "ts_unix_nano", "nonce", "seq", "algo", "idx0", "idx1", "sig0", "sig1"} { + if _, ok := m[k]; !ok { + t.Errorf("header JSON missing required field %q", k) + } + } + if OperatorHeaderName != "X-SohoCloud-Operator" { + t.Errorf("header name %q must match the coordinator constant", OperatorHeaderName) + } +}