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
35 changes: 35 additions & 0 deletions internal/coord/coord.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
100 changes: 100 additions & 0 deletions internal/coord/operator_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
55 changes: 55 additions & 0 deletions internal/opcred/header.go
Original file line number Diff line number Diff line change
@@ -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)
}
92 changes: 92 additions & 0 deletions internal/opcred/header_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading