feat(client): sign every control-plane request (transport auth headers)
doJSON, putBlob and getBlob now go through newSignedRequest, which attaches X-Unibus-Pub/Ts/Nonce/Sig signing membership.CanonicalRequest with the peer's Ed25519 key. GETs are signed too so the server can authenticate the caller uniformly under enforce. The payload-level owner signature (invite/rekey) is unchanged and coexists with this transport-level signature. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+55
-8
@@ -16,16 +16,20 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
cs "fn-registry/functions/cybersecurity"
|
cs "fn-registry/functions/cybersecurity"
|
||||||
|
|
||||||
"github.com/enmanuel/unibus/pkg/frame"
|
"github.com/enmanuel/unibus/pkg/frame"
|
||||||
|
"github.com/enmanuel/unibus/pkg/membership"
|
||||||
"github.com/enmanuel/unibus/pkg/room"
|
"github.com/enmanuel/unibus/pkg/room"
|
||||||
"github.com/nats-io/nats.go"
|
"github.com/nats-io/nats.go"
|
||||||
"github.com/nats-io/nats.go/jetstream"
|
"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 ------------------------------------------
|
// ---- control-plane HTTP helpers ------------------------------------------
|
||||||
|
|
||||||
func (c *Client) doJSON(method, path string, body, out any) error {
|
func (c *Client) doJSON(method, path string, body, out any) error {
|
||||||
var rdr io.Reader
|
var bodyBytes []byte
|
||||||
if body != nil {
|
if body != nil {
|
||||||
b, err := json.Marshal(body)
|
b, err := json.Marshal(body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("client: marshal request: %w", err)
|
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 {
|
if err != nil {
|
||||||
return fmt.Errorf("client: new request: %w", err)
|
return err
|
||||||
}
|
}
|
||||||
if body != nil {
|
if body != nil {
|
||||||
req.Header.Set("Content-Type", "application/json")
|
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
|
// 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
|
// 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 {
|
func (c *Client) signRequest(req any) []byte {
|
||||||
b, _ := json.Marshal(req)
|
b, _ := json.Marshal(req)
|
||||||
return cs.SignEd25519(c.id.SignPriv, b)
|
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) -------------------------
|
// ---- mirror of server wire types (control plane) -------------------------
|
||||||
|
|
||||||
type policyJSON struct {
|
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) {
|
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 {
|
if err != nil {
|
||||||
return "", fmt.Errorf("client: new blob request: %w", err)
|
return "", err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/octet-stream")
|
req.Header.Set("Content-Type", "application/octet-stream")
|
||||||
resp, err := c.http.Do(req)
|
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) {
|
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 {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("client: get blob: %w", err)
|
return nil, fmt.Errorf("client: get blob: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user