From 36f4ba0eaf79d0807f76ebd9ab23a194404f1dcf Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 22:11:39 +0200 Subject: [PATCH 1/4] feat(embeddednats): optional WebSocket listener for browser clients MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add WebsocketConfig to ServerConfig so the embedded nats-server can expose an additional WebSocket port (nats.ws) alongside the TCP data plane. This lets a browser SPA speak the NATS protocol directly, the way native TCP peers (Go, Kotlin/android) already do — the first enabler for uniweb becoming a browser-native client with no Go gateway (issue uniweb/0001, Phase 0). The client authenticator applies to WebSocket connections too, so this adds a transport, not a trust bypass. Plain ws:// is used only without TLS (loopback dev); a certificate yields wss://. An empty AllowedOrigins enforces same-origin. Nil WebsocketConfig keeps the server TCP-only, so existing single-node and cluster deployments are unchanged. Tests: WebSocket listener opens and completes the upgrade handshake (101); no listener opens when WebsocketConfig is nil. --- pkg/embeddednats/embeddednats.go | 59 ++++++++++++++++ pkg/embeddednats/websocket_test.go | 108 +++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 pkg/embeddednats/websocket_test.go diff --git a/pkg/embeddednats/embeddednats.go b/pkg/embeddednats/embeddednats.go index 9cd02c6c..a6acc119 100644 --- a/pkg/embeddednats/embeddednats.go +++ b/pkg/embeddednats/embeddednats.go @@ -79,6 +79,42 @@ type ServerConfig struct { // availability (issue 0003a). Nil keeps the server standalone (the legacy // single-node behavior). Cluster *ClusterConfig + // Websocket, when non-nil, opens an ADDITIONAL WebSocket listener on the + // embedded nats-server so browser clients (nats.ws) can reach the data plane + // directly, the same way native TCP peers (Go, Kotlin) do (issue uniweb/0001). + // Native TCP clients are unaffected: the WebSocket listener is a separate port + // layered on top of the existing TCP listener, and the client authenticator + // (Auth) applies to both. Nil keeps the server TCP-only (legacy behavior). + Websocket *WebsocketConfig +} + +// WebsocketConfig configures the embedded nats-server's WebSocket listener so a +// browser can speak the NATS protocol over ws://. A browser cannot open a raw TCP +// socket, so this is the only way the SPA reaches the data plane without a Go +// gateway in between. +// +// Security: off loopback a browser requires wss:// (TLS) — set TLS with a +// certificate the browser trusts. NoTLS plain ws:// is acceptable only for a +// loopback dev stack. The WebSocket upgrade also enforces an Origin allowlist +// (browser same-origin policy); AllowedOrigins must list the SPA's origins or the +// browser handshake is refused. +type WebsocketConfig struct { + // Host is the bind interface for the WebSocket listener; "" lets nats-server + // pick its default. Use "127.0.0.1" to keep it loopback-only in dev. + Host string + // Port is the WebSocket listen port (e.g. 8480). Required (non-zero) for the + // listener to open. + Port int + // NoTLS serves plain ws:// instead of wss://. Loopback/dev only: browsers refuse + // ws:// to a non-loopback origin. Ignored when TLS is set (TLS implies wss://). + NoTLS bool + // TLS, when set, serves wss:// with this certificate. Required for any browser + // origin that is not loopback. + TLS *tls.Config + // AllowedOrigins is the allowlist of browser Origin headers permitted to upgrade + // the WebSocket. Empty = same-origin only (nats-server SameOrigin). Never use a + // wildcard in production; list the exact SPA origins. + AllowedOrigins []string } // Start is a thin backward-compatible wrapper: embedded JetStream server on the @@ -170,6 +206,29 @@ func StartServer(cfg ServerConfig) (*server.Server, error) { opts.TLS = true } + if cfg.Websocket != nil { + // Layer a WebSocket listener on top of the TCP data plane so browser + // clients (nats.ws) can connect. The client authenticator (opts.*Auth above) + // applies to WebSocket connections too, so a browser still has to pass the + // nkey + allowlist check; this only adds a transport, not a trust bypass. + ws := server.WebsocketOpts{ + Host: cfg.Websocket.Host, + Port: cfg.Websocket.Port, + AllowedOrigins: cfg.Websocket.AllowedOrigins, + } + if cfg.Websocket.TLS != nil { + ws.TLSConfig = cfg.Websocket.TLS + } else { + // No certificate: plain ws:// (loopback/dev only). Browsers refuse this + // off-loopback, which is the intended guard rail. + ws.NoTLS = true + } + // Empty AllowedOrigins means "same-origin only": tell nats-server to enforce + // it rather than defaulting to accept-any-origin. + ws.SameOrigin = len(cfg.Websocket.AllowedOrigins) == 0 + opts.Websocket = ws + } + if cfg.Cluster != nil { if err := applyClusterOpts(opts, cfg.Cluster); err != nil { return nil, err diff --git a/pkg/embeddednats/websocket_test.go b/pkg/embeddednats/websocket_test.go new file mode 100644 index 00000000..162a8949 --- /dev/null +++ b/pkg/embeddednats/websocket_test.go @@ -0,0 +1,108 @@ +package embeddednats_test + +import ( + "fmt" + "net" + "net/http" + "strings" + "testing" + "time" + + "github.com/enmanuel/unibus/pkg/embeddednats" +) + +// wsFreePort returns an OS-assigned free TCP port on loopback. Kept local to this +// file so the WebSocket tests do not depend on the cluster test helpers. +func wsFreePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("reserve free port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// TestWebsocketListenerOpens verifies that when a ServerConfig carries a +// WebsocketConfig the embedded nats-server opens the additional WebSocket port and +// accepts a connection there, while the regular TCP client port keeps working. A +// browser cannot speak raw TCP, so this WebSocket listener is the only path the SPA +// has to the data plane (issue uniweb/0001). +func TestWebsocketListenerOpens(t *testing.T) { + clientPort := wsFreePort(t) + wsPort := wsFreePort(t) + + ns, err := embeddednats.StartServer(embeddednats.ServerConfig{ + StoreDir: t.TempDir(), + Host: "127.0.0.1", + Port: clientPort, + Websocket: &embeddednats.WebsocketConfig{ + Host: "127.0.0.1", + Port: wsPort, + NoTLS: true, // loopback dev: plain ws:// + }, + }) + if err != nil { + t.Fatalf("StartServer with websocket: %v", err) + } + t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() }) + + // The WebSocket listener must accept a TCP connection on its dedicated port. + addr := fmt.Sprintf("127.0.0.1:%d", wsPort) + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + if err != nil { + t.Fatalf("websocket port %d not accepting connections: %v", wsPort, err) + } + conn.Close() + + // And it must speak the WebSocket upgrade handshake: a GET with the upgrade + // headers should get a 101 Switching Protocols (nats-server's ws endpoint), + // proving it is a real WebSocket listener, not just an open socket. + req, err := http.NewRequest(http.MethodGet, "http://"+addr+"/", nil) + if err != nil { + t.Fatalf("build upgrade request: %v", err) + } + req.Header.Set("Upgrade", "websocket") + req.Header.Set("Connection", "Upgrade") + req.Header.Set("Sec-WebSocket-Version", "13") + req.Header.Set("Sec-WebSocket-Key", "dGhlIHNhbXBsZSBub25jZQ==") + + client := &http.Client{Timeout: 2 * time.Second} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("websocket upgrade request: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusSwitchingProtocols { + t.Fatalf("websocket upgrade: got status %d, want 101 Switching Protocols", resp.StatusCode) + } +} + +// TestNoWebsocketByDefault verifies the listener stays TCP-only when WebsocketConfig +// is nil: opening the browser transport must be an explicit opt-in so existing +// single-node and cluster deployments are unchanged. +func TestNoWebsocketByDefault(t *testing.T) { + clientPort := wsFreePort(t) + // Reserve a port, then free it, so we can assert nothing is listening there. + maybeWSPort := wsFreePort(t) + + ns, err := embeddednats.StartServer(embeddednats.ServerConfig{ + StoreDir: t.TempDir(), + Host: "127.0.0.1", + Port: clientPort, + // Websocket intentionally nil. + }) + if err != nil { + t.Fatalf("StartServer: %v", err) + } + t.Cleanup(func() { ns.Shutdown(); ns.WaitForShutdown() }) + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", maybeWSPort), 300*time.Millisecond) + if err == nil { + conn.Close() + t.Fatalf("a listener is unexpectedly open on %d with no WebsocketConfig", maybeWSPort) + } + if !strings.Contains(err.Error(), "refused") && !strings.Contains(err.Error(), "timeout") { + t.Logf("dial error (acceptable, port closed): %v", err) + } +} From ec8d34aaa1cc42a6cfd426223bd5c0b939e13c4a Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 22:17:44 +0200 Subject: [PATCH 2/4] feat(membership): opt-in CORS allowlist for the browser-native client Add Server.AllowedOrigins and an applyCORS step at the top of ServeHTTP so a browser SPA (uniweb) can call the control plane cross-origin: an allow-listed Origin gets the Access-Control-Allow-* headers, and a preflight (OPTIONS) is answered 204 before the rate limiter and auth ever run. A disallowed or missing origin gets no headers (preflight 403), so the browser blocks the request. Wire it through membershipd's --cors-origins flag (comma list, reusing splitRoutes as a generic parser). Empty allowlist = CORS off, no headers emitted, behavior identical to before: native Go/Kotlin clients send no Origin and are unaffected. Opt-in per deployment (issue uniweb/0001, Phase 0). Tests: preflight allow/deny, header on the real response, CORS-off default, and no-Origin native client unaffected. --- cmd/membershipd/main.go | 9 +++ pkg/membership/cors_test.go | 150 ++++++++++++++++++++++++++++++++++++ pkg/membership/server.go | 67 ++++++++++++++++ 3 files changed, 226 insertions(+) create mode 100644 pkg/membership/cors_test.go diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index 7637c0f9..a252e75d 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -13,6 +13,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -56,6 +57,7 @@ func main() { natsPort = flag.Int("nats-port", 4250, "embedded NATS listen port (when --nats-url empty)") natsStore = flag.String("nats-store", "./local_files/jetstream", "embedded JetStream store dir") busAuth = flag.String("bus-auth", "off", "control-plane auth rollout: off|soft|enforce (feature flag bus-auth)") + corsOrigins = flag.String("cors-origins", "", "comma-separated CORS allowlist of browser origins permitted to call the control plane (e.g. http://localhost:5173,https://chat.example.com); empty = CORS off. Enables the browser-native uniweb client (issue uniweb/0001)") tlsCert = flag.String("tls-cert", "", "PATH to the NATS server certificate (deploy/tls/server.crt); enables TLS on the embedded data plane") tlsKey = flag.String("tls-key", "", "path to the NATS server private key (deploy/tls/server.key); required with --tls-cert") // Cluster (issue 0003a): empty --cluster-name keeps the server standalone. @@ -329,6 +331,13 @@ func main() { Cluster: clustered, Store: *storeBackend, } + // CORS allowlist for the browser-native client (uniweb). splitRoutes is reused + // as a generic comma-list parser (trim + drop empties). Empty flag => empty + // slice => CORS stays off, identical to the pre-flag behavior. + if origins := splitRoutes(*corsOrigins); len(origins) > 0 { + srv.AllowedOrigins = origins + log.Printf("CORS: allowing %d browser origin(s): %s", len(origins), strings.Join(origins, ", ")) + } // Replicated anti-replay (issue 0006a, audit 0008 N3): a clustered node MUST // share its nonce store across the cluster, or a request accepted on one node diff --git a/pkg/membership/cors_test.go b/pkg/membership/cors_test.go new file mode 100644 index 00000000..a1786300 --- /dev/null +++ b/pkg/membership/cors_test.go @@ -0,0 +1,150 @@ +package membership_test + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/enmanuel/unibus/pkg/blobstore" + "github.com/enmanuel/unibus/pkg/membership" +) + +// newCORSServer builds a control-plane server with the given CORS allowlist over a +// throwaway store, and returns a live httptest server. /healthz is auth-exempt, so +// the CORS tests can exercise the cross-origin pipeline without signing requests. +func newCORSServer(t *testing.T, origins ...string) *httptest.Server { + t.Helper() + dir := t.TempDir() + store, err := membership.Open(filepath.Join(dir, "unibus.db")) + if err != nil { + t.Fatalf("store: %v", err) + } + t.Cleanup(func() { store.Close() }) + blobs, _ := blobstore.New(filepath.Join(dir, "blobs")) + + srv := membership.NewServer(store, blobs, membership.AuthOff) + srv.AllowedOrigins = origins + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + return ts +} + +// TestCORSPreflightAllowedOrigin: a preflight (OPTIONS) from an allow-listed origin +// is answered 204 with the Access-Control headers, and never reaches auth. This is +// what lets the browser-native uniweb client call the control plane (issue +// uniweb/0001). +func TestCORSPreflightAllowedOrigin(t *testing.T) { + const origin = "http://localhost:5173" + ts := newCORSServer(t, origin) + + req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil) + req.Header.Set("Origin", origin) + req.Header.Set("Access-Control-Request-Method", "POST") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("preflight: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusNoContent { + t.Fatalf("preflight status = %d, want 204", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("Allow-Origin = %q, want %q", got, origin) + } + if got := resp.Header.Get("Access-Control-Allow-Methods"); got == "" { + t.Fatalf("Allow-Methods missing on preflight") + } +} + +// TestCORSPreflightDisallowedOrigin: a preflight from an origin NOT in the allowlist +// gets 403 and no Access-Control headers, so the browser blocks the real request. +func TestCORSPreflightDisallowedOrigin(t *testing.T) { + ts := newCORSServer(t, "http://localhost:5173") + + req, _ := http.NewRequest(http.MethodOptions, ts.URL+"/rooms", nil) + req.Header.Set("Origin", "https://evil.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("preflight: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("disallowed preflight status = %d, want 403", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Allow-Origin leaked for disallowed origin: %q", got) + } +} + +// TestCORSActualRequestCarriesHeader: a real GET from an allow-listed origin is +// served normally AND carries the Allow-Origin header so the browser accepts the +// response. +func TestCORSActualRequestCarriesHeader(t *testing.T) { + const origin = "http://localhost:5173" + ts := newCORSServer(t, origin) + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil) + req.Header.Set("Origin", origin) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != origin { + t.Fatalf("Allow-Origin = %q, want %q", got, origin) + } +} + +// TestCORSDisabledByDefault: with an empty allowlist no Access-Control header is +// ever emitted (CORS off) and requests behave exactly as before. This guards the +// opt-in invariant: untouched deployments are unaffected. +func TestCORSDisabledByDefault(t *testing.T) { + ts := newCORSServer(t) // no origins + + req, _ := http.NewRequest(http.MethodGet, ts.URL+"/healthz", nil) + req.Header.Set("Origin", "http://localhost:5173") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Allow-Origin emitted with CORS off: %q", got) + } +} + +// TestCORSNativeClientUnaffected: a request with no Origin header (a native Go/Kotlin +// client) is processed normally and gets no CORS headers, even when an allowlist is +// configured. +func TestCORSNativeClientUnaffected(t *testing.T) { + ts := newCORSServer(t, "http://localhost:5173") + + resp, err := http.Get(ts.URL + "/healthz") // no Origin header + if err != nil { + t.Fatalf("get: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("healthz status = %d, want 200", resp.StatusCode) + } + if got := resp.Header.Get("Access-Control-Allow-Origin"); got != "" { + t.Fatalf("Allow-Origin set for a no-Origin native client: %q", got) + } +} diff --git a/pkg/membership/server.go b/pkg/membership/server.go index 198b0efb..08e82589 100644 --- a/pkg/membership/server.go +++ b/pkg/membership/server.go @@ -87,6 +87,16 @@ type Server struct { // posture a secure cluster requires (audit 0008 N1). It is set by the command; // the zero value (all false) reflects an unsecured dev node. Posture Posture + + // AllowedOrigins is the CORS allowlist of browser Origin headers permitted to + // call the control plane cross-origin. It exists so a browser-native client + // (uniweb) can talk to membershipd directly, the way the Go/Kotlin clients + // already do over a non-browser transport (issue uniweb/0001). Native clients + // send no Origin header and are unaffected. The zero value (empty) keeps CORS + // OFF — no Access-Control headers are emitted and the server behaves exactly as + // before — so this is opt-in per deployment. Entries are matched exactly (scheme + // + host + port); never use "*" with credentials. Set by the command from a flag. + AllowedOrigins []string } // Posture describes the security posture a membershipd node runs with. It is @@ -143,6 +153,15 @@ func (s *Server) UseReplicatedNonces(js jetstream.JetStream, replicas int) error func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { now := time.Now() + // CORS runs before everything else so a browser preflight never pays the rate + // limit or auth cost. When the request carries an allowed Origin we echo the + // Access-Control headers; a preflight (OPTIONS) is answered here and short- + // circuits the pipeline. With an empty allowlist this is a no-op, so non-browser + // clients and untouched deployments behave exactly as before (issue uniweb/0001). + if s.applyCORS(w, r) { + return // preflight handled + } + // Per-IP rate limit runs first, ahead of auth and body reads, so a flood is // shed at the cheapest possible point. The health probe is exempt so liveness // checks are never throttled. @@ -221,6 +240,54 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(w, r.WithContext(withSigner(r.Context(), res.endpoint, res.pubHex))) } +// applyCORS handles cross-origin requests for the control plane. When the request +// carries an Origin in the allowlist it sets the Access-Control-Allow-* response +// headers so the browser accepts the eventual response; when the request is a CORS +// preflight (OPTIONS) it writes the preflight reply and returns true so ServeHTTP +// short-circuits before the rate limiter and auth ever run. It returns false for +// every non-preflight request — including same-origin and native clients that send +// no Origin header — leaving the normal pipeline to run unchanged. With an empty +// AllowedOrigins it never sets a header (CORS is off): the opt-in default. +func (s *Server) applyCORS(w http.ResponseWriter, r *http.Request) (preflight bool) { + origin := r.Header.Get("Origin") + allowed := origin != "" && s.originAllowed(origin) + if allowed { + h := w.Header() + h.Set("Access-Control-Allow-Origin", origin) + // Vary: Origin so a cache never serves an allow-listed response to another + // origin. Add (not Set) to preserve any Vary the handler may add later. + h.Add("Vary", "Origin") + h.Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + h.Set("Access-Control-Allow-Headers", "Content-Type, Authorization") + h.Set("Access-Control-Max-Age", "600") + } + if r.Method == http.MethodOptions { + // Answer the preflight here so it never reaches the rate limiter or auth. An + // allowed origin gets 204 with the headers above; a disallowed or missing + // origin gets 403 with no Access-Control headers, so the browser blocks the + // real cross-origin request. + if allowed { + w.WriteHeader(http.StatusNoContent) + } else { + w.WriteHeader(http.StatusForbidden) + } + return true + } + return false +} + +// originAllowed reports whether origin is in the CORS allowlist. Matching is exact +// (scheme + host + port): a browser Origin is an opaque string, so an exact compare +// is both correct and the safest policy (no wildcard, no suffix tricks). +func (s *Server) originAllowed(origin string) bool { + for _, o := range s.AllowedOrigins { + if o == origin { + return true + } + } + return false +} + // isBodyTooLarge reports whether err is the sentinel returned by MaxBytesReader // when the body exceeds its limit, so the middleware can map it to 413. func isBodyTooLarge(err error) bool { From 8a51c5cc1f529dd700c165d97673362715e5c9ec Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 22:21:32 +0200 Subject: [PATCH 3/4] feat(busvectors): deterministic cross-language test vectors Add cmd/busvectors, a generator that emits stable JSON test vectors for the bus protocol and its E2E crypto (endpoint id, Ed25519 sign, ChaCha20-Poly1305 AEAD with a fixed nonce, sealed-box of a room key, and canonical Frame wire bytes + SigningBytes). It uses the same registry crypto (functions/cybersecurity) the bus uses, so the vectors are the contract the TypeScript port must match byte-for-byte (issue uniweb/0001, Phase 0). Regenerate with: go run ./cmd/busvectors > ../uniweb/web/src/bus/testdata/vectors.json --- cmd/busvectors/main.go | 229 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 cmd/busvectors/main.go diff --git a/cmd/busvectors/main.go b/cmd/busvectors/main.go new file mode 100644 index 00000000..ef34226e --- /dev/null +++ b/cmd/busvectors/main.go @@ -0,0 +1,229 @@ +// Command busvectors emits deterministic cross-language test vectors for the bus +// protocol and its end-to-end crypto. The browser-native client (uniweb) ports the +// protocol to TypeScript; these vectors are the contract that proves the port is +// byte-for-byte compatible with this Go reference implementation (issue +// uniweb/0001, Phase 0). +// +// Every input is fixed (hardcoded key material and messages) so the output is +// stable across runs and can be committed as a golden file. The crypto primitives +// are the SAME registry functions the bus uses (functions/cybersecurity), so the +// vectors exercise the real path, not a test-only reimplementation. +// +// Coverage: +// - endpoint_id : EndpointID(signPub) = base64url(sha256(signPub)) +// - sign : Ed25519 signature over a fixed message (deterministic) +// - aead : ChaCha20-Poly1305 seal with a FIXED nonce (deterministic, so +// the TS port must reproduce the same ciphertext AND open it) +// - keybox : sealed-box (X25519) of a room key for a recipient; the TS port +// must OPEN it (the ephemeral sender key is random, so only the +// open direction is a stable vector — the TS->Go seal direction +// is covered by the live E2E test in Phase 3) +// - frame : canonical JSON wire bytes of a Frame, and its SigningBytes +// +// Usage: +// +// go run ./cmd/busvectors > ../uniweb/web/src/bus/testdata/vectors.json +package main + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/hex" + "encoding/json" + "fmt" + "os" + + cs "fn-registry/functions/cybersecurity" + + "github.com/enmanuel/unibus/pkg/frame" + "golang.org/x/crypto/chacha20poly1305" + "golang.org/x/crypto/curve25519" +) + +// Fixed key material. The bytes are arbitrary but stable: the point is a golden +// file, not secrecy (these are test vectors, never real identities). +var ( + signSeed = mustHex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f") + kexPriv = mustHex("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f") + recipientKexPriv = mustHex("404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f") + aeadKey = mustHex("606162636465666768696a6b6c6d6e6f707172737475767778797a7b7c7d7e7f") + aeadNonce = mustHex("808182838485868788898a8b") // 12 bytes (ChaCha20-Poly1305 IETF) + roomKey = mustHex("a0a1a2a3a4a5a6a7a8a9aaabacadaeafb0b1b2b3b4b5b6b7b8b9babbbcbdbebf") + signMessage = []byte("unibus parity vector message") + aeadAAD = []byte("unibus-room-42") + aeadPlaintext = []byte("hello from the bus") +) + +// vectors is the JSON document consumed by the TypeScript parity tests. Every field +// is hex except the frame wire bytes, which are base64 (the frame is JSON, so the +// TS side compares the exact UTF-8 bytes). +type vectors struct { + Note string `json:"note"` + Endpoint endpointVector `json:"endpoint_id"` + Sign signVector `json:"sign"` + AEAD aeadVector `json:"aead"` + KeyBox keyboxVector `json:"keybox"` + Frame frameVector `json:"frame"` +} + +type endpointVector struct { + SignPubHex string `json:"sign_pub_hex"` + EndpointID string `json:"endpoint_id"` // base64url(sha256(sign_pub)), unpadded +} + +type signVector struct { + SignPrivHex string `json:"sign_priv_hex"` + SignPubHex string `json:"sign_pub_hex"` + MessageHex string `json:"message_hex"` + SigHex string `json:"sig_hex"` +} + +type aeadVector struct { + KeyHex string `json:"key_hex"` + NonceHex string `json:"nonce_hex"` + AADHex string `json:"aad_hex"` + PlaintextHex string `json:"plaintext_hex"` + CiphertextHex string `json:"ciphertext_hex"` // includes the 16-byte Poly1305 tag +} + +type keyboxVector struct { + RecipientKexPubHex string `json:"recipient_kex_pub_hex"` + RecipientKexPrivHex string `json:"recipient_kex_priv_hex"` + SecretHex string `json:"secret_hex"` + SealedHex string `json:"sealed_hex"` +} + +type frameVector struct { + // The source fields, so the TS side can build the same Frame and compare. + Type int `json:"type"` + Subject string `json:"subject"` + Sender string `json:"sender"` + MsgID string `json:"msg_id"` + Epoch int `json:"epoch"` + NonceHex string `json:"nonce_hex"` + PayloadHex string `json:"payload_hex"` + WireB64 string `json:"wire_b64"` // base64(Marshal()) — full frame incl. sig + SigningB64 string `json:"signing_bytes_b64"` // base64(SigningBytes()) — what gets signed + SigHex string `json:"sig_hex"` // Ed25519 over SigningBytes +} + +func main() { + if err := run(os.Stdout); err != nil { + fmt.Fprintln(os.Stderr, "busvectors:", err) + os.Exit(1) + } +} + +func run(out *os.File) error { + // Identity from the fixed seed: Go's ed25519 private key layout is seed||pub, the + // same 64-byte layout cs.Identity and the TS wallet use. + signPriv := ed25519.NewKeyFromSeed(signSeed) + signPub := signPriv.Public().(ed25519.PublicKey) + + // X25519 public keys from the fixed private scalars (curve25519 clamps internally, + // matching @noble/curves x25519.getPublicKey). + kexPub, err := curve25519.X25519(kexPriv, curve25519.Basepoint) + if err != nil { + return fmt.Errorf("kex pub: %w", err) + } + recipientKexPub, err := curve25519.X25519(recipientKexPriv, curve25519.Basepoint) + if err != nil { + return fmt.Errorf("recipient kex pub: %w", err) + } + + // AEAD with a FIXED nonce so the vector is deterministic. This is the same cipher + // (ChaCha20-Poly1305 IETF, 12-byte nonce) that cs.SealAEAD uses; we set the nonce + // explicitly only to make the vector reproducible. OpenAEAD verifies round-trip. + aead, err := chacha20poly1305.New(aeadKey) + if err != nil { + return fmt.Errorf("aead cipher: %w", err) + } + ciphertext := aead.Seal(nil, aeadNonce, aeadPlaintext, aeadAAD) + if _, err := cs.OpenAEAD(aeadKey, aeadNonce, ciphertext, aeadAAD); err != nil { + return fmt.Errorf("aead self-check: %w", err) + } + + // Sealed box of the room key for the recipient. The sender's ephemeral key is + // random (anonymous sealed box), so SealedHex changes per run; the stable, useful + // assertion for the TS port is that OpenKeyBox recovers the secret, which we + // self-check here. The TS test opens SealedHex and compares to SecretHex. + sealed, err := cs.SealKeyBox(recipientKexPub, roomKey) + if err != nil { + return fmt.Errorf("seal keybox: %w", err) + } + if got, err := cs.OpenKeyBox(recipientKexPub, recipientKexPriv, sealed); err != nil || hex.EncodeToString(got) != hex.EncodeToString(roomKey) { + return fmt.Errorf("keybox self-check failed: %v", err) + } + + // A representative encrypted-room frame, signed end-to-end. + f := frame.Frame{ + Type: frame.PUB, + Subject: "room.parity", + Sender: frame.EndpointID(signPub), + MsgID: "01HZY0VECTORFIXEDULID0001", + Epoch: 1, + Nonce: aeadNonce, + Payload: ciphertext, + } + f.Sig = ed25519.Sign(signPriv, f.SigningBytes()) + wire, err := f.Marshal() + if err != nil { + return fmt.Errorf("marshal frame: %w", err) + } + + v := vectors{ + Note: "Deterministic cross-language vectors for the unibus protocol. Generated by " + + "cmd/busvectors in the unibus repo; regenerate with `go run ./cmd/busvectors`. " + + "sealed_hex varies per run (anonymous sealed box); assert via OpenKeyBox.", + Endpoint: endpointVector{ + SignPubHex: hex.EncodeToString(signPub), + EndpointID: frame.EndpointID(signPub), + }, + Sign: signVector{ + SignPrivHex: hex.EncodeToString(signPriv), + SignPubHex: hex.EncodeToString(signPub), + MessageHex: hex.EncodeToString(signMessage), + SigHex: hex.EncodeToString(ed25519.Sign(signPriv, signMessage)), + }, + AEAD: aeadVector{ + KeyHex: hex.EncodeToString(aeadKey), + NonceHex: hex.EncodeToString(aeadNonce), + AADHex: hex.EncodeToString(aeadAAD), + PlaintextHex: hex.EncodeToString(aeadPlaintext), + CiphertextHex: hex.EncodeToString(ciphertext), + }, + KeyBox: keyboxVector{ + RecipientKexPubHex: hex.EncodeToString(recipientKexPub), + RecipientKexPrivHex: hex.EncodeToString(recipientKexPriv), + SecretHex: hex.EncodeToString(roomKey), + SealedHex: hex.EncodeToString(sealed), + }, + Frame: frameVector{ + Type: int(f.Type), + Subject: f.Subject, + Sender: f.Sender, + MsgID: f.MsgID, + Epoch: f.Epoch, + NonceHex: hex.EncodeToString(f.Nonce), + PayloadHex: hex.EncodeToString(f.Payload), + WireB64: base64.StdEncoding.EncodeToString(wire), + SigningB64: base64.StdEncoding.EncodeToString(f.SigningBytes()), + SigHex: hex.EncodeToString(f.Sig), + }, + // kexPub is unused in a vector field today but derived above to validate the + // scalar; reference it so the intent is documented. + } + _ = kexPub + + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(v) +} + +func mustHex(s string) []byte { + b, err := hex.DecodeString(s) + if err != nil { + panic("busvectors: bad fixed hex: " + s) + } + return b +} From a5086ecd1848496848b6b1051debdd4a737b22a3 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sat, 13 Jun 2026 22:21:51 +0200 Subject: [PATCH 4/4] chore: bump unibus to 0.14.0 (browser-native client prep, Phase 0) --- app.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/app.md b/app.md index a95c8c4b..b957d002 100644 --- a/app.md +++ b/app.md @@ -2,7 +2,7 @@ name: unibus lang: go domain: infra -version: 0.13.0 +version: 0.14.0 description: "Bus de mensajería unificado sobre NATS+JetStream con cifrado E2E por room (megolm/olm reducido): service de membresía/claves, librería cliente y peers demo." tags: [service, messaging, nats, e2e] uses_functions: @@ -169,6 +169,16 @@ agent..{in,out} inbox/outbox de agente LLM (agent.scout.in) ## Capability growth log +- v0.14.0 (2026-06-13) — prep para el cliente browser-nativo `uniweb` (issue + uniweb/0001, Fase 0), todo aditivo y opt-in: (1) el nats-server embebido puede + exponer un listener WebSocket (`WebsocketConfig`) para que un navegador hable el + protocolo NATS via `nats.ws`, igual que los peers TCP nativos; el authenticator + nkey aplica también al WebSocket. (2) El control-plane (`membershipd`) gana una + allowlist CORS opt-in (`--cors-origins`) para aceptar llamadas cross-origin del + navegador; vacía = CORS off, sin cambios para clientes nativos. (3) `cmd/busvectors` + genera vectores de test deterministas (endpoint id, firma Ed25519, AEAD + ChaCha20-Poly1305, sealed-box, wire del Frame) como contrato de paridad para el + port TypeScript. Peers Go/Kotlin existentes sin cambios; build/vet/test verdes. - v0.13.0 (2026-06-13) — el frontend web se separa a su propia app `uniweb` (`projects/message_bus/apps/uniweb`, sub-repo Gitea propio). unibus deja de contener la SPA (`web/`) y el gateway web (`cmd/webgw`): ahora es estrictamente