feat: initial scaffold of unibus message bus (membership service + client lib + demo peers)

This commit is contained in:
agent
2026-06-03 19:47:32 +02:00
commit cd02a52191
22 changed files with 2888 additions and 0 deletions
+32
View File
@@ -0,0 +1,32 @@
-- 001_init.sql — initial schema for the unibus membership/key-distribution service.
-- Additive and idempotent: safe to apply repeatedly. Never modify this file;
-- schema changes go in new numbered migrations (see .claude/rules/db_migrations.md).
CREATE TABLE IF NOT EXISTS rooms (
room_id TEXT PRIMARY KEY,
subject TEXT NOT NULL,
key_epoch INTEGER NOT NULL DEFAULT 1,
encrypt INTEGER NOT NULL,
persist INTEGER NOT NULL,
sign_msgs INTEGER NOT NULL,
owner_endpoint TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS members (
room_id TEXT NOT NULL,
endpoint TEXT NOT NULL,
role TEXT NOT NULL,
joined_at TEXT NOT NULL,
sign_pub BLOB NOT NULL,
kex_pub BLOB NOT NULL,
PRIMARY KEY (room_id, endpoint)
);
CREATE TABLE IF NOT EXISTS room_keys (
room_id TEXT NOT NULL,
epoch INTEGER NOT NULL,
endpoint TEXT NOT NULL,
sealed_key BLOB NOT NULL,
PRIMARY KEY (room_id, epoch, endpoint)
);
+342
View File
@@ -0,0 +1,342 @@
package membership
import (
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
cs "fn-registry/functions/cybersecurity"
"github.com/enmanuel/unibus/pkg/blobstore"
)
// Server is the HTTP control plane: the authoritative source of room metadata,
// membership, and per-epoch sealed keys. The data plane (messages) is NATS.
//
// Auth model (v1): mutating endpoints require an Ed25519 signature from the
// room owner over the canonical bytes of the request (the request body with the
// "sig" field cleared). v1 trusts the internal network: there is no TLS, no
// rate limiting, and read endpoints (GET) are unauthenticated. Hardening
// (mTLS, capabilities, rate limits) is a later phase.
type Server struct {
store *Store
blobs *blobstore.Store
mux *http.ServeMux
}
// NewServer wires the membership store and blob store into an http.Handler.
func NewServer(store *Store, blobs *blobstore.Store) *Server {
s := &Server{store: store, blobs: blobs, mux: http.NewServeMux()}
s.routes()
return s
}
// ServeHTTP satisfies http.Handler.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r) }
func (s *Server) routes() {
s.mux.HandleFunc("GET /healthz", s.handleHealth)
s.mux.HandleFunc("POST /rooms", s.handleCreateRoom)
s.mux.HandleFunc("POST /rooms/{id}/invite", s.handleInvite)
s.mux.HandleFunc("GET /rooms/{id}/key", s.handleGetKey)
s.mux.HandleFunc("GET /rooms/{id}/members", s.handleListMembers)
s.mux.HandleFunc("POST /rooms/{id}/rekey", s.handleRekey)
s.mux.HandleFunc("GET /rooms/{id}", s.handleGetRoom)
s.mux.HandleFunc("POST /blobs", s.handlePutBlob)
s.mux.HandleFunc("GET /blobs/{hash}", s.handleGetBlob)
}
// ---- wire types -----------------------------------------------------------
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"` // owner endpoint id
Sig []byte `json:"sig"` // Ed25519 over canonical(request with sig cleared)
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"`
}
// ---- helpers --------------------------------------------------------------
func writeJSON(w http.ResponseWriter, code int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(v)
}
func writeErr(w http.ResponseWriter, code int, msg string) {
writeJSON(w, code, map[string]string{"error": msg})
}
// canonicalSig returns the bytes to verify for a request: the request struct
// re-marshaled with its Sig field cleared. The caller passes a copy with Sig
// already zeroed. This is symmetric with how the client signs.
func canonicalSig(v any) []byte {
b, _ := json.Marshal(v)
return b
}
// verifyOwnerSig checks that sig is a valid Ed25519 signature by the room owner
// over canonical(reqWithSigCleared). It returns the owner Member on success.
func (s *Server) verifyOwnerSig(roomID, by string, sig, canonical []byte) (Member, error) {
info, err := s.store.GetRoom(roomID)
if err != nil {
return Member{}, fmt.Errorf("room not found")
}
if by != info.OwnerEndpoint {
return Member{}, fmt.Errorf("requester %q is not the room owner", by)
}
owner, err := s.store.GetMember(roomID, by)
if err != nil {
return Member{}, fmt.Errorf("owner member not found")
}
if !cs.VerifyEd25519(owner.SignPub, canonical, sig) {
return Member{}, fmt.Errorf("invalid owner signature")
}
return owner, nil
}
// ---- handlers -------------------------------------------------------------
func (s *Server) handleHealth(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (s *Server) handleCreateRoom(w http.ResponseWriter, r *http.Request) {
var req createRoomReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
return
}
if req.Subject == "" || req.Owner.Endpoint == "" {
writeErr(w, http.StatusBadRequest, "subject and owner.endpoint required")
return
}
roomID := newULID()
info := RoomInfo{
RoomID: roomID,
Subject: req.Subject,
Encrypt: req.Policy.Encrypt,
Persist: req.Policy.Persist,
SignMsgs: req.Policy.SignMsgs,
OwnerEndpoint: req.Owner.Endpoint,
}
if err := s.store.CreateRoom(info, req.Owner.SignPub, req.Owner.KexPub, req.SealedKeySelf); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusCreated, createRoomResp{RoomID: roomID})
}
func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
var req inviteReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
return
}
// Canonical bytes = the request with Sig cleared.
sig := req.Sig
req.Sig = nil
if _, err := s.verifyOwnerSig(roomID, req.By, sig, canonicalSig(req)); err != nil {
writeErr(w, http.StatusForbidden, err.Error())
return
}
info, err := s.store.GetRoom(roomID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
m := Member{
Endpoint: req.Member.Endpoint,
Role: "member",
SignPub: req.Member.SignPub,
KexPub: req.Member.KexPub,
}
if err := s.store.AddMember(roomID, m, info.Epoch, req.SealedKey); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "invited"})
}
func (s *Server) handleGetKey(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
endpoint := r.URL.Query().Get("endpoint")
if endpoint == "" {
writeErr(w, http.StatusBadRequest, "endpoint query param required")
return
}
epoch := 0
if e := r.URL.Query().Get("epoch"); e != "" {
if n, err := strconv.Atoi(e); err == nil {
epoch = n
}
}
ep, sealed, err := s.store.GetSealedKey(roomID, endpoint, epoch)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, keyResp{Epoch: ep, SealedKey: sealed})
}
func (s *Server) handleListMembers(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
members, err := s.store.ListMembers(roomID)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
out := make([]memberJSON, 0, len(members))
for _, m := range members {
out = append(out, memberJSON{Endpoint: m.Endpoint, Role: m.Role, SignPub: m.SignPub, KexPub: m.KexPub})
}
writeJSON(w, http.StatusOK, out)
}
func (s *Server) handleGetRoom(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
info, err := s.store.GetRoom(roomID)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
writeJSON(w, http.StatusOK, roomResp{
Subject: info.Subject,
Epoch: info.Epoch,
Policy: policyJSON{Encrypt: info.Encrypt, Persist: info.Persist, SignMsgs: info.SignMsgs},
})
}
func (s *Server) handleRekey(w http.ResponseWriter, r *http.Request) {
roomID := r.PathValue("id")
var req rekeyReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeErr(w, http.StatusBadRequest, "bad json: "+err.Error())
return
}
sig := req.Sig
req.Sig = nil
if _, err := s.verifyOwnerSig(roomID, req.By, sig, canonicalSig(req)); err != nil {
writeErr(w, http.StatusForbidden, err.Error())
return
}
if req.NewEpoch <= 0 {
writeErr(w, http.StatusBadRequest, "new_epoch must be > 0")
return
}
// Bump epoch, then store the fresh sealed keys for the remaining members,
// then remove the kicked/left members.
if err := s.store.BumpEpoch(roomID, req.NewEpoch); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
keys := make(map[string][]byte, len(req.Keys))
for _, k := range req.Keys {
keys[k.Endpoint] = k.SealedKey
}
if len(keys) > 0 {
if err := s.store.PutSealedKeys(roomID, req.NewEpoch, keys); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
}
for _, ep := range req.Remove {
if err := s.store.RemoveMember(roomID, ep); err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
}
writeJSON(w, http.StatusOK, map[string]any{"status": "rekeyed", "epoch": req.NewEpoch})
}
func (s *Server) handlePutBlob(w http.ResponseWriter, r *http.Request) {
data, err := io.ReadAll(r.Body)
if err != nil {
writeErr(w, http.StatusBadRequest, "read body: "+err.Error())
return
}
hash, err := s.blobs.Put(data)
if err != nil {
writeErr(w, http.StatusInternalServerError, err.Error())
return
}
writeJSON(w, http.StatusOK, blobResp{Hash: hash})
}
func (s *Server) handleGetBlob(w http.ResponseWriter, r *http.Request) {
hash := r.PathValue("hash")
if strings.ContainsAny(hash, "/\\.") {
writeErr(w, http.StatusBadRequest, "invalid hash")
return
}
data, err := s.blobs.Get(hash)
if err != nil {
writeErr(w, http.StatusNotFound, err.Error())
return
}
w.Header().Set("Content-Type", "application/octet-stream")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(data)
}
+289
View File
@@ -0,0 +1,289 @@
// Package membership implements the authoritative control plane of unibus:
// room metadata, member directory, and per-epoch sealed room keys.
//
// The data plane (actual messages) is NATS; this package owns the SQLite-backed
// state that NATS cannot: who is in a room, what their public keys are, and the
// encrypted room key K each member needs to participate at a given epoch.
//
// Migrations are embedded and applied idempotently on Open. The embedded copy
// under pkg/membership/migrations mirrors the module-root migrations/ directory
// (kept in sync); both are additive-only per .claude/rules/db_migrations.md.
package membership
import (
"database/sql"
"embed"
"fmt"
"io/fs"
"sort"
"strings"
"time"
// modernc.org/sqlite registers the pure-Go "sqlite" driver (no CGO).
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
// Member is a participant of a room with their published public keys.
type Member struct {
Endpoint string `json:"endpoint"`
Role string `json:"role"`
SignPub []byte `json:"sign_pub"`
KexPub []byte `json:"kex_pub"`
}
// RoomInfo is the metadata of a room.
type RoomInfo struct {
RoomID string
Subject string
Epoch int
Encrypt bool
Persist bool
SignMsgs bool
OwnerEndpoint string
}
// Store is the SQLite-backed membership/key store.
type Store struct {
db *sql.DB
}
// Open opens (creating if needed) the SQLite database at path and applies all
// embedded migrations idempotently.
func Open(path string) (*Store, error) {
// _pragma busy_timeout avoids spurious "database is locked" under concurrent
// HTTP handlers; foreign_keys kept off — we manage referential integrity in code.
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("membership: open db: %w", err)
}
if err := db.Ping(); err != nil {
db.Close()
return nil, fmt.Errorf("membership: ping db: %w", err)
}
s := &Store{db: db}
if err := s.applyMigrations(); err != nil {
db.Close()
return nil, err
}
return s, nil
}
// Close closes the underlying database.
func (s *Store) Close() error { return s.db.Close() }
// applyMigrations runs every embedded migration in lexical order, tolerating
// the "already applied" errors that SQLite's non-idempotent DDL produces.
func (s *Store) applyMigrations() error {
files, err := fs.Glob(migrationsFS, "migrations/*.sql")
if err != nil {
return fmt.Errorf("membership: glob migrations: %w", err)
}
sort.Strings(files)
for _, f := range files {
b, err := migrationsFS.ReadFile(f)
if err != nil {
return fmt.Errorf("membership: read %s: %w", f, err)
}
if _, err := s.db.Exec(string(b)); err != nil {
msg := err.Error()
if !strings.Contains(msg, "duplicate column") && !strings.Contains(msg, "already exists") {
return fmt.Errorf("membership: apply %s: %w", f, err)
}
}
}
return nil
}
func nowRFC3339() string { return time.Now().UTC().Format(time.RFC3339Nano) }
// CreateRoom inserts a room at epoch 1, registers the owner as a member with
// role "owner", and stores the owner's sealed key for epoch 1. Idempotent
// inserts are not used: a duplicate room_id returns an error.
func (s *Store) CreateRoom(info RoomInfo, ownerSignPub, ownerKexPub, ownerSealedKey []byte) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("membership: begin: %w", err)
}
defer tx.Rollback()
now := nowRFC3339()
if _, err := tx.Exec(
`INSERT INTO rooms (room_id, subject, key_epoch, encrypt, persist, sign_msgs, owner_endpoint, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
info.RoomID, info.Subject, 1,
b2i(info.Encrypt), b2i(info.Persist), b2i(info.SignMsgs),
info.OwnerEndpoint, now,
); err != nil {
return fmt.Errorf("membership: insert room: %w", err)
}
if _, err := tx.Exec(
`INSERT INTO members (room_id, endpoint, role, joined_at, sign_pub, kex_pub)
VALUES (?, ?, 'owner', ?, ?, ?)`,
info.RoomID, info.OwnerEndpoint, now, ownerSignPub, ownerKexPub,
); err != nil {
return fmt.Errorf("membership: insert owner member: %w", err)
}
if info.Encrypt {
if _, err := tx.Exec(
`INSERT INTO room_keys (room_id, epoch, endpoint, sealed_key) VALUES (?, 1, ?, ?)`,
info.RoomID, info.OwnerEndpoint, ownerSealedKey,
); err != nil {
return fmt.Errorf("membership: insert owner key: %w", err)
}
}
return tx.Commit()
}
// GetRoom returns room metadata (including current epoch).
func (s *Store) GetRoom(roomID string) (RoomInfo, error) {
var info RoomInfo
var enc, per, sgn int
err := s.db.QueryRow(
`SELECT room_id, subject, key_epoch, encrypt, persist, sign_msgs, owner_endpoint
FROM rooms WHERE room_id = ?`, roomID,
).Scan(&info.RoomID, &info.Subject, &info.Epoch, &enc, &per, &sgn, &info.OwnerEndpoint)
if err != nil {
return RoomInfo{}, fmt.Errorf("membership: get room %q: %w", roomID, err)
}
info.Encrypt, info.Persist, info.SignMsgs = enc != 0, per != 0, sgn != 0
return info, nil
}
// AddMember inserts a member at the given role and stores their sealed key for
// the supplied epoch.
func (s *Store) AddMember(roomID string, m Member, epoch int, sealedKey []byte) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("membership: begin: %w", err)
}
defer tx.Rollback()
now := nowRFC3339()
if _, err := tx.Exec(
`INSERT INTO members (room_id, endpoint, role, joined_at, sign_pub, kex_pub)
VALUES (?, ?, ?, ?, ?, ?)`,
roomID, m.Endpoint, m.Role, now, m.SignPub, m.KexPub,
); err != nil {
return fmt.Errorf("membership: insert member: %w", err)
}
if len(sealedKey) > 0 {
if _, err := tx.Exec(
`INSERT INTO room_keys (room_id, epoch, endpoint, sealed_key) VALUES (?, ?, ?, ?)`,
roomID, epoch, m.Endpoint, sealedKey,
); err != nil {
return fmt.Errorf("membership: insert member key: %w", err)
}
}
return tx.Commit()
}
// GetMember returns a single member of a room.
func (s *Store) GetMember(roomID, endpoint string) (Member, error) {
var m Member
err := s.db.QueryRow(
`SELECT endpoint, role, sign_pub, kex_pub FROM members WHERE room_id = ? AND endpoint = ?`,
roomID, endpoint,
).Scan(&m.Endpoint, &m.Role, &m.SignPub, &m.KexPub)
if err != nil {
return Member{}, fmt.Errorf("membership: get member %q/%q: %w", roomID, endpoint, err)
}
return m, nil
}
// ListMembers returns all members of a room ordered by endpoint.
func (s *Store) ListMembers(roomID string) ([]Member, error) {
rows, err := s.db.Query(
`SELECT endpoint, role, sign_pub, kex_pub FROM members WHERE room_id = ? ORDER BY endpoint`,
roomID,
)
if err != nil {
return nil, fmt.Errorf("membership: list members %q: %w", roomID, err)
}
defer rows.Close()
var out []Member
for rows.Next() {
var m Member
if err := rows.Scan(&m.Endpoint, &m.Role, &m.SignPub, &m.KexPub); err != nil {
return nil, fmt.Errorf("membership: scan member: %w", err)
}
out = append(out, m)
}
return out, rows.Err()
}
// GetSealedKey returns the sealed room key for an endpoint at a given epoch.
// If epoch <= 0, the latest epoch for that endpoint is returned.
func (s *Store) GetSealedKey(roomID, endpoint string, epoch int) (int, []byte, error) {
var ep int
var sealed []byte
var err error
if epoch <= 0 {
err = s.db.QueryRow(
`SELECT epoch, sealed_key FROM room_keys
WHERE room_id = ? AND endpoint = ? ORDER BY epoch DESC LIMIT 1`,
roomID, endpoint,
).Scan(&ep, &sealed)
} else {
err = s.db.QueryRow(
`SELECT epoch, sealed_key FROM room_keys
WHERE room_id = ? AND endpoint = ? AND epoch = ?`,
roomID, endpoint, epoch,
).Scan(&ep, &sealed)
}
if err != nil {
return 0, nil, fmt.Errorf("membership: get sealed key %q/%q@%d: %w", roomID, endpoint, epoch, err)
}
return ep, sealed, nil
}
// PutSealedKeys stores a batch of sealed keys for the given epoch (endpoint ->
// sealed bytes), upserting on conflict so a rekey can overwrite stale entries.
func (s *Store) PutSealedKeys(roomID string, epoch int, keys map[string][]byte) error {
tx, err := s.db.Begin()
if err != nil {
return fmt.Errorf("membership: begin: %w", err)
}
defer tx.Rollback()
for endpoint, sealed := range keys {
if _, err := tx.Exec(
`INSERT INTO room_keys (room_id, epoch, endpoint, sealed_key) VALUES (?, ?, ?, ?)
ON CONFLICT(room_id, epoch, endpoint) DO UPDATE SET sealed_key = excluded.sealed_key`,
roomID, epoch, endpoint, sealed,
); err != nil {
return fmt.Errorf("membership: put sealed key for %q: %w", endpoint, err)
}
}
return tx.Commit()
}
// BumpEpoch sets the room's current key_epoch to newEpoch.
func (s *Store) BumpEpoch(roomID string, newEpoch int) error {
if _, err := s.db.Exec(`UPDATE rooms SET key_epoch = ? WHERE room_id = ?`, newEpoch, roomID); err != nil {
return fmt.Errorf("membership: bump epoch %q->%d: %w", roomID, newEpoch, err)
}
return nil
}
// RemoveMember deletes a member from a room. Their sealed keys for past epochs
// are left intact (they encrypt only data that member could already read).
func (s *Store) RemoveMember(roomID, endpoint string) error {
if _, err := s.db.Exec(`DELETE FROM members WHERE room_id = ? AND endpoint = ?`, roomID, endpoint); err != nil {
return fmt.Errorf("membership: remove member %q/%q: %w", roomID, endpoint, err)
}
return nil
}
func b2i(b bool) int {
if b {
return 1
}
return 0
}
+142
View File
@@ -0,0 +1,142 @@
package membership
import (
"bytes"
"path/filepath"
"testing"
)
func openTestStore(t *testing.T) *Store {
t.Helper()
path := filepath.Join(t.TempDir(), "test.db")
s, err := Open(path)
if err != nil {
t.Fatalf("Open: %v", err)
}
t.Cleanup(func() { s.Close() })
return s
}
func TestMigrationsCreateSchema(t *testing.T) {
s := openTestStore(t)
// Verify the three tables exist by querying sqlite_master.
for _, tbl := range []string{"rooms", "members", "room_keys"} {
var name string
err := s.db.QueryRow(
`SELECT name FROM sqlite_master WHERE type='table' AND name=?`, tbl,
).Scan(&name)
if err != nil {
t.Fatalf("table %q not created: %v", tbl, err)
}
}
// Re-applying migrations must be idempotent (no error on a populated db).
if err := s.applyMigrations(); err != nil {
t.Fatalf("re-apply migrations: %v", err)
}
}
func TestRoomMemberKeyRoundTrip(t *testing.T) {
s := openTestStore(t)
owner := "owner-ep"
roomID := "room-1"
info := RoomInfo{
RoomID: roomID,
Subject: "room.test",
Encrypt: true,
Persist: true,
SignMsgs: true,
OwnerEndpoint: owner,
}
ownerSealed := []byte("owner-sealed-key-epoch1")
if err := s.CreateRoom(info, []byte("owner-sign"), []byte("owner-kex"), ownerSealed); err != nil {
t.Fatalf("CreateRoom: %v", err)
}
// GetRoom returns epoch 1 and the policy.
got, err := s.GetRoom(roomID)
if err != nil {
t.Fatalf("GetRoom: %v", err)
}
if got.Epoch != 1 || !got.Encrypt || !got.Persist || !got.SignMsgs || got.OwnerEndpoint != owner {
t.Fatalf("GetRoom mismatch: %+v", got)
}
// Owner sealed key at epoch 1 (latest).
ep, sealed, err := s.GetSealedKey(roomID, owner, 0)
if err != nil {
t.Fatalf("GetSealedKey owner: %v", err)
}
if ep != 1 || !bytes.Equal(sealed, ownerSealed) {
t.Fatalf("owner sealed key mismatch: epoch=%d sealed=%q", ep, sealed)
}
// Add member at epoch 1.
member := Member{Endpoint: "member-ep", Role: "member", SignPub: []byte("m-sign"), KexPub: []byte("m-kex")}
memberSealed := []byte("member-sealed-epoch1")
if err := s.AddMember(roomID, member, 1, memberSealed); err != nil {
t.Fatalf("AddMember: %v", err)
}
gotMember, err := s.GetMember(roomID, "member-ep")
if err != nil {
t.Fatalf("GetMember: %v", err)
}
if gotMember.Role != "member" || !bytes.Equal(gotMember.SignPub, []byte("m-sign")) {
t.Fatalf("GetMember mismatch: %+v", gotMember)
}
members, err := s.ListMembers(roomID)
if err != nil {
t.Fatalf("ListMembers: %v", err)
}
if len(members) != 2 {
t.Fatalf("expected 2 members, got %d", len(members))
}
// Bump to epoch 2 and store new keys only for the owner (simulating a kick of member-ep).
if err := s.BumpEpoch(roomID, 2); err != nil {
t.Fatalf("BumpEpoch: %v", err)
}
newKeys := map[string][]byte{owner: []byte("owner-sealed-epoch2")}
if err := s.PutSealedKeys(roomID, 2, newKeys); err != nil {
t.Fatalf("PutSealedKeys: %v", err)
}
if err := s.RemoveMember(roomID, "member-ep"); err != nil {
t.Fatalf("RemoveMember: %v", err)
}
got, err = s.GetRoom(roomID)
if err != nil {
t.Fatalf("GetRoom after bump: %v", err)
}
if got.Epoch != 2 {
t.Fatalf("expected epoch 2, got %d", got.Epoch)
}
// Owner now has a fresh sealed key at epoch 2 (latest).
ep, sealed, err = s.GetSealedKey(roomID, owner, 0)
if err != nil {
t.Fatalf("GetSealedKey owner epoch2: %v", err)
}
if ep != 2 || !bytes.Equal(sealed, []byte("owner-sealed-epoch2")) {
t.Fatalf("owner epoch2 key mismatch: epoch=%d sealed=%q", ep, sealed)
}
// The removed member is gone.
if _, err := s.GetMember(roomID, "member-ep"); err == nil {
t.Fatalf("expected error getting removed member")
}
// The kicked member has no key at epoch 2 (was excluded from the rekey).
if _, _, err := s.GetSealedKey(roomID, "member-ep", 2); err == nil {
t.Fatalf("kicked member should have no key at epoch 2")
}
members, err = s.ListMembers(roomID)
if err != nil {
t.Fatalf("ListMembers after remove: %v", err)
}
if len(members) != 1 || members[0].Endpoint != owner {
t.Fatalf("expected only owner remaining, got %+v", members)
}
}
+13
View File
@@ -0,0 +1,13 @@
package membership
import (
"crypto/rand"
"github.com/oklog/ulid/v2"
)
// newULID returns a fresh, lexicographically-sortable unique id used for room
// ids. It uses crypto/rand entropy so ids are unguessable and collision-free.
func newULID() string {
return ulid.MustNew(ulid.Now(), rand.Reader).String()
}