diff --git a/pkg/client/client.go b/pkg/client/client.go index f09cf35..52d05e0 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -16,16 +16,20 @@ import ( "bytes" "context" "crypto/rand" + "encoding/base64" + "encoding/hex" "encoding/json" "fmt" "io" "net/http" + "strconv" "sync" "time" cs "fn-registry/functions/cybersecurity" "github.com/enmanuel/unibus/pkg/frame" + "github.com/enmanuel/unibus/pkg/membership" "github.com/enmanuel/unibus/pkg/room" "github.com/nats-io/nats.go" "github.com/nats-io/nats.go/jetstream" @@ -116,17 +120,17 @@ func (c *Client) getCachedKey(roomID string, epoch int) ([]byte, bool) { // ---- control-plane HTTP helpers ------------------------------------------ func (c *Client) doJSON(method, path string, body, out any) error { - var rdr io.Reader + var bodyBytes []byte if body != nil { b, err := json.Marshal(body) if err != nil { return fmt.Errorf("client: marshal request: %w", err) } - rdr = bytes.NewReader(b) + bodyBytes = b } - req, err := http.NewRequest(method, c.ctrlURL+path, rdr) + req, err := c.newSignedRequest(method, path, bodyBytes) if err != nil { - return fmt.Errorf("client: new request: %w", err) + return err } if body != nil { req.Header.Set("Content-Type", "application/json") @@ -158,12 +162,51 @@ func (c *Client) doJSON(method, path string, body, out any) error { // signRequest signs the canonical bytes of req (req must already have its Sig // field cleared) with the client's Ed25519 key. It is symmetric with the -// server's verifyOwnerSig. +// server's verifyOwnerSig. This is the PAYLOAD-level owner signature that +// authorizes room operations (invite/rekey) by ownership — distinct from the +// transport-level request signature applied by newSignedRequest below, which +// authenticates the caller's identity on every request. func (c *Client) signRequest(req any) []byte { b, _ := json.Marshal(req) return cs.SignEd25519(c.id.SignPriv, b) } +// newSignedRequest builds an *http.Request to the control plane and attaches the +// transport authentication headers (X-Unibus-Pub/Ts/Nonce/Sig) signing the +// canonical request bytes with this peer's Ed25519 key. path is the request URI +// (path plus any query); body is the raw request body (nil for GET). The server +// (membership.authenticate) verifies these headers under the bus-auth flag. +// +// Signing happens on every request — including GETs — so that under enforce the +// server can authenticate the caller and reject unregistered or revoked +// identities uniformly. The canonical construction is the single source of truth +// in membership.CanonicalRequest, shared by both sides. +func (c *Client) newSignedRequest(method, path string, body []byte) (*http.Request, error) { + var rdr io.Reader + if body != nil { + rdr = bytes.NewReader(body) + } + req, err := http.NewRequest(method, c.ctrlURL+path, rdr) + if err != nil { + return nil, fmt.Errorf("client: new request: %w", err) + } + + ts := strconv.FormatInt(time.Now().Unix(), 10) + nonceRaw := make([]byte, 16) + if _, err := rand.Read(nonceRaw); err != nil { + return nil, fmt.Errorf("client: generate nonce: %w", err) + } + nonce := base64.StdEncoding.EncodeToString(nonceRaw) + canonical := membership.CanonicalRequest(method, path, ts, nonce, body) + sig := cs.SignEd25519(c.id.SignPriv, canonical) + + req.Header.Set("X-Unibus-Pub", hex.EncodeToString(c.id.SignPub)) + req.Header.Set("X-Unibus-Ts", ts) + req.Header.Set("X-Unibus-Nonce", nonce) + req.Header.Set("X-Unibus-Sig", base64.StdEncoding.EncodeToString(sig)) + return req, nil +} + // ---- mirror of server wire types (control plane) ------------------------- type policyJSON struct { @@ -769,9 +812,9 @@ func (c *Client) FetchMedia(roomID string, f frame.Frame) ([]byte, error) { } func (c *Client) putBlob(ciphertext []byte) (string, error) { - req, err := http.NewRequest("POST", c.ctrlURL+"/blobs", bytes.NewReader(ciphertext)) + req, err := c.newSignedRequest("POST", "/blobs", ciphertext) if err != nil { - return "", fmt.Errorf("client: new blob request: %w", err) + return "", err } req.Header.Set("Content-Type", "application/octet-stream") resp, err := c.http.Do(req) @@ -791,7 +834,11 @@ func (c *Client) putBlob(ciphertext []byte) (string, error) { } func (c *Client) getBlob(hash string) ([]byte, error) { - resp, err := c.http.Get(c.ctrlURL + "/blobs/" + hash) + req, err := c.newSignedRequest("GET", "/blobs/"+hash, nil) + if err != nil { + return nil, err + } + resp, err := c.http.Do(req) if err != nil { return nil, fmt.Errorf("client: get blob: %w", err) }