feat: initial scaffold of unibus message bus (membership service + client lib + demo peers)
This commit is contained in:
@@ -0,0 +1,627 @@
|
||||
// Package client is the unibus client library: the single API that every peer
|
||||
// (process worker, human chat UI, LLM agent) uses to talk to the bus.
|
||||
//
|
||||
// It hides the two planes behind one object:
|
||||
// - control plane (HTTP to membershipd): create/join rooms, invite, fetch
|
||||
// sealed keys, rekey on kick.
|
||||
// - data plane (NATS): publish/subscribe/request/reply of frames.
|
||||
//
|
||||
// In encrypted rooms it transparently seals/opens payloads with the room key K,
|
||||
// distributes K to invitees via sealed boxes, signs and verifies messages, and
|
||||
// rotates K to a new epoch on kick (forward secrecy). All crypto comes from the
|
||||
// fn-registry cybersecurity package; this library never reimplements primitives.
|
||||
package client
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/rand"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/frame"
|
||||
"github.com/enmanuel/unibus/pkg/room"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// Endpoint is the public identity of a peer.
|
||||
type Endpoint struct {
|
||||
ID string
|
||||
SignPub []byte
|
||||
KexPub []byte
|
||||
}
|
||||
|
||||
// Client is a connected unibus peer.
|
||||
type Client struct {
|
||||
id cs.Identity
|
||||
endpoint string
|
||||
nc *nats.Conn
|
||||
ctrlURL string
|
||||
http *http.Client
|
||||
|
||||
mu sync.RWMutex
|
||||
keyCache map[string]map[int][]byte // roomID -> epoch -> K
|
||||
signCache map[string][]byte // sender endpoint -> sign pub (for verification)
|
||||
}
|
||||
|
||||
// New connects to NATS and records the control-plane URL. The identity holds
|
||||
// the peer's long-term keypairs.
|
||||
func New(natsURL, ctrlURL string, id cs.Identity) (*Client, error) {
|
||||
nc, err := nats.Connect(natsURL, nats.Name("unibus-client"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: connect nats %q: %w", natsURL, err)
|
||||
}
|
||||
return &Client{
|
||||
id: id,
|
||||
endpoint: frame.EndpointID(id.SignPub),
|
||||
nc: nc,
|
||||
ctrlURL: ctrlURL,
|
||||
http: &http.Client{Timeout: 10 * time.Second},
|
||||
keyCache: map[string]map[int][]byte{},
|
||||
signCache: map[string][]byte{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Endpoint returns this client's public identity.
|
||||
func (c *Client) Endpoint() Endpoint {
|
||||
return Endpoint{ID: c.endpoint, SignPub: c.id.SignPub, KexPub: c.id.KexPub}
|
||||
}
|
||||
|
||||
// Close releases the NATS connection.
|
||||
func (c *Client) Close() error {
|
||||
c.nc.Close()
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- key cache ------------------------------------------------------------
|
||||
|
||||
func (c *Client) cacheKey(roomID string, epoch int, k []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
m := c.keyCache[roomID]
|
||||
if m == nil {
|
||||
m = map[int][]byte{}
|
||||
c.keyCache[roomID] = m
|
||||
}
|
||||
m[epoch] = k
|
||||
}
|
||||
|
||||
func (c *Client) getCachedKey(roomID string, epoch int) ([]byte, bool) {
|
||||
c.mu.RLock()
|
||||
defer c.mu.RUnlock()
|
||||
if m := c.keyCache[roomID]; m != nil {
|
||||
k, ok := m[epoch]
|
||||
return k, ok
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// ---- control-plane HTTP helpers ------------------------------------------
|
||||
|
||||
func (c *Client) doJSON(method, path string, body, out any) error {
|
||||
var rdr io.Reader
|
||||
if body != nil {
|
||||
b, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: marshal request: %w", err)
|
||||
}
|
||||
rdr = bytes.NewReader(b)
|
||||
}
|
||||
req, err := http.NewRequest(method, c.ctrlURL+path, rdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: new request: %w", err)
|
||||
}
|
||||
if body != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: do %s %s: %w", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return fmt.Errorf("client: %s %s -> %d: %s", method, path, resp.StatusCode, string(respBody))
|
||||
}
|
||||
if out != nil {
|
||||
if err := json.Unmarshal(respBody, out); err != nil {
|
||||
return fmt.Errorf("client: decode response: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (c *Client) signRequest(req any) []byte {
|
||||
b, _ := json.Marshal(req)
|
||||
return cs.SignEd25519(c.id.SignPriv, b)
|
||||
}
|
||||
|
||||
// ---- mirror of server wire types (control plane) -------------------------
|
||||
|
||||
type policyJSON struct {
|
||||
Encrypt bool `json:"encrypt"`
|
||||
Persist bool `json:"persist"`
|
||||
SignMsgs bool `json:"sign_msgs"`
|
||||
}
|
||||
|
||||
type endpointJSON struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
SignPub []byte `json:"sign_pub"`
|
||||
KexPub []byte `json:"kex_pub"`
|
||||
}
|
||||
|
||||
type createRoomReq struct {
|
||||
Subject string `json:"subject"`
|
||||
Policy policyJSON `json:"policy"`
|
||||
Owner endpointJSON `json:"owner"`
|
||||
SealedKeySelf []byte `json:"sealed_key_self"`
|
||||
}
|
||||
|
||||
type createRoomResp struct {
|
||||
RoomID string `json:"room_id"`
|
||||
}
|
||||
|
||||
type inviteReq struct {
|
||||
By string `json:"by"`
|
||||
Sig []byte `json:"sig"`
|
||||
Member endpointJSON `json:"member"`
|
||||
SealedKey []byte `json:"sealed_key"`
|
||||
}
|
||||
|
||||
type keyResp struct {
|
||||
Epoch int `json:"epoch"`
|
||||
SealedKey []byte `json:"sealed_key"`
|
||||
}
|
||||
|
||||
type memberJSON struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
Role string `json:"role"`
|
||||
SignPub []byte `json:"sign_pub"`
|
||||
KexPub []byte `json:"kex_pub"`
|
||||
}
|
||||
|
||||
type roomResp struct {
|
||||
Subject string `json:"subject"`
|
||||
Epoch int `json:"epoch"`
|
||||
Policy policyJSON `json:"policy"`
|
||||
}
|
||||
|
||||
type rekeyKey struct {
|
||||
Endpoint string `json:"endpoint"`
|
||||
SealedKey []byte `json:"sealed_key"`
|
||||
}
|
||||
|
||||
type rekeyReq struct {
|
||||
By string `json:"by"`
|
||||
Sig []byte `json:"sig"`
|
||||
NewEpoch int `json:"new_epoch"`
|
||||
Keys []rekeyKey `json:"keys"`
|
||||
Remove []string `json:"remove"`
|
||||
}
|
||||
|
||||
type blobResp struct {
|
||||
Hash string `json:"hash"`
|
||||
}
|
||||
|
||||
// ---- room operations ------------------------------------------------------
|
||||
|
||||
// newRoomKey returns 32 random bytes for a symmetric room key.
|
||||
func newRoomKey() ([]byte, error) {
|
||||
k := make([]byte, 32)
|
||||
if _, err := rand.Read(k); err != nil {
|
||||
return nil, fmt.Errorf("client: generate room key: %w", err)
|
||||
}
|
||||
return k, nil
|
||||
}
|
||||
|
||||
// CreateRoom creates a room with the given subject and policy. For encrypted
|
||||
// rooms it generates K, seals K to itself, and caches K at epoch 1.
|
||||
func (c *Client) CreateRoom(subject string, p room.Policy) (string, error) {
|
||||
req := createRoomReq{
|
||||
Subject: subject,
|
||||
Policy: policyJSON{Encrypt: p.Encrypt, Persist: p.Persist, SignMsgs: p.SignMsgs},
|
||||
Owner: endpointJSON{Endpoint: c.endpoint, SignPub: c.id.SignPub, KexPub: c.id.KexPub},
|
||||
}
|
||||
var k []byte
|
||||
if p.Encrypt {
|
||||
var err error
|
||||
if k, err = newRoomKey(); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sealed, err := cs.SealKeyBox(c.id.KexPub, k)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("client: seal own key: %w", err)
|
||||
}
|
||||
req.SealedKeySelf = sealed
|
||||
}
|
||||
var resp createRoomResp
|
||||
if err := c.doJSON("POST", "/rooms", req, &resp); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if p.Encrypt {
|
||||
c.cacheKey(resp.RoomID, 1, k)
|
||||
}
|
||||
return resp.RoomID, nil
|
||||
}
|
||||
|
||||
// Invite adds a member to a room. It seals the current-epoch room key to the
|
||||
// invitee's X25519 public key and signs the request as the owner.
|
||||
func (c *Client) Invite(roomID string, m Endpoint) error {
|
||||
info, err := c.fetchRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var sealed []byte
|
||||
if info.Policy.Encrypt {
|
||||
k, ok := c.getCachedKey(roomID, info.Epoch)
|
||||
if !ok {
|
||||
return fmt.Errorf("client: invite: no cached key for room %s epoch %d", roomID, info.Epoch)
|
||||
}
|
||||
if sealed, err = cs.SealKeyBox(m.KexPub, k); err != nil {
|
||||
return fmt.Errorf("client: seal key for invitee: %w", err)
|
||||
}
|
||||
}
|
||||
req := inviteReq{
|
||||
By: c.endpoint,
|
||||
Member: endpointJSON{Endpoint: m.ID, SignPub: m.SignPub, KexPub: m.KexPub},
|
||||
SealedKey: sealed,
|
||||
}
|
||||
req.Sig = c.signRequest(req) // Sig is zero-valued at sign time
|
||||
return c.doJSON("POST", "/rooms/"+roomID+"/invite", req, nil)
|
||||
}
|
||||
|
||||
// roomView is the resolved room metadata.
|
||||
type roomView struct {
|
||||
Subject string
|
||||
Epoch int
|
||||
Policy room.Policy
|
||||
}
|
||||
|
||||
func (c *Client) fetchRoom(roomID string) (roomView, error) {
|
||||
var resp roomResp
|
||||
if err := c.doJSON("GET", "/rooms/"+roomID, nil, &resp); err != nil {
|
||||
return roomView{}, err
|
||||
}
|
||||
return roomView{
|
||||
Subject: resp.Subject,
|
||||
Epoch: resp.Epoch,
|
||||
Policy: room.Policy{Encrypt: resp.Policy.Encrypt, Persist: resp.Policy.Persist, SignMsgs: resp.Policy.SignMsgs},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// fetchKey retrieves and caches the room key K for the given epoch (epoch <= 0
|
||||
// means latest). It opens the sealed box with the client's own X25519 keypair.
|
||||
func (c *Client) fetchKey(roomID string, epoch int) ([]byte, int, error) {
|
||||
if epoch > 0 {
|
||||
if k, ok := c.getCachedKey(roomID, epoch); ok {
|
||||
return k, epoch, nil
|
||||
}
|
||||
}
|
||||
path := fmt.Sprintf("/rooms/%s/key?endpoint=%s", roomID, c.endpoint)
|
||||
if epoch > 0 {
|
||||
path += fmt.Sprintf("&epoch=%d", epoch)
|
||||
}
|
||||
var resp keyResp
|
||||
if err := c.doJSON("GET", path, nil, &resp); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
k, err := cs.OpenKeyBox(c.id.KexPub, c.id.KexPriv, resp.SealedKey)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("client: open room key: %w", err)
|
||||
}
|
||||
c.cacheKey(roomID, resp.Epoch, k)
|
||||
return k, resp.Epoch, nil
|
||||
}
|
||||
|
||||
// Join resolves room metadata and, for encrypted rooms, fetches and caches the
|
||||
// current room key. It does not subscribe to NATS (use Subscribe for that).
|
||||
func (c *Client) Join(roomID string) error {
|
||||
info, err := c.fetchRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if info.Policy.Encrypt {
|
||||
if _, _, err := c.fetchKey(roomID, info.Epoch); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// signerPub returns the sign public key of a sender endpoint, fetching the
|
||||
// member list once and caching it.
|
||||
func (c *Client) signerPub(roomID, sender string) ([]byte, error) {
|
||||
c.mu.RLock()
|
||||
pub, ok := c.signCache[sender]
|
||||
c.mu.RUnlock()
|
||||
if ok {
|
||||
return pub, nil
|
||||
}
|
||||
var members []memberJSON
|
||||
if err := c.doJSON("GET", "/rooms/"+roomID+"/members", nil, &members); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.mu.Lock()
|
||||
for _, m := range members {
|
||||
c.signCache[m.Endpoint] = m.SignPub
|
||||
}
|
||||
pub, ok = c.signCache[sender]
|
||||
c.mu.Unlock()
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("client: no sign key for sender %q", sender)
|
||||
}
|
||||
return pub, nil
|
||||
}
|
||||
|
||||
// ---- data plane: publish/subscribe ---------------------------------------
|
||||
|
||||
// Publish sends plaintext to a room. For encrypted rooms it seals the payload
|
||||
// with the current K using the subject as AEAD additional-authenticated-data;
|
||||
// for signed rooms it attaches an Ed25519 signature.
|
||||
func (c *Client) Publish(roomID string, plaintext []byte) error {
|
||||
info, err := c.fetchRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := frame.Frame{
|
||||
Type: frame.PUB,
|
||||
Subject: info.Subject,
|
||||
Sender: c.endpoint,
|
||||
MsgID: newULID(),
|
||||
Epoch: info.Epoch,
|
||||
}
|
||||
if info.Policy.Encrypt {
|
||||
k, ep, err := c.fetchKey(roomID, info.Epoch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce, ct, err := cs.SealAEAD(k, plaintext, []byte(info.Subject))
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: seal payload: %w", err)
|
||||
}
|
||||
f.Epoch, f.Nonce, f.Payload = ep, nonce, ct
|
||||
} else {
|
||||
f.Payload = plaintext
|
||||
}
|
||||
if info.Policy.SignMsgs {
|
||||
f.Sig = cs.SignEd25519(c.id.SignPriv, f.SigningBytes())
|
||||
}
|
||||
b, err := f.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: marshal frame: %w", err)
|
||||
}
|
||||
return c.nc.Publish(info.Subject, b)
|
||||
}
|
||||
|
||||
// Subscribe subscribes to a room and invokes handler for each message with the
|
||||
// decoded frame and (for encrypted rooms) the decrypted plaintext. Signature
|
||||
// verification and epoch-driven key refresh happen transparently. Messages that
|
||||
// fail verification or decryption are dropped (handler not called).
|
||||
func (c *Client) Subscribe(roomID string, handler func(f frame.Frame, plaintext []byte)) (*nats.Subscription, error) {
|
||||
info, err := c.fetchRoom(roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c.nc.Subscribe(info.Subject, func(msg *nats.Msg) {
|
||||
f, err := frame.Unmarshal(msg.Data)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if info.Policy.SignMsgs && f.Sig != nil {
|
||||
pub, err := c.signerPub(roomID, f.Sender)
|
||||
if err != nil || !cs.VerifyEd25519(pub, f.SigningBytes(), f.Sig) {
|
||||
return // unauthenticated frame: drop
|
||||
}
|
||||
}
|
||||
plaintext := f.Payload
|
||||
// Decrypt only inline payloads. Media frames carry their bytes in the
|
||||
// blob store (referenced by f.Blob) with the nonce in BlobRef.Nonce;
|
||||
// the handler decrypts those on demand via FetchMedia. A frame with an
|
||||
// inline ciphertext always has a non-empty Nonce.
|
||||
if info.Policy.Encrypt && len(f.Nonce) > 0 && len(f.Payload) > 0 {
|
||||
k, ok := c.getCachedKey(roomID, f.Epoch)
|
||||
if !ok {
|
||||
// Sender used a newer (or unknown) epoch: refresh K from the control plane.
|
||||
k, _, err = c.fetchKey(roomID, f.Epoch)
|
||||
if err != nil {
|
||||
return // cannot obtain key for this epoch (e.g. we were kicked): drop
|
||||
}
|
||||
}
|
||||
pt, err := cs.OpenAEAD(k, f.Nonce, f.Payload, []byte(info.Subject))
|
||||
if err != nil {
|
||||
return // cannot decrypt (wrong epoch/kicked): drop
|
||||
}
|
||||
plaintext = pt
|
||||
}
|
||||
handler(f, plaintext)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- request/reply (cleartext v1) ----------------------------------------
|
||||
|
||||
// Request performs a NATS request/reply on subject (cleartext in v1, intended
|
||||
// for rpc.* subjects).
|
||||
func (c *Client) Request(subject string, plaintext []byte, timeout time.Duration) ([]byte, error) {
|
||||
msg, err := c.nc.Request(subject, plaintext, timeout)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: request %q: %w", subject, err)
|
||||
}
|
||||
return msg.Data, nil
|
||||
}
|
||||
|
||||
// Reply registers a responder for subject; handler receives the request bytes
|
||||
// and returns the reply bytes (cleartext in v1).
|
||||
func (c *Client) Reply(subject string, handler func([]byte) []byte) (*nats.Subscription, error) {
|
||||
return c.nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
if msg.Reply == "" {
|
||||
return
|
||||
}
|
||||
_ = c.nc.Publish(msg.Reply, handler(msg.Data))
|
||||
})
|
||||
}
|
||||
|
||||
// ---- kick / forward secrecy ----------------------------------------------
|
||||
|
||||
// Kick removes a member and rotates the room key to a new epoch, re-sealing it
|
||||
// only for the remaining members. The kicked member can no longer decrypt
|
||||
// messages published after the rotation (forward secrecy).
|
||||
func (c *Client) Kick(roomID string, endpoint string) error {
|
||||
info, err := c.fetchRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newEpoch := info.Epoch + 1
|
||||
|
||||
if !info.Policy.Encrypt {
|
||||
// Unencrypted room: just remove the member, no key rotation needed.
|
||||
req := rekeyReq{By: c.endpoint, NewEpoch: newEpoch, Remove: []string{endpoint}}
|
||||
req.Sig = c.signRequest(req)
|
||||
return c.doJSON("POST", "/rooms/"+roomID+"/rekey", req, nil)
|
||||
}
|
||||
|
||||
kPrime, err := newRoomKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var members []memberJSON
|
||||
if err := c.doJSON("GET", "/rooms/"+roomID+"/members", nil, &members); err != nil {
|
||||
return err
|
||||
}
|
||||
var keys []rekeyKey
|
||||
for _, m := range members {
|
||||
if m.Endpoint == endpoint {
|
||||
continue // exclude the kicked member
|
||||
}
|
||||
sealed, err := cs.SealKeyBox(m.KexPub, kPrime)
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: seal new key for %q: %w", m.Endpoint, err)
|
||||
}
|
||||
keys = append(keys, rekeyKey{Endpoint: m.Endpoint, SealedKey: sealed})
|
||||
}
|
||||
req := rekeyReq{By: c.endpoint, NewEpoch: newEpoch, Keys: keys, Remove: []string{endpoint}}
|
||||
req.Sig = c.signRequest(req)
|
||||
if err := c.doJSON("POST", "/rooms/"+roomID+"/rekey", req, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
c.cacheKey(roomID, newEpoch, kPrime)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ---- media (object store) -------------------------------------------------
|
||||
|
||||
// PublishMedia encrypts data with the room key, uploads the ciphertext to the
|
||||
// blob store, and publishes a frame carrying only a BlobRef. Receivers whose
|
||||
// handler sees f.Blob != nil should GET /blobs/{hash} and OpenAEAD it.
|
||||
func (c *Client) PublishMedia(roomID string, data []byte) error {
|
||||
info, err := c.fetchRoom(roomID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f := frame.Frame{
|
||||
Type: frame.PUB,
|
||||
Subject: info.Subject,
|
||||
Sender: c.endpoint,
|
||||
MsgID: newULID(),
|
||||
Epoch: info.Epoch,
|
||||
}
|
||||
|
||||
var ciphertext []byte
|
||||
var nonce []byte
|
||||
if info.Policy.Encrypt {
|
||||
k, ep, err := c.fetchKey(roomID, info.Epoch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nonce, ciphertext, err = cs.SealAEAD(k, data, []byte(info.Subject))
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: seal media: %w", err)
|
||||
}
|
||||
f.Epoch = ep
|
||||
} else {
|
||||
ciphertext = data
|
||||
}
|
||||
|
||||
hash, err := c.putBlob(ciphertext)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
f.Blob = &frame.BlobRef{Hash: hash, Nonce: nonce, Size: int64(len(ciphertext))}
|
||||
|
||||
if info.Policy.SignMsgs {
|
||||
f.Sig = cs.SignEd25519(c.id.SignPriv, f.SigningBytes())
|
||||
}
|
||||
b, err := f.Marshal()
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: marshal media frame: %w", err)
|
||||
}
|
||||
return c.nc.Publish(info.Subject, b)
|
||||
}
|
||||
|
||||
// FetchMedia downloads and (for encrypted rooms) decrypts a blob referenced by
|
||||
// a received frame. It is a convenience for handlers that see f.Blob != nil.
|
||||
func (c *Client) FetchMedia(roomID string, f frame.Frame) ([]byte, error) {
|
||||
if f.Blob == nil {
|
||||
return nil, fmt.Errorf("client: frame has no blob ref")
|
||||
}
|
||||
ct, err := c.getBlob(f.Blob.Hash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info, err := c.fetchRoom(roomID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !info.Policy.Encrypt {
|
||||
return ct, nil
|
||||
}
|
||||
k, ok := c.getCachedKey(roomID, f.Epoch)
|
||||
if !ok {
|
||||
if k, _, err = c.fetchKey(roomID, f.Epoch); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return cs.OpenAEAD(k, f.Blob.Nonce, ct, []byte(info.Subject))
|
||||
}
|
||||
|
||||
func (c *Client) putBlob(ciphertext []byte) (string, error) {
|
||||
req, err := http.NewRequest("POST", c.ctrlURL+"/blobs", bytes.NewReader(ciphertext))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("client: new blob request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/octet-stream")
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("client: put blob: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode >= 300 {
|
||||
return "", fmt.Errorf("client: put blob -> %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var r blobResp
|
||||
if err := json.Unmarshal(body, &r); err != nil {
|
||||
return "", fmt.Errorf("client: decode blob resp: %w", err)
|
||||
}
|
||||
return r.Hash, nil
|
||||
}
|
||||
|
||||
func (c *Client) getBlob(hash string) ([]byte, error) {
|
||||
resp, err := c.http.Get(c.ctrlURL + "/blobs/" + hash)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client: get blob: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 300 {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("client: get blob -> %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
return io.ReadAll(resp.Body)
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
package client_test
|
||||
|
||||
import (
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/enmanuel/unibus/pkg/blobstore"
|
||||
"github.com/enmanuel/unibus/pkg/client"
|
||||
"github.com/enmanuel/unibus/pkg/embeddednats"
|
||||
"github.com/enmanuel/unibus/pkg/frame"
|
||||
"github.com/enmanuel/unibus/pkg/membership"
|
||||
"github.com/enmanuel/unibus/pkg/room"
|
||||
server "github.com/nats-io/nats-server/v2/server"
|
||||
)
|
||||
|
||||
// testHarness boots an embedded NATS server and an in-process membershipd HTTP
|
||||
// server, returning their URLs and a cleanup func.
|
||||
type testHarness struct {
|
||||
natsURL string
|
||||
ctrlURL string
|
||||
ns *server.Server
|
||||
httpts *httptest.Server
|
||||
}
|
||||
|
||||
func freePort(t *testing.T) int {
|
||||
t.Helper()
|
||||
l, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("free port: %v", err)
|
||||
}
|
||||
defer l.Close()
|
||||
return l.Addr().(*net.TCPAddr).Port
|
||||
}
|
||||
|
||||
func newHarness(t *testing.T) *testHarness {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
|
||||
ns, err := embeddednats.Start(filepath.Join(dir, "js"), freePort(t))
|
||||
if err != nil {
|
||||
t.Fatalf("embedded nats: %v", err)
|
||||
}
|
||||
|
||||
store, err := membership.Open(filepath.Join(dir, "unibus.db"))
|
||||
if err != nil {
|
||||
ns.Shutdown()
|
||||
t.Fatalf("membership store: %v", err)
|
||||
}
|
||||
blobs, err := blobstore.New(filepath.Join(dir, "blobs"))
|
||||
if err != nil {
|
||||
ns.Shutdown()
|
||||
t.Fatalf("blob store: %v", err)
|
||||
}
|
||||
srv := membership.NewServer(store, blobs)
|
||||
httpts := httptest.NewServer(srv)
|
||||
|
||||
h := &testHarness{natsURL: embeddednats.ClientURL(ns), ctrlURL: httpts.URL, ns: ns, httpts: httpts}
|
||||
t.Cleanup(func() {
|
||||
httpts.Close()
|
||||
store.Close()
|
||||
ns.Shutdown()
|
||||
ns.WaitForShutdown()
|
||||
})
|
||||
return h
|
||||
}
|
||||
|
||||
func waitHealth(t *testing.T, ctrlURL string) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(3 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
resp, err := http.Get(ctrlURL + "/healthz")
|
||||
if err == nil && resp.StatusCode == 200 {
|
||||
resp.Body.Close()
|
||||
return
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
t.Fatalf("membershipd never became healthy")
|
||||
}
|
||||
|
||||
func mustIdentity(t *testing.T) cs.Identity {
|
||||
t.Helper()
|
||||
id, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
t.Fatalf("generate identity: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// TestE2EEncryptedForwardSecrecy is the headline test: A creates an encrypted
|
||||
// room, invites B, A publishes a message B decrypts, then A kicks B and
|
||||
// publishes at the new epoch — B must NOT be able to decrypt the new message.
|
||||
func TestE2EEncryptedForwardSecrecy(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
waitHealth(t, h.ctrlURL)
|
||||
|
||||
a, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||
if err != nil {
|
||||
t.Fatalf("connect A: %v", err)
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||
if err != nil {
|
||||
t.Fatalf("connect B: %v", err)
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
roomID, err := a.CreateRoom("room.test", room.ModeMatrix)
|
||||
if err != nil {
|
||||
t.Fatalf("A create room: %v", err)
|
||||
}
|
||||
if err := a.Invite(roomID, b.Endpoint()); err != nil {
|
||||
t.Fatalf("A invite B: %v", err)
|
||||
}
|
||||
if err := b.Join(roomID); err != nil {
|
||||
t.Fatalf("B join: %v", err)
|
||||
}
|
||||
|
||||
var mu sync.Mutex
|
||||
var received []string
|
||||
sub, err := b.Subscribe(roomID, func(f frame.Frame, plaintext []byte) {
|
||||
mu.Lock()
|
||||
received = append(received, string(plaintext))
|
||||
mu.Unlock()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("B subscribe: %v", err)
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
const msg1 = "hola E2E"
|
||||
if err := a.Publish(roomID, []byte(msg1)); err != nil {
|
||||
t.Fatalf("A publish msg1: %v", err)
|
||||
}
|
||||
|
||||
// Wait for B to receive and decrypt msg1.
|
||||
if !waitFor(&mu, &received, func(rs []string) bool {
|
||||
for _, r := range rs {
|
||||
if r == msg1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 2*time.Second) {
|
||||
t.Fatalf("B did not decrypt pre-kick message %q; got %v", msg1, snapshot(&mu, &received))
|
||||
}
|
||||
|
||||
// A kicks B (rotates K to a new epoch, re-sealed only for the owner).
|
||||
if err := a.Kick(roomID, b.Endpoint().ID); err != nil {
|
||||
t.Fatalf("A kick B: %v", err)
|
||||
}
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
const msg2 = "secreto post-kick"
|
||||
if err := a.Publish(roomID, []byte(msg2)); err != nil {
|
||||
t.Fatalf("A publish msg2: %v", err)
|
||||
}
|
||||
|
||||
// Give B a chance to (fail to) decrypt; assert it never sees msg2.
|
||||
time.Sleep(1 * time.Second)
|
||||
for _, r := range snapshot(&mu, &received) {
|
||||
if r == msg2 {
|
||||
t.Fatalf("forward secrecy broken: B decrypted post-kick message %q", msg2)
|
||||
}
|
||||
}
|
||||
|
||||
// Sanity: A itself can still decrypt at the new epoch (self-loopback via a fresh subscriber).
|
||||
aSub := subscribeCollect(t, a, roomID)
|
||||
defer aSub.sub.Unsubscribe()
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
const msg3 = "owner-still-works"
|
||||
if err := a.Publish(roomID, []byte(msg3)); err != nil {
|
||||
t.Fatalf("A publish msg3: %v", err)
|
||||
}
|
||||
if !waitFor(&aSub.mu, &aSub.msgs, func(rs []string) bool {
|
||||
for _, r := range rs {
|
||||
if r == msg3 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 2*time.Second) {
|
||||
t.Fatalf("owner could not decrypt own message at new epoch; got %v", snapshot(&aSub.mu, &aSub.msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCleartextWorkerToChat validates the ModeNATS path: a publisher and a
|
||||
// subscriber sharing a subject, no encryption, messages flow through verbatim.
|
||||
func TestCleartextWorkerToChat(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
waitHealth(t, h.ctrlURL)
|
||||
|
||||
pub, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||
if err != nil {
|
||||
t.Fatalf("connect pub: %v", err)
|
||||
}
|
||||
defer pub.Close()
|
||||
subC, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||
if err != nil {
|
||||
t.Fatalf("connect sub: %v", err)
|
||||
}
|
||||
defer subC.Close()
|
||||
|
||||
const subject = "proc.test.ticks"
|
||||
// Each peer owns a room mapped to the shared subject; NATS fans out by subject.
|
||||
pubRoom, err := pub.CreateRoom(subject, room.ModeNATS)
|
||||
if err != nil {
|
||||
t.Fatalf("pub create room: %v", err)
|
||||
}
|
||||
subRoom, err := subC.CreateRoom(subject, room.ModeNATS)
|
||||
if err != nil {
|
||||
t.Fatalf("sub create room: %v", err)
|
||||
}
|
||||
|
||||
collector := subscribeCollect(t, subC, subRoom)
|
||||
defer collector.sub.Unsubscribe()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
const msg = "tick 1"
|
||||
if err := pub.Publish(pubRoom, []byte(msg)); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
if !waitFor(&collector.mu, &collector.msgs, func(rs []string) bool {
|
||||
for _, r := range rs {
|
||||
if r == msg {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}, 2*time.Second) {
|
||||
t.Fatalf("subscriber did not receive cleartext message; got %v", snapshot(&collector.mu, &collector.msgs))
|
||||
}
|
||||
}
|
||||
|
||||
// TestMediaBlobRoundTrip validates encrypted media via the object store.
|
||||
func TestMediaBlobRoundTrip(t *testing.T) {
|
||||
h := newHarness(t)
|
||||
waitHealth(t, h.ctrlURL)
|
||||
|
||||
a, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||
if err != nil {
|
||||
t.Fatalf("connect A: %v", err)
|
||||
}
|
||||
defer a.Close()
|
||||
b, err := client.New(h.natsURL, h.ctrlURL, mustIdentity(t))
|
||||
if err != nil {
|
||||
t.Fatalf("connect B: %v", err)
|
||||
}
|
||||
defer b.Close()
|
||||
|
||||
roomID, err := a.CreateRoom("room.media", room.ModeMatrix)
|
||||
if err != nil {
|
||||
t.Fatalf("create room: %v", err)
|
||||
}
|
||||
if err := a.Invite(roomID, b.Endpoint()); err != nil {
|
||||
t.Fatalf("invite: %v", err)
|
||||
}
|
||||
if err := b.Join(roomID); err != nil {
|
||||
t.Fatalf("join: %v", err)
|
||||
}
|
||||
|
||||
gotBlob := make(chan []byte, 1)
|
||||
sub, err := b.Subscribe(roomID, func(f frame.Frame, _ []byte) {
|
||||
if f.Blob == nil {
|
||||
return
|
||||
}
|
||||
data, err := b.FetchMedia(roomID, f)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
gotBlob <- data
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
defer sub.Unsubscribe()
|
||||
time.Sleep(150 * time.Millisecond)
|
||||
|
||||
payload := []byte("a fake image payload that should be encrypted in the store")
|
||||
if err := a.PublishMedia(roomID, payload); err != nil {
|
||||
t.Fatalf("publish media: %v", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case got := <-gotBlob:
|
||||
if string(got) != string(payload) {
|
||||
t.Fatalf("media mismatch: got %q want %q", got, payload)
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("B never received/decrypted the media blob")
|
||||
}
|
||||
}
|
||||
|
||||
// ---- test helpers ---------------------------------------------------------
|
||||
|
||||
type collector struct {
|
||||
mu sync.Mutex
|
||||
msgs []string
|
||||
sub interface{ Unsubscribe() error }
|
||||
}
|
||||
|
||||
func subscribeCollect(t *testing.T, c *client.Client, roomID string) *collector {
|
||||
t.Helper()
|
||||
col := &collector{}
|
||||
sub, err := c.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) {
|
||||
col.mu.Lock()
|
||||
col.msgs = append(col.msgs, string(plaintext))
|
||||
col.mu.Unlock()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("subscribe: %v", err)
|
||||
}
|
||||
col.sub = sub
|
||||
return col
|
||||
}
|
||||
|
||||
func waitFor(mu *sync.Mutex, slice *[]string, pred func([]string) bool, timeout time.Duration) bool {
|
||||
deadline := time.Now().Add(timeout)
|
||||
for time.Now().Before(deadline) {
|
||||
mu.Lock()
|
||||
cp := append([]string(nil), (*slice)...)
|
||||
mu.Unlock()
|
||||
if pred(cp) {
|
||||
return true
|
||||
}
|
||||
time.Sleep(25 * time.Millisecond)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func snapshot(mu *sync.Mutex, slice *[]string) []string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return append([]string(nil), (*slice)...)
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
cs "fn-registry/functions/cybersecurity"
|
||||
|
||||
"github.com/oklog/ulid/v2"
|
||||
)
|
||||
|
||||
// newULID returns a fresh, lexicographically-sortable message id with
|
||||
// crypto/rand entropy.
|
||||
func newULID() string {
|
||||
return ulid.MustNew(ulid.Now(), rand.Reader).String()
|
||||
}
|
||||
|
||||
// identityFile is the on-disk JSON representation of an Identity. The four key
|
||||
// fields are base64-encoded.
|
||||
//
|
||||
// SECURITY: this file contains the peer's long-term PRIVATE keys (SignPriv and
|
||||
// KexPriv). It is written 0600. Losing it means losing the ability to decrypt
|
||||
// any message addressed to this endpoint — there is no recovery. Treat it like
|
||||
// an SSH private key. (Hardening with OS keyrings/HSM is a later phase.)
|
||||
type identityFile struct {
|
||||
SignPub string `json:"sign_pub"`
|
||||
SignPriv string `json:"sign_priv"`
|
||||
KexPub string `json:"kex_pub"`
|
||||
KexPriv string `json:"kex_priv"`
|
||||
}
|
||||
|
||||
// LoadOrCreateIdentity loads the identity at path, or generates and persists a
|
||||
// new one if the file does not exist. The file is written with 0600
|
||||
// permissions because it holds private keys.
|
||||
func LoadOrCreateIdentity(path string) (cs.Identity, error) {
|
||||
if data, err := os.ReadFile(path); err == nil {
|
||||
var f identityFile
|
||||
if err := json.Unmarshal(data, &f); err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("client: parse identity %q: %w", path, err)
|
||||
}
|
||||
id, err := f.toIdentity()
|
||||
if err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("client: decode identity %q: %w", path, err)
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
id, err := cs.GenerateIdentity()
|
||||
if err != nil {
|
||||
return cs.Identity{}, fmt.Errorf("client: generate identity: %w", err)
|
||||
}
|
||||
if err := saveIdentity(path, id); err != nil {
|
||||
return cs.Identity{}, err
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func saveIdentity(path string, id cs.Identity) error {
|
||||
if dir := filepath.Dir(path); dir != "" {
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("client: mkdir for identity: %w", err)
|
||||
}
|
||||
}
|
||||
f := identityFile{
|
||||
SignPub: base64.StdEncoding.EncodeToString(id.SignPub),
|
||||
SignPriv: base64.StdEncoding.EncodeToString(id.SignPriv),
|
||||
KexPub: base64.StdEncoding.EncodeToString(id.KexPub),
|
||||
KexPriv: base64.StdEncoding.EncodeToString(id.KexPriv),
|
||||
}
|
||||
data, err := json.MarshalIndent(f, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("client: marshal identity: %w", err)
|
||||
}
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
return fmt.Errorf("client: write identity %q: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f identityFile) toIdentity() (cs.Identity, error) {
|
||||
dec := func(s string) ([]byte, error) { return base64.StdEncoding.DecodeString(s) }
|
||||
signPub, err := dec(f.SignPub)
|
||||
if err != nil {
|
||||
return cs.Identity{}, err
|
||||
}
|
||||
signPriv, err := dec(f.SignPriv)
|
||||
if err != nil {
|
||||
return cs.Identity{}, err
|
||||
}
|
||||
kexPub, err := dec(f.KexPub)
|
||||
if err != nil {
|
||||
return cs.Identity{}, err
|
||||
}
|
||||
kexPriv, err := dec(f.KexPriv)
|
||||
if err != nil {
|
||||
return cs.Identity{}, err
|
||||
}
|
||||
return cs.Identity{SignPub: signPub, SignPriv: signPriv, KexPub: kexPub, KexPriv: kexPriv}, nil
|
||||
}
|
||||
Reference in New Issue
Block a user