From 74c8d4f9418b364d06bdb8803aa0210f611ef945 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:49:19 +0200 Subject: [PATCH 1/5] feat(client,cmd,mobile): connect securely via client.Connect(caPath) client.Connect is the single migration seam: a non-empty caPath connects with TLS pinned to the bus CA plus nkey auth (matching enforce + bus-tls), an empty caPath keeps the legacy plaintext dev connection; control-plane requests are signed either way. worker and chat gain a --ca flag; the gomobile NewSession gains a caPath parameter so the Android app bundles ca.crt and connects securely. Every peer now flows through one code path. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/chat/main.go | 25 +++++++++++++------------ cmd/worker/main.go | 3 ++- mobile/unibus.go | 12 ++++++++---- pkg/client/client.go | 18 ++++++++++++++++++ 4 files changed, 41 insertions(+), 17 deletions(-) diff --git a/cmd/chat/main.go b/cmd/chat/main.go index cc7c35e..33985cb 100644 --- a/cmd/chat/main.go +++ b/cmd/chat/main.go @@ -27,11 +27,12 @@ import ( func main() { var ( - natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "NATS url") - ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "membershipd control-plane url") - roomSub = flag.String("room", "proc.test.ticks", "room subject to subscribe to") - idFile = flag.String("id-file", "./local_files/chat.id", "identity file path") - demoEnc = flag.Bool("demo-encrypted", false, "run the encrypted forward-secrecy demo") + natsURL = flag.String("nats-url", "nats://127.0.0.1:4250", "NATS url") + ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "membershipd control-plane url") + roomSub = flag.String("room", "proc.test.ticks", "room subject to subscribe to") + idFile = flag.String("id-file", "./local_files/chat.id", "identity file path") + demoEnc = flag.Bool("demo-encrypted", false, "run the encrypted forward-secrecy demo") + caFile = flag.String("ca", "", "path to the bus CA cert (ca.crt); set to connect with TLS + nkey to a secured bus") ) flag.Parse() @@ -39,19 +40,19 @@ func main() { log.SetPrefix("[chat] ") if *demoEnc { - runEncryptedDemo(*natsURL, *ctrlURL) + runEncryptedDemo(*natsURL, *ctrlURL, *caFile) return } - runSimple(*natsURL, *ctrlURL, *roomSub, *idFile) + runSimple(*natsURL, *ctrlURL, *roomSub, *idFile, *caFile) } // runSimple subscribes to a cleartext subject and prints messages live. -func runSimple(natsURL, ctrlURL, roomSub, idFile string) { +func runSimple(natsURL, ctrlURL, roomSub, idFile, caFile string) { id, err := client.LoadOrCreateIdentity(idFile) if err != nil { log.Fatalf("identity: %v", err) } - c, err := client.New(natsURL, ctrlURL, id) + c, err := client.Connect(natsURL, ctrlURL, id, caFile) if err != nil { log.Fatalf("connect: %v", err) } @@ -91,7 +92,7 @@ func shortID(id string) string { } // runEncryptedDemo proves E2E encryption + forward secrecy end-to-end. -func runEncryptedDemo(natsURL, ctrlURL string) { +func runEncryptedDemo(natsURL, ctrlURL, caFile string) { log.Printf("=== encrypted forward-secrecy demo ===") pass := true check := func(name string, ok bool) { @@ -109,10 +110,10 @@ func runEncryptedDemo(natsURL, ctrlURL string) { idB, err := newEphemeralIdentity() must(err, "generate B identity") - a, err := client.New(natsURL, ctrlURL, idA) + a, err := client.Connect(natsURL, ctrlURL, idA, caFile) must(err, "connect A") defer a.Close() - b, err := client.New(natsURL, ctrlURL, idB) + b, err := client.Connect(natsURL, ctrlURL, idB, caFile) must(err, "connect B") defer b.Close() diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 5e72aa7..1f422be 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -23,6 +23,7 @@ func main() { ctrlURL = flag.String("ctrl-url", "http://127.0.0.1:8470", "membershipd control-plane url") roomSub = flag.String("room", "proc.test.ticks", "room subject to publish to") idFile = flag.String("id-file", "./local_files/worker.id", "identity file path") + caFile = flag.String("ca", "", "path to the bus CA cert (ca.crt); set to connect with TLS + nkey to a secured bus") ) flag.Parse() @@ -33,7 +34,7 @@ func main() { if err != nil { log.Fatalf("identity: %v", err) } - c, err := client.New(*natsURL, *ctrlURL, id) + c, err := client.Connect(*natsURL, *ctrlURL, id, *caFile) if err != nil { log.Fatalf("connect: %v", err) } diff --git a/mobile/unibus.go b/mobile/unibus.go index 3e4d79b..8956d80 100644 --- a/mobile/unibus.go +++ b/mobile/unibus.go @@ -44,14 +44,18 @@ func GenerateIdentity(path string) error { } // NewSession loads the identity at idPath and connects to the bus. natsURL is -// the data plane (for example nats://host:4250) and ctrlURL is the control -// plane HTTP endpoint (for example http://host:8470). -func NewSession(idPath, natsURL, ctrlURL string) (*Session, error) { +// the data plane (for example tls://host:4250) and ctrlURL is the control plane +// HTTP endpoint (for example http://host:8470). caPath is the path to the bus +// CA certificate (ca.crt) bundled with the app: when set, the session connects +// securely (TLS pinned to that CA + nkey authentication on the data plane), +// matching a bus running with auth + TLS. Pass an empty caPath to connect in +// plaintext to an unsecured (dev) bus. +func NewSession(idPath, natsURL, ctrlURL, caPath string) (*Session, error) { id, err := client.LoadOrCreateIdentity(idPath) if err != nil { return nil, err } - c, err := client.New(natsURL, ctrlURL, id) + c, err := client.Connect(natsURL, ctrlURL, id, caPath) if err != nil { return nil, err } diff --git a/pkg/client/client.go b/pkg/client/client.go index 8e3a878..4a7316d 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -79,6 +79,24 @@ func New(natsURL, ctrlURL string, id cs.Identity) (*Client, error) { return NewWithOptions(natsURL, ctrlURL, id, Options{}) } +// Connect is the single migration seam every peer (worker, chat, mobile, +// gateway) uses to pick its security posture from one input: the CA path. With +// a non-empty caPath it connects securely — TLS pinned to that CA plus nkey +// authentication on the data plane — matching a bus running with bus-auth +// enforce + bus-tls. With an empty caPath it falls back to the legacy plaintext, +// no-nkey connection for local dev against an unsecured bus. The control-plane +// HTTP requests are signed in both cases (that signing is unconditional). +func Connect(natsURL, ctrlURL string, id cs.Identity, caPath string) (*Client, error) { + if caPath == "" { + return New(natsURL, ctrlURL, id) + } + tlsCfg, err := busauth.LoadCATLSConfig(caPath) + if err != nil { + return nil, fmt.Errorf("client: load CA %q: %w", caPath, err) + } + return NewWithOptions(natsURL, ctrlURL, id, Options{UseNkey: true, TLS: tlsCfg}) +} + // NewWithOptions is New with explicit connection options (nkey auth, and, from // phase 0001d, TLS). It is the single place the data-plane connection is built, // so every peer (worker, chat, mobile, gateway) gets identical behavior by From b64777952158e9fa1826cffd4ed272e62728ece6 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:49:19 +0200 Subject: [PATCH 2/5] feat(membershipd): enable NATS nkey auth (enforce) and TLS flags Opens the store before NATS so the authenticator can consult IsAuthorized. Under --bus-auth enforce the embedded NATS gets the nkey authenticator (only allowlisted identities connect); --tls-cert/--tls-key make it present the server certificate and require TLS. External NATS manages its own auth/TLS. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmd/membershipd/main.go | 64 +++++++++++++++++++++++++++++------------ 1 file changed, 45 insertions(+), 19 deletions(-) diff --git a/cmd/membershipd/main.go b/cmd/membershipd/main.go index a26428e..23a444b 100644 --- a/cmd/membershipd/main.go +++ b/cmd/membershipd/main.go @@ -17,6 +17,7 @@ import ( server "github.com/nats-io/nats-server/v2/server" "github.com/enmanuel/unibus/pkg/blobstore" + "github.com/enmanuel/unibus/pkg/busauth" "github.com/enmanuel/unibus/pkg/embeddednats" "github.com/enmanuel/unibus/pkg/membership" ) @@ -41,6 +42,8 @@ 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)") + 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") ) flag.Parse() @@ -52,25 +55,8 @@ func main() { log.SetFlags(log.LstdFlags | log.Lmsgprefix) log.SetPrefix("[membershipd] ") - // Data plane: embedded or external NATS. - var ns *server.Server - natsClientURL := *natsURL - if natsClientURL == "" { - var err error - // Bind the embedded NATS to the same interface as the HTTP API so a single - // --bind flag governs reachability: 127.0.0.1 keeps the whole stack - // loopback-only; 0.0.0.0 exposes both planes to the LAN. - ns, err = embeddednats.StartHost(*natsStore, *bind, *natsPort) - if err != nil { - log.Fatalf("start embedded nats: %v", err) - } - natsClientURL = embeddednats.ClientURL(ns) - log.Printf("embedded NATS (JetStream) ready: %s", natsClientURL) - } else { - log.Printf("using external NATS: %s", natsClientURL) - } - - // Control plane: SQLite store + blob store + HTTP API. + // Control plane store first: the NATS authenticator consults IsAuthorized, so + // the store must exist before the embedded server starts. store, err := membership.Open(*dbPath) if err != nil { log.Fatalf("open membership store: %v", err) @@ -84,6 +70,46 @@ func main() { } log.Printf("blob store: %s", *storeDir) + // Data plane: embedded or external NATS. For the embedded server, enforce + // turns on the nkey authenticator (only allowlisted identities may connect) + // and --tls-cert/--tls-key turn on TLS. An external NATS manages its own + // auth/TLS, so those flags do not apply to it. + var ns *server.Server + natsClientURL := *natsURL + if natsClientURL == "" { + cfg := embeddednats.ServerConfig{ + // Bind the embedded NATS to the same interface as the HTTP API so a + // single --bind flag governs reachability: 127.0.0.1 keeps the whole + // stack loopback-only; 0.0.0.0 exposes both planes to the LAN. + StoreDir: *natsStore, + Host: *bind, + Port: *natsPort, + } + if authMode == membership.AuthEnforce { + cfg.Auth = busauth.NewNkeyAuthenticator(store.IsAuthorized) + log.Printf("NATS nkey authentication: ON (enforce)") + } + if *tlsCert != "" || *tlsKey != "" { + if *tlsCert == "" || *tlsKey == "" { + log.Fatalf("--tls-cert and --tls-key must be set together") + } + tlsCfg, err := busauth.ServerTLSConfig(*tlsCert, *tlsKey) + if err != nil { + log.Fatalf("load NATS TLS: %v", err) + } + cfg.TLS = tlsCfg + log.Printf("NATS TLS: ON (%s)", *tlsCert) + } + ns, err = embeddednats.StartServer(cfg) + if err != nil { + log.Fatalf("start embedded nats: %v", err) + } + natsClientURL = embeddednats.ClientURL(ns) + log.Printf("embedded NATS (JetStream) ready: %s", natsClientURL) + } else { + log.Printf("using external NATS: %s", natsClientURL) + } + srv := membership.NewServer(store, blobs, authMode) log.Printf("control-plane auth: %s", authMode) addr := *bind + ":" + *httpPort From 87dbc421cd26f6c26d624e46db22fc7bd89af196 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:49:19 +0200 Subject: [PATCH 3/5] chore(flags): flip bus-auth to enforce and bus-tls on (target state) Declares the project's target rollout: bus-auth enforce, bus-tls enabled. Flags are declarative; the operator activates them at deploy via membershipd --bus-auth/--tls-cert/--tls-key. CLI defaults stay off so dev and tests run unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/feature_flags.json | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/dev/feature_flags.json b/dev/feature_flags.json index 3027207..8fc10d1 100644 --- a/dev/feature_flags.json +++ b/dev/feature_flags.json @@ -1,19 +1,19 @@ { "flags": { "bus-auth": { - "enabled": false, - "state": "off", + "enabled": true, + "state": "enforce", "issue": "0001", - "description": "Signed control-plane auth + NATS nkey auth. Rollout: off -> soft (verify+log, allow) -> enforce (reject). 'enabled' mirrors state!=off.", + "description": "Signed control-plane auth + NATS nkey auth. Rollout: off -> soft (verify+log, allow) -> enforce (reject). 'enabled' mirrors state!=off. Server opts in via membershipd --bus-auth; clients via client.Connect(caPath).", "added": "2026-06-07", - "enabled_at": null + "enabled_at": "2026-06-07" }, "bus-tls": { - "enabled": false, + "enabled": true, "issue": "0001", - "description": "TLS on the NATS data plane using the project's self-signed CA (deploy/tls/). When enabled the server presents its cert and clients pin the CA.", + "description": "TLS on the NATS data plane using the project's self-signed CA (deploy/tls/). Server opts in via membershipd --tls-cert/--tls-key; clients pin ca.crt via client.Connect(caPath).", "added": "2026-06-07", - "enabled_at": null + "enabled_at": "2026-06-07" } } } From 6b0916f1fa64b619312313b8249da458cd066617 Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:49:19 +0200 Subject: [PATCH 4/5] docs(0001e): note remaining gateway and unibots migration The web gateway (playground) and unibots (in the agents repo) are not migrated here: the gateway stays a local dev tool at AuthOff, and the bot transport lives outside this sub-repo. dev/0001e-remaining-clients.md records exactly what each needs (client.Connect with ca.crt, identity registered in the allowlist) and the operator server flags for phase 0001f. Co-Authored-By: Claude Opus 4.8 (1M context) --- dev/0001e-remaining-clients.md | 55 ++++++++++++++++++++++++++++++++++ playground/server.go | 3 +- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 dev/0001e-remaining-clients.md diff --git a/dev/0001e-remaining-clients.md b/dev/0001e-remaining-clients.md new file mode 100644 index 0000000..6c0e488 --- /dev/null +++ b/dev/0001e-remaining-clients.md @@ -0,0 +1,55 @@ +# Issue 0001e — remaining client migrations (notes, NOT implemented) + +Phase 0001e migrated the first-class Go clients and the mobile binding to the +secure connection path (`client.Connect(caPath)` → TLS + nkey; control-plane +requests are always signed). Two consumers are intentionally **left as notes** +because they live outside this sub-repo or need their own coordination: + +## 1. Web gateway (`playground/server.go`) + +The playground is a local dev gateway that embeds its own membershipd +(`membership.NewServer(..., AuthOff)`) and an open embedded NATS, and connects +browser sessions through an in-process client. To run it against a **secured** +bus it would need: + +- Connect its internal client via `client.Connect(natsURL, ctrlURL, id, caPath)` + with the bundled `ca.crt` (it currently builds the client without options). +- If it should itself enforce auth on the browser-facing side, start its + embedded membershipd with an auth mode and its embedded NATS with + `embeddednats.StartServer(ServerConfig{Auth: ..., TLS: ...})` — but a local + dev gateway typically stays open and only the *upstream* bus is secured. +- The gateway's own bus identity must be registered in the upstream allowlist + (`membershipd user add`). + +Decision: left at `AuthOff` + plaintext for now (local dev tool). Migrate when +the gateway is pointed at the public bus. + +## 2. unibots (`shell/transportunibus`, in the agents repo — NOT this sub-repo) + +The bot transport lives in the `agents_and_robots` / message_bus consumer, not +in `dataforge/unibus`. To talk to the secured bus it must, after recompiling +against this `pkg/client`: + +- Switch its connect call to `client.Connect(natsURL, ctrlURL, id, caPath)`, + passing the path to the bundled `ca.crt`. +- Ship `ca.crt` alongside the bot binary (read-only) and point `caPath` at it. +- Register each bot's identity (`hex(SignPub)`) in the bus allowlist via + `membershipd user add --handle --sign-pub ` on the bus host. +- Run as `systemd --user` with `caPath` set, per the deploy plan (0001f). + +No code change is possible from this sub-repo; this is the contract the bot +transport consumes. + +## Server enablement (operator, phase 0001f) + +`membershipd` now accepts: + +- `--bus-auth enforce` — verify signed control-plane requests AND turn on the + NATS nkey authenticator (only allowlisted identities connect). +- `--tls-cert deploy/tls/server.crt --tls-key deploy/tls/server.key` — present + the server certificate and require TLS on the embedded NATS. + +`dev/feature_flags.json` now declares both `bus-auth: enforce` and +`bus-tls: enabled` as the project's target state. The flags are declarative; +the operator activates them at deploy time with the flags above. The CLI +defaults remain off so local dev and the test suite are unaffected. diff --git a/playground/server.go b/playground/server.go index 0e44a5d..b93a409 100644 --- a/playground/server.go +++ b/playground/server.go @@ -861,7 +861,8 @@ func main() { log.Fatalf("open blob store: %v", err) } // AuthOff: the playground is a local dev gateway that has not migrated to - // signed control-plane requests yet (tracked in phase 0001e of issue 0001). + // signed control-plane requests or a secured upstream bus yet. What it would + // need is written up in dev/0001e-remaining-clients.md (issue 0001, phase 0001e). ctrlSrv := &http.Server{Addr: ctrlAddr, Handler: membership.NewServer(store, blobs, membership.AuthOff)} go func() { if err := ctrlSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { From 04e27518af7c51f00f3dcd61ce5eba63e864599b Mon Sep 17 00:00:00 2001 From: Egutierrez Date: Sun, 7 Jun 2026 12:49:19 +0200 Subject: [PATCH 5/5] test(client): secure bus end-to-end (auth + TLS + E2E together) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestSecureBusEndToEnd boots the server with control-plane enforce, NATS nkey auth, and TLS all on; two registered peers connect with nkey+TLS, A creates a Matrix room, invites B, publishes, and B decrypts — proving the three layers compose. This is the headline golden of issue 0001. Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/client/tls_test.go | 72 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/pkg/client/tls_test.go b/pkg/client/tls_test.go index 236168a..8fc1d5e 100644 --- a/pkg/client/tls_test.go +++ b/pkg/client/tls_test.go @@ -7,13 +7,16 @@ import ( "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/hex" "encoding/pem" "math/big" "net" + "sync" "testing" "time" "github.com/enmanuel/unibus/pkg/client" + "github.com/enmanuel/unibus/pkg/frame" "github.com/enmanuel/unibus/pkg/membership" "github.com/enmanuel/unibus/pkg/room" ) @@ -111,3 +114,72 @@ func TestNatsTLS(t *testing.T) { t.Fatalf("client without the CA must fail the TLS handshake") } } + +// TestSecureBusEndToEnd is the headline golden of issue 0001: with ALL three +// layers active at once — control-plane request signing (enforce), NATS nkey +// auth, and TLS — two registered peers run an encrypted room end to end. A +// creates a Matrix-policy room, invites B, A publishes and B decrypts. This +// proves the layers compose: signed HTTP control plane + authenticated, +// encrypted data plane + E2E room content. +func TestSecureBusEndToEnd(t *testing.T) { + serverTLS, caPool := genTestCA(t) + h := bootHarness(t, membership.AuthEnforce, true, serverTLS) + waitHealth(t, h.ctrlURL) + + clientTLS := &tls.Config{RootCAs: caPool, MinVersion: tls.VersionTLS12} + secure := func(t *testing.T, handle string) (*client.Client, membership.AuthMode) { + id := mustIdentity(t) + if err := h.store.AddUser(hex.EncodeToString(id.SignPub), handle, membership.RoleMember); err != nil { + t.Fatalf("register %s: %v", handle, err) + } + c, err := client.NewWithOptions(h.natsURL, h.ctrlURL, id, client.Options{UseNkey: true, TLS: clientTLS}) + if err != nil { + t.Fatalf("connect %s securely: %v", handle, err) + } + return c, 0 + } + + a, _ := secure(t, "alice") + defer a.Close() + b, _ := secure(t, "bob") + defer b.Close() + + roomID, err := a.CreateRoom("room.secure", room.ModeMatrix) + if err != nil { + t.Fatalf("A create encrypted room over secure bus: %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 got []string + sub, err := b.Subscribe(roomID, func(_ frame.Frame, plaintext []byte) { + mu.Lock() + got = append(got, string(plaintext)) + mu.Unlock() + }) + if err != nil { + t.Fatalf("B subscribe: %v", err) + } + defer sub.Unsubscribe() + time.Sleep(150 * time.Millisecond) + + const msg = "mensaje sobre bus seguro (auth+TLS+E2E)" + if err := a.Publish(roomID, []byte(msg)); err != nil { + t.Fatalf("A publish: %v", err) + } + if !waitFor(&mu, &got, func(rs []string) bool { + for _, r := range rs { + if r == msg { + return true + } + } + return false + }, 2*time.Second) { + t.Fatalf("B did not receive/decrypt the message over the secured bus; got %v", snapshot(&mu, &got)) + } +}