feat: initial scaffold of unibus message bus (membership service + client lib + demo peers)
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
// Package frame defines the wire format of the unibus message bus.
|
||||
//
|
||||
// A Frame is the unit transported over NATS. It carries an envelope (type,
|
||||
// subject, sender, message id, epoch) plus an optional payload that, in
|
||||
// encrypted rooms, is an AEAD ciphertext. Frames may be signed end-to-end with
|
||||
// Ed25519 so that any receiver can authenticate the sender without trusting the
|
||||
// transport.
|
||||
//
|
||||
// v1 serializes frames as JSON for legibility and forward-compatibility. The
|
||||
// canonical signing bytes are the JSON of the frame with Sig cleared, so that
|
||||
// signature verification is independent of field ordering as long as the
|
||||
// marshaler is deterministic (encoding/json emits struct fields in declaration
|
||||
// order).
|
||||
package frame
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// FrameType enumerates the kinds of frames that travel over the bus.
|
||||
type FrameType uint8
|
||||
|
||||
const (
|
||||
// PUB is a published message to a room/subject.
|
||||
PUB FrameType = iota
|
||||
// INVITE announces a new member was invited (informational; the key
|
||||
// distribution itself happens over the control plane).
|
||||
INVITE
|
||||
// JOIN announces a member joined a room.
|
||||
JOIN
|
||||
// LEAVE announces a member voluntarily left a room.
|
||||
LEAVE
|
||||
// KICK announces a member was removed from a room.
|
||||
KICK
|
||||
// ACK acknowledges receipt of a previous frame.
|
||||
ACK
|
||||
)
|
||||
|
||||
// BlobRef references an out-of-band encrypted blob stored in the object store.
|
||||
// The bus never carries blob bytes; only this reference travels over NATS.
|
||||
type BlobRef struct {
|
||||
Hash string `json:"h"` // sha256 hex of the ciphertext in the blob store
|
||||
Nonce []byte `json:"n"` // AEAD nonce used to encrypt the blob
|
||||
Size int64 `json:"sz"` // size in bytes of the ciphertext
|
||||
}
|
||||
|
||||
// Frame is the unit of transport on the unibus message bus.
|
||||
type Frame struct {
|
||||
Type FrameType `json:"t"`
|
||||
Subject string `json:"s"`
|
||||
Sender string `json:"from"` // endpoint id = EndpointID(signPub)
|
||||
MsgID string `json:"id"` // ULID
|
||||
Epoch int `json:"e"` // epoch of the room key K used to encrypt
|
||||
Nonce []byte `json:"n,omitempty"` // AEAD nonce (encrypted rooms only)
|
||||
Payload []byte `json:"p,omitempty"` // AEAD ciphertext (or cleartext if the room does not encrypt)
|
||||
Blob *BlobRef `json:"b,omitempty"`
|
||||
Sig []byte `json:"sig,omitempty"` // Ed25519 signature over SigningBytes()
|
||||
}
|
||||
|
||||
// Marshal serializes the frame to its wire representation (JSON in v1).
|
||||
func (f Frame) Marshal() ([]byte, error) {
|
||||
return json.Marshal(f)
|
||||
}
|
||||
|
||||
// Unmarshal parses a wire representation back into a Frame.
|
||||
func Unmarshal(b []byte) (Frame, error) {
|
||||
var f Frame
|
||||
err := json.Unmarshal(b, &f)
|
||||
return f, err
|
||||
}
|
||||
|
||||
// EndpointID derives a stable, transport-agnostic endpoint identifier from an
|
||||
// Ed25519 signing public key: base64url(sha256(signPub)), unpadded.
|
||||
func EndpointID(signPub []byte) string {
|
||||
sum := sha256.Sum256(signPub)
|
||||
return base64.RawURLEncoding.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// SigningBytes returns the canonical bytes that are signed and verified. The
|
||||
// signature covers the entire frame except the Sig field itself, so we clear
|
||||
// Sig before marshaling. Errors are intentionally swallowed: the frame is a
|
||||
// plain struct of JSON-serializable fields, so json.Marshal cannot fail here.
|
||||
func (f Frame) SigningBytes() []byte {
|
||||
f.Sig = nil
|
||||
b, _ := json.Marshal(f)
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package frame
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMarshalUnmarshalRoundTrip(t *testing.T) {
|
||||
orig := Frame{
|
||||
Type: PUB,
|
||||
Subject: "room.general",
|
||||
Sender: "abc123",
|
||||
MsgID: "01J000000000000000000000",
|
||||
Epoch: 3,
|
||||
Nonce: []byte{1, 2, 3, 4},
|
||||
Payload: []byte("ciphertext-bytes"),
|
||||
Blob: &BlobRef{Hash: "deadbeef", Nonce: []byte{9, 8, 7}, Size: 42},
|
||||
Sig: []byte{0xaa, 0xbb},
|
||||
}
|
||||
|
||||
b, err := orig.Marshal()
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal: %v", err)
|
||||
}
|
||||
got, err := Unmarshal(b)
|
||||
if err != nil {
|
||||
t.Fatalf("Unmarshal: %v", err)
|
||||
}
|
||||
|
||||
if got.Type != orig.Type || got.Subject != orig.Subject || got.Sender != orig.Sender ||
|
||||
got.MsgID != orig.MsgID || got.Epoch != orig.Epoch {
|
||||
t.Fatalf("envelope mismatch: got %+v want %+v", got, orig)
|
||||
}
|
||||
if !bytes.Equal(got.Nonce, orig.Nonce) || !bytes.Equal(got.Payload, orig.Payload) || !bytes.Equal(got.Sig, orig.Sig) {
|
||||
t.Fatalf("byte fields mismatch")
|
||||
}
|
||||
if got.Blob == nil || got.Blob.Hash != orig.Blob.Hash || got.Blob.Size != orig.Blob.Size ||
|
||||
!bytes.Equal(got.Blob.Nonce, orig.Blob.Nonce) {
|
||||
t.Fatalf("blob ref mismatch: %+v", got.Blob)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpointIDDeterministic(t *testing.T) {
|
||||
pub := []byte("some-ed25519-public-key-bytes-32")
|
||||
a := EndpointID(pub)
|
||||
b := EndpointID(pub)
|
||||
if a != b {
|
||||
t.Fatalf("EndpointID not deterministic: %q != %q", a, b)
|
||||
}
|
||||
if a == "" {
|
||||
t.Fatalf("EndpointID returned empty string")
|
||||
}
|
||||
// Different inputs must produce different ids.
|
||||
if EndpointID([]byte("other-key")) == a {
|
||||
t.Fatalf("EndpointID collision for different inputs")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSigningBytesExcludesSig(t *testing.T) {
|
||||
withSig := Frame{Type: PUB, Subject: "s", Sender: "x", MsgID: "id", Epoch: 1, Payload: []byte("p"), Sig: []byte{1, 2, 3}}
|
||||
noSig := withSig
|
||||
noSig.Sig = nil
|
||||
|
||||
if !bytes.Equal(withSig.SigningBytes(), noSig.SigningBytes()) {
|
||||
t.Fatalf("SigningBytes should be identical regardless of Sig field")
|
||||
}
|
||||
// And SigningBytes must not be affected by mutating Sig afterward (value receiver).
|
||||
sb := withSig.SigningBytes()
|
||||
if bytes.Contains(sb, []byte{1, 2, 3}) {
|
||||
t.Fatalf("SigningBytes leaked the Sig bytes")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user