diff --git a/README.md b/README.md index 2e6bc5e..f0406a0 100644 --- a/README.md +++ b/README.md @@ -121,10 +121,9 @@ Connect two agents in 3 commands. No JSON editing. ### Prerequisites - **Node.js 22+** (uses built-in `node:sqlite`) -- **NATS server**: - ```bash - docker run -d --name nats -p 4222:4222 nats:2.10-alpine -js --auth YOUR_SECRET - ``` +- **TLS-required NATS server** with one subject-scoped user per agent. See + [`docs/nats-transport-security.md`](docs/nats-transport-security.md) for the + server policy and isolated integration proof. ### Step 1 — Host generates invite @@ -132,10 +131,12 @@ Connect two agents in 3 commands. No JSON editing. git clone https://github.com/alexfrmn/murmur.git && cd mur-mur-v2 npm install && npm run build -AGENT_ID=alice NATS_URL=nats://your-server:4222 NATS_TOKEN=YOUR_SECRET \ +AGENT_ID=alice NATS_URL=tls://your-server:4222 \ + NATS_USER=alice NATS_PASSWORD=ALICE_SECRET NATS_CA_FILE=/secure/nats-ca.pem \ node scripts/agent-config-init.mjs -node scripts/murmur-invite.mjs +MURMUR_INVITE_NATS_USER=bob MURMUR_INVITE_NATS_PASSWORD=BOB_SECRET \ + node scripts/murmur-invite.mjs # → Prints MURMUR:eyJ... blob — send it to your peer via any channel ``` @@ -145,8 +146,7 @@ node scripts/murmur-invite.mjs git clone https://github.com/alexfrmn/murmur.git && cd mur-mur-v2 npm install && npm run build -AGENT_ID=bob NATS_URL=nats://your-server:4222 NATS_TOKEN=YOUR_SECRET \ - node scripts/murmur-join.mjs 'MURMUR:eyJ...' +AGENT_ID=bob node scripts/murmur-join.mjs 'MURMUR:eyJ...' # → Prints MURMUR-REPLY:eyJ... blob — send it back to host ``` diff --git a/deploy/docker-compose.messaging.yml b/deploy/docker-compose.messaging.yml index d6137d1..8574eb6 100644 --- a/deploy/docker-compose.messaging.yml +++ b/deploy/docker-compose.messaging.yml @@ -5,6 +5,6 @@ services: # JetStream is enabled for durable broker delivery; Murmur daemons still keep SQLite outbox durability locally. command: ["-js", "-m", "8222", "--auth", "${NATS_TOKEN}"] ports: - - "4222:4222" + - "127.0.0.1:4222:4222" - "127.0.0.1:8222:8222" restart: unless-stopped diff --git a/deploy/kubernetes/README.md b/deploy/kubernetes/README.md index 5916e2c..b1a2052 100644 --- a/deploy/kubernetes/README.md +++ b/deploy/kubernetes/README.md @@ -9,13 +9,14 @@ Helm chart. - `Namespace/murmur` - `StatefulSet/murmur-nats` with JetStream storage - `Service/murmur-nats` exposing NATS inside the cluster -- `Secret/murmur-nats-auth` with the NATS token placeholder +- `Secret/murmur-nats-auth` with a per-agent user and bcrypt password hash +- `Secret/murmur-nats-tls` with the server certificate/key and client CA - `StatefulSet/murmur-agent` with a per-agent SQLite PVC - `Service/murmur-agent` exposing the optional Prometheus exporter in-cluster - `Secret/murmur-agent-config` with an example `agent-config.json` Presence/discovery metadata is public by design, but `agent-config.json` still -contains private keys and the NATS token. Keep it in a secret manager or sealed +contains private keys and a NATS client password. Keep it in a secret manager or sealed secret in real deployments. ## Build The Daemon Image @@ -31,8 +32,9 @@ Update `deploy/kubernetes/agent-daemon.yaml` with your image name. Edit these placeholders before applying: -- `deploy/kubernetes/nats.yaml`: `CHANGE_ME_NATS_TOKEN` -- `deploy/kubernetes/agent-config.example.yaml`: `agentId`, keys, peers, and token +- `deploy/kubernetes/nats.yaml`: TLS PEM values, user, and bcrypt password hash +- `deploy/kubernetes/agent-config.example.yaml`: `agentId`, keys, peers, and the + matching distinct plaintext client password The example also exposes the streaming delivery knobs used by the daemon: @@ -48,7 +50,8 @@ For real clusters, prefer generating these from your secret manager: ```bash kubectl -n murmur create secret generic murmur-nats-auth \ - --from-literal=NATS_TOKEN="$NATS_TOKEN" \ + --from-literal=NATS_USER="$NATS_USER" \ + --from-literal=NATS_PASSWORD_BCRYPT="$NATS_PASSWORD_BCRYPT" \ --dry-run=client -o yaml kubectl -n murmur create secret generic murmur-agent-config \ @@ -67,7 +70,7 @@ kubectl -n murmur rollout status statefulset/murmur-agent The in-cluster NATS URL for agents is: ```text -nats://murmur-nats.murmur.svc.cluster.local:4222 +tls://murmur-nats.murmur.svc.cluster.local:4222 ``` For multiple agents, create one config secret and one agent StatefulSet per @@ -80,6 +83,9 @@ agent, or keep this directory as a base and add per-agent Kustomize overlays. - The agent StatefulSet runs `scripts/prometheus-exporter.mjs` as a sidecar on port `9464`; remove the sidecar and `murmur-agent` Service if you do not need metrics. +- This reference requires TLS and one subject-scoped user. Replace every + placeholder before applying it; the example TLS secret is intentionally not a + usable certificate. See `docs/nats-transport-security.md`. - This reference does not expose NATS outside the cluster. Use an ingress, LoadBalancer, VPN, or NATS leaf-node topology only after setting explicit authz boundaries. diff --git a/deploy/kubernetes/agent-config.example.yaml b/deploy/kubernetes/agent-config.example.yaml index f467d0e..35f0a5f 100644 --- a/deploy/kubernetes/agent-config.example.yaml +++ b/deploy/kubernetes/agent-config.example.yaml @@ -7,16 +7,11 @@ stringData: agent-config.json: | { "agentId": "agent-k8s-demo", - "natsUrl": "nats://murmur-nats.murmur.svc.cluster.local:4222", - "natsToken": "CHANGE_ME_NATS_TOKEN", + "natsUrl": "tls://murmur-nats.murmur.svc.cluster.local:4222", + "natsUser": "agent-k8s-demo", + "natsPassword": "CHANGE_ME_DISTINCT_CLIENT_PASSWORD", + "natsTls": { "caFile": "/etc/murmur/nats/ca.crt" }, "subject": "msg.agent-k8s-demo", - "jetstream": { - "enabled": true, - "stream": "MURMUR", - "subjects": ["msg.>", "ack.>"], - "maxDeliver": 5, - "ackWaitMs": 30000 - }, "streaming": { "ackTimeoutMs": 15000, "ackWindow": { diff --git a/deploy/kubernetes/agent-daemon.yaml b/deploy/kubernetes/agent-daemon.yaml index 43d1f5d..514387c 100644 --- a/deploy/kubernetes/agent-daemon.yaml +++ b/deploy/kubernetes/agent-daemon.yaml @@ -42,8 +42,6 @@ spec: value: /data - name: FLUSH_INTERVAL_MS value: "2000" - - name: MURMUR_JETSTREAM - value: "1" - name: MURMUR_JETSTREAM_MAX_DELIVER value: "5" - name: MURMUR_JETSTREAM_ACK_WAIT_MS @@ -71,6 +69,10 @@ spec: mountPath: /data/agent-config.json subPath: agent-config.json readOnly: true + - name: nats-ca + mountPath: /etc/murmur/nats/ca.crt + subPath: ca.crt + readOnly: true - name: metrics image: ghcr.io/acme/murmur-v2-daemon:2.2.0 imagePullPolicy: IfNotPresent @@ -101,6 +103,9 @@ spec: - name: agent-config secret: secretName: murmur-agent-config + - name: nats-ca + secret: + secretName: murmur-nats-tls volumeClaimTemplates: - metadata: name: agent-data diff --git a/deploy/kubernetes/nats.yaml b/deploy/kubernetes/nats.yaml index 98c48d4..10291e9 100644 --- a/deploy/kubernetes/nats.yaml +++ b/deploy/kubernetes/nats.yaml @@ -4,7 +4,45 @@ metadata: name: murmur-nats-auth type: Opaque stringData: - NATS_TOKEN: CHANGE_ME_NATS_TOKEN + NATS_USER: agent-k8s-demo + NATS_PASSWORD_BCRYPT: CHANGE_ME_BCRYPT_HASH +--- +apiVersion: v1 +kind: Secret +metadata: + name: murmur-nats-tls +type: Opaque +stringData: + tls.crt: CHANGE_ME_SERVER_CERTIFICATE_PEM + tls.key: CHANGE_ME_SERVER_PRIVATE_KEY_PEM + ca.crt: CHANGE_ME_CA_CERTIFICATE_PEM +--- +apiVersion: v1 +kind: ConfigMap +metadata: + name: murmur-nats-config +data: + nats.conf: | + port: 4222 + http: 8222 + jetstream { store_dir: "/data/jetstream" } + tls { + cert_file: "/etc/nats/tls/tls.crt" + key_file: "/etc/nats/tls/tls.key" + timeout: 2 + } + authorization { + users: [ + { + user: $NATS_USER + password: $NATS_PASSWORD_BCRYPT + permissions: { + publish: ["msg.agent-peer", "ack.agent-peer"] + subscribe: ["msg.agent-k8s-demo", "ack.agent-k8s-demo"] + } + } + ] + } --- apiVersion: v1 kind: Service @@ -49,13 +87,18 @@ spec: - /bin/sh - -ec args: - - exec nats-server -js -sd /data/jetstream -m 8222 --auth "$NATS_TOKEN" + - exec nats-server -c /etc/nats/nats.conf env: - - name: NATS_TOKEN + - name: NATS_USER + valueFrom: + secretKeyRef: + name: murmur-nats-auth + key: NATS_USER + - name: NATS_PASSWORD_BCRYPT valueFrom: secretKeyRef: name: murmur-nats-auth - key: NATS_TOKEN + key: NATS_PASSWORD_BCRYPT ports: - name: nats containerPort: 4222 @@ -69,6 +112,20 @@ spec: volumeMounts: - name: nats-data mountPath: /data + - name: nats-config + mountPath: /etc/nats/nats.conf + subPath: nats.conf + readOnly: true + - name: nats-tls + mountPath: /etc/nats/tls + readOnly: true + volumes: + - name: nats-config + configMap: + name: murmur-nats-config + - name: nats-tls + secret: + secretName: murmur-nats-tls volumeClaimTemplates: - metadata: name: nats-data diff --git a/docs/codex-mac-wake-relay.md b/docs/codex-mac-wake-relay.md index 1b0e2f8..d0d447c 100644 --- a/docs/codex-mac-wake-relay.md +++ b/docs/codex-mac-wake-relay.md @@ -7,7 +7,7 @@ LaunchAgents or the Codex app-server wake path. ## Current Topology - Agent id: `agent-codex-mac-kovalyaevo` -- Broker: `nats://nats.server-pilot.ru:4222` +- Broker: `tls://nats.server-pilot.ru:4222` - Peer that wakes Codex on the Mac: `agent-jarvis` - Murmur repo on the Mac: `/Users/alex/.local/share/mur-mur-v2` - Mac data dir: @@ -68,8 +68,8 @@ The Mac peer config needs these wake fields: } ``` -Only `natsUrl` should point at the current broker. The mesh token and keys are -not part of this runbook. +`natsUrl` must use `tls://`. The per-agent broker username/password, CA path, +and mesh keys are not part of this runbook. ## Implementation Notes diff --git a/docs/nats-transport-security.md b/docs/nats-transport-security.md new file mode 100644 index 0000000..95d62d9 --- /dev/null +++ b/docs/nats-transport-security.md @@ -0,0 +1,92 @@ +# NATS transport security + +Murmur permits plaintext NATS only on loopback. Every non-loopback client must +use a `tls://` URL; the shared connection builder then gives nats.js an explicit +TLS policy so the connection fails if TLS is unavailable or certificate and +hostname validation fail. + +## Agent config + +Use a distinct NATS user/password for every agent. Do not reuse the historical +shared token. + +```json +{ + "natsUrl": "tls://broker.example:4222", + "natsUser": "agent-a", + "natsPassword": "A_DISTINCT_RANDOM_PASSWORD", + "natsTls": { + "caFile": "/run/secrets/murmur-nats-ca.pem" + } +} +``` + +Omit `natsTls.caFile` only when the server certificate chains to a normal system +trust root. `certFile` and `keyFile` are available for deployments that also use +mutual TLS. If `natsUrl` contains a literal IP address, set +`natsTls.serverName` to the DNS identity in the certificate; Murmur rejects IP +endpoints without it because nats.js does not otherwise perform an IP hostname +check. The `serverName` field is optional for DNS URLs such as the example +above. Never put credentials in the URL. + +## Server policy + +TLS must be required; do not set `allow_non_tls`. A minimal two-peer core-NATS +configuration has symmetric, subject-bound permissions: + +```hcl +host: "PRIVATE_OR_ALLOWLISTED_INTERFACE" +port: 4222 +http: "127.0.0.1:8222" + +tls { + cert_file: "/run/secrets/server.crt" + key_file: "/run/secrets/server.key" + timeout: 2 +} + +authorization { + users: [ + { + user: "agent-a" + password: "$2a$11$BCRYPT_HASH_FOR_AGENT_A" + permissions: { + publish: ["msg.agent-b", "ack.agent-b"] + subscribe: ["msg.agent-a", "ack.agent-a"] + } + }, + { + user: "agent-b" + password: "$2a$11$BCRYPT_HASH_FOR_AGENT_B" + permissions: { + publish: ["msg.agent-a", "ack.agent-a"] + subscribe: ["msg.agent-b", "ack.agent-b"] + } + } + ] +} +``` + +Add only the proxy/presence/JetStream subjects actually used by that identity. +JetStream management and advisory subjects are intentionally absent from the +core-NATS example; enabling JetStream requires a separately reviewed permission +set. Store the config, server key, client config, and client password files as +owner-only (`0600`). Prefer a Tailscale/private listener. If a public listener is +unavoidable for a peer, firewall it to that peer's fixed address. + +## Coordinated cutover + +1. Inventory every client and its exact publish/subscribe subjects. +2. Generate the server certificate and separate client passwords. Store bcrypt + password hashes—not plaintext client passwords—in `nats.conf`. +3. Deliver each peer only its own password and the public CA/certificate through + an authenticated, encrypted channel. +4. Update all clients to `tls://`, username/password, and the correct CA file. +5. Stop the clients, replace the broker config, validate it with + `nats-server -t -c`, restart the broker, then restart clients. +6. Prove allowed delivery works, forbidden subjects raise permission violations, + the old token fails, and wrong CA/hostname connections fail. +7. Block arbitrary public TCP/4222 and confirm from an external host. + +Run `packages/broker-nats/integration/run-secure-transport-live.sh` on a host +with `nats-server` and `openssl` for an isolated TLS/ACL proof. diff --git a/examples/agent-runner/README.md b/examples/agent-runner/README.md index a0bd28b..2b0fd4e 100644 --- a/examples/agent-runner/README.md +++ b/examples/agent-runner/README.md @@ -38,9 +38,11 @@ Copy `agent-config.example.json` → `agent-config.json` and set: - `agentId` — your kebab-case id (e.g. `agent-stas`). - `subject` — `msg.` (your inbox). -- `natsUrl` — the **public** broker: `nats://5.181.3.139:4222` +- `natsUrl` — the TLS-required broker: `tls://broker.example:4222`. (the internal `100.95.23.7` Tailscale address is in-tenant only — use the public one). -- `natsToken` — ask the operator (delivered out-of-band, e.g. via the upload bot). +- `natsUser` / `natsPassword` — the distinct, subject-scoped credential assigned + to this agent and delivered out of band. +- `natsTls.caFile` — local path to the operator-provided CA certificate. - `keys` — from step 2. - `peers` — the operator gives you the `agent-jarvis` and `agent-codex-volt` public keys + subjects. Add anyone you need to message. @@ -68,7 +70,7 @@ node agent-runner.mjs send agent-jarvis "HANDSHAKE OK from agent-stas — runner Within ~30–60s you should see an inbound reply logged by your running agent. If the reply does not decrypt, the most common causes are a wrong key, a peer -public key mismatch, or a `natsToken`/network issue — re-check those first. +public key mismatch, or a TLS/credential/network issue — re-check those first. ## Files diff --git a/examples/agent-runner/agent-config.example.json b/examples/agent-runner/agent-config.example.json index 74626e1..edb29a4 100644 --- a/examples/agent-runner/agent-config.example.json +++ b/examples/agent-runner/agent-config.example.json @@ -1,7 +1,9 @@ { "agentId": "agent-stas", - "natsUrl": "nats://5.181.3.139:4222", - "natsToken": "", + "natsUrl": "tls://broker.example:4222", + "natsUser": "", + "natsPassword": "", + "natsTls": { "caFile": "/secure/path/murmur-nats-ca.pem" }, "subject": "msg.agent-stas", "keys": { "encryption": { diff --git a/examples/agent-runner/agent-runner.mjs b/examples/agent-runner/agent-runner.mjs index 23a0299..611e69e 100644 --- a/examples/agent-runner/agent-runner.mjs +++ b/examples/agent-runner/agent-runner.mjs @@ -29,7 +29,17 @@ import { // --- config --- const configPath = process.env.AGENT_CONFIG || "./agent-config.json"; const config = JSON.parse(readFileSync(configPath, "utf8")); -const { agentId, natsUrl, natsToken, subject, keys, peers } = config; +const { + agentId, + natsUrl, + natsToken, + natsUser, + natsPassword, + natsTls, + subject, + keys, + peers, +} = config; const dbPath = process.env.MURMUR_STORE_PATH || "./murmur.db"; const flushIntervalMs = Number(process.env.FLUSH_INTERVAL_MS) || 2000; @@ -38,7 +48,13 @@ const log = (level, msg, data = {}) => const store = new SQLiteMessageStore(dbPath); const outbox = new SQLiteDedupeOutboxStore(dbPath); -const broker = new NatsBroker({ url: natsUrl, token: natsToken }); +const broker = new NatsBroker({ + url: natsUrl, + token: natsToken, + user: natsUser, + password: natsPassword, + tls: natsTls, +}); // Stable payload that gets signed. The canonical source of truth is // `stableEnvelopePayload` in @murmurv2/core, golden-locked in diff --git a/examples/agent-runner/package.json b/examples/agent-runner/package.json index 69203eb..c0a2b0a 100644 --- a/examples/agent-runner/package.json +++ b/examples/agent-runner/package.json @@ -12,8 +12,8 @@ "node": ">=22" }, "dependencies": { - "@murmurv2/broker-nats": "^0.1.0", - "@murmurv2/core": "^0.1.0", + "@murmurv2/broker-nats": "^0.2.1", + "@murmurv2/core": "^0.3.2", "@murmurv2/security": "^0.1.1" } } diff --git a/package-lock.json b/package-lock.json index dbe18c0..f62b690 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1774,11 +1774,11 @@ }, "packages/bridge-a2a": { "name": "@murmurv2/bridge-a2a", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { "@a2a-js/sdk": "1.0.0-alpha.0", - "@murmurv2/core": "^0.2.0", + "@murmurv2/core": "^0.3.2", "@murmurv2/security": "^0.1.0", "express": "^5.1.0", "nats": "^2.28.2" @@ -1787,15 +1787,6 @@ "@types/express": "^5.0.0" } }, - "packages/bridge-a2a/node_modules/@murmurv2/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@murmurv2/core/-/core-0.2.0.tgz", - "integrity": "sha512-ldczX36kucZCajlo4kYfR8MJAlixpITAA629iXbMSNRp5ylaTCdIXm+xoM5vAEBnV3+bE4Eu+sMCydOJWgo8kw==", - "license": "MIT", - "dependencies": { - "pg": "^8.16.3" - } - }, "packages/bridge-murmur": { "name": "@murmurv2/bridge-murmur", "version": "0.1.1", @@ -1803,22 +1794,13 @@ }, "packages/bridge-openclaw": { "name": "@murmurv2/bridge-openclaw", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { - "@murmurv2/core": "^0.2.0", + "@murmurv2/core": "^0.3.2", "nats": "^2.28.2" } }, - "packages/bridge-openclaw/node_modules/@murmurv2/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@murmurv2/core/-/core-0.2.0.tgz", - "integrity": "sha512-ldczX36kucZCajlo4kYfR8MJAlixpITAA629iXbMSNRp5ylaTCdIXm+xoM5vAEBnV3+bE4Eu+sMCydOJWgo8kw==", - "license": "MIT", - "dependencies": { - "pg": "^8.16.3" - } - }, "packages/bridge-telegram": { "name": "@murmurv2/bridge-telegram", "version": "0.1.0", @@ -1839,22 +1821,13 @@ }, "packages/broker-nats": { "name": "@murmurv2/broker-nats", - "version": "0.2.0", + "version": "0.2.1", "license": "MIT", "dependencies": { - "@murmurv2/core": "^0.2.0", + "@murmurv2/core": "^0.3.2", "nats": "^2.28.2" } }, - "packages/broker-nats/node_modules/@murmurv2/core": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/@murmurv2/core/-/core-0.2.0.tgz", - "integrity": "sha512-ldczX36kucZCajlo4kYfR8MJAlixpITAA629iXbMSNRp5ylaTCdIXm+xoM5vAEBnV3+bE4Eu+sMCydOJWgo8kw==", - "license": "MIT", - "dependencies": { - "pg": "^8.16.3" - } - }, "packages/broker-ws": { "name": "@murmurv2/broker-ws", "version": "0.1.0", @@ -1875,7 +1848,7 @@ }, "packages/core": { "name": "@murmurv2/core", - "version": "0.3.1", + "version": "0.3.2", "license": "MIT", "dependencies": { "pg": "^8.16.3" @@ -1913,11 +1886,11 @@ }, "packages/mcp-server": { "name": "@murmurv2/mcp-server", - "version": "0.1.0", + "version": "0.1.1", "license": "MIT", "dependencies": { - "@murmurv2/broker-nats": "^0.2.0", - "@murmurv2/core": "^0.3.0", + "@murmurv2/broker-nats": "^0.2.1", + "@murmurv2/core": "^0.3.2", "@murmurv2/security": "^0.1.0" } }, diff --git a/packages/bridge-a2a/package.json b/packages/bridge-a2a/package.json index 3478d6a..b06b50b 100644 --- a/packages/bridge-a2a/package.json +++ b/packages/bridge-a2a/package.json @@ -1,6 +1,6 @@ { "name": "@murmurv2/bridge-a2a", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -12,7 +12,7 @@ }, "dependencies": { "@a2a-js/sdk": "1.0.0-alpha.0", - "@murmurv2/core": "^0.2.0", + "@murmurv2/core": "^0.3.2", "@murmurv2/security": "^0.1.0", "express": "^5.1.0", "nats": "^2.28.2" diff --git a/packages/bridge-a2a/src/index.ts b/packages/bridge-a2a/src/index.ts index 69a29d4..ab98250 100644 --- a/packages/bridge-a2a/src/index.ts +++ b/packages/bridge-a2a/src/index.ts @@ -26,7 +26,14 @@ import { randomUUID } from "node:crypto"; import type { Server } from "node:http"; import express from "express"; import { StringCodec, connect, type NatsConnection, type Subscription } from "nats"; -import { isEnvelopeV1, stableEnvelopePayload, type AckV1, type EnvelopeV1 } from "@murmurv2/core"; +import { + buildSecureNatsConnectionOptions, + isEnvelopeV1, + stableEnvelopePayload, + type AckV1, + type EnvelopeV1, + type NatsTlsOptions, +} from "@murmurv2/core"; import { decryptPayload, encryptPayload, signEnvelope } from "@murmurv2/security"; import { DefaultRequestHandler, @@ -48,6 +55,9 @@ export interface BridgeA2AConfig { /** NATS bus the internal mesh runs on. */ natsUrl: string; natsToken?: string; + natsUser?: string; + natsPassword?: string; + natsTls?: NatsTlsOptions; /** This bridge's own agent id on the mesh (e.g. "a2a-bridge"). */ agentId: string; /** Default internal recipient for inbound A2A tasks (e.g. "agent-jarvis"). */ @@ -185,7 +195,13 @@ export class A2AMurmurBridge { async start(): Promise { if (this.running) return; - this.nc = await connect({ servers: this.config.natsUrl, token: this.config.natsToken }); + this.nc = await connect(buildSecureNatsConnectionOptions({ + url: this.config.natsUrl, + token: this.config.natsToken, + user: this.config.natsUser, + password: this.config.natsPassword, + tls: this.config.natsTls, + })); // Internal replies/ACKs to this bridge land on ack.; correlate by msgId. this.ackSub = this.nc.subscribe(this.ackSubject()); diff --git a/packages/bridge-openclaw/package.json b/packages/bridge-openclaw/package.json index 38be05e..bc3bf4e 100644 --- a/packages/bridge-openclaw/package.json +++ b/packages/bridge-openclaw/package.json @@ -1,6 +1,6 @@ { "name": "@murmurv2/bridge-openclaw", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -9,7 +9,7 @@ "prepack": "npm run build" }, "dependencies": { - "@murmurv2/core": "^0.2.0", + "@murmurv2/core": "^0.3.2", "nats": "^2.28.2" }, "license": "MIT", diff --git a/packages/bridge-openclaw/src/index.ts b/packages/bridge-openclaw/src/index.ts index 715c5ec..0ab7eaf 100644 --- a/packages/bridge-openclaw/src/index.ts +++ b/packages/bridge-openclaw/src/index.ts @@ -1,10 +1,13 @@ import { StringCodec, connect, type NatsConnection, type Subscription } from "nats"; -import { isEnvelopeV1 } from "@murmurv2/core"; +import { buildSecureNatsConnectionOptions, isEnvelopeV1, type NatsTlsOptions } from "@murmurv2/core"; export interface OpenClawBridgeConfig { agentId: string; natsUrl: string; natsToken?: string; + natsUser?: string; + natsPassword?: string; + natsTls?: NatsTlsOptions; natsSubject?: string; openclawBaseUrl: string; @@ -165,7 +168,13 @@ export class OpenClawBridge { async start(): Promise { if (this.running) return; - this.nc = await connect({ servers: this.config.natsUrl, token: this.config.natsToken }); + this.nc = await connect(buildSecureNatsConnectionOptions({ + url: this.config.natsUrl, + token: this.config.natsToken, + user: this.config.natsUser, + password: this.config.natsPassword, + tls: this.config.natsTls, + })); this.sub = this.nc.subscribe(this.subject()); this.running = true; diff --git a/packages/broker-nats/integration/run-secure-transport-live.sh b/packages/broker-nats/integration/run-secure-transport-live.sh new file mode 100755 index 0000000..0e4993b --- /dev/null +++ b/packages/broker-nats/integration/run-secure-transport-live.sh @@ -0,0 +1,87 @@ +#!/usr/bin/env bash +set -euo pipefail + +NATS_SERVER_BIN="${NATS_SERVER_BIN:-$(command -v nats-server || true)}" +OPENSSL_BIN="${OPENSSL_BIN:-$(command -v openssl || true)}" +SECURE_NATS_PORT="${SECURE_NATS_PORT:-14622}" + +if [[ -z "$NATS_SERVER_BIN" || -z "$OPENSSL_BIN" ]]; then + echo "SKIP: nats-server and openssl are required" + exit 0 +fi + +TEST_DIR="$(mktemp -d "${TMPDIR:-/tmp}/murmur-secure-nats.XXXXXX")" +NATS_PID="" +cleanup() { + if [[ -n "$NATS_PID" ]]; then + kill "$NATS_PID" 2>/dev/null || true + wait "$NATS_PID" 2>/dev/null || true + fi + rm -rf "$TEST_DIR" +} +trap cleanup EXIT + +"$OPENSSL_BIN" req -x509 -newkey rsa:2048 -nodes -sha256 -days 1 \ + -subj "/CN=localhost" \ + -addext "subjectAltName=DNS:localhost" \ + -keyout "$TEST_DIR/server.key" \ + -out "$TEST_DIR/server.crt" >/dev/null 2>&1 +chmod 600 "$TEST_DIR/server.key" + +cat >"$TEST_DIR/nats.conf" <"$TEST_DIR/nats.log" 2>&1 & +NATS_PID=$! + +TLS_READY=0 +for _ in {1..30}; do + kill -0 "$NATS_PID" 2>/dev/null || { + sed -n '1,120p' "$TEST_DIR/nats.log" >&2 + exit 1 + } + # Classic NATS TLS upgrades after the protocol INFO line, so a raw + # openssl s_client readiness probe is not valid unless handshake_first is set. + if grep -q "Server is ready" "$TEST_DIR/nats.log"; then + TLS_READY=1 + break + fi + sleep 0.1 +done +if [[ "$TLS_READY" != "1" ]]; then + echo "TLS listener did not become ready" >&2 + sed -n '1,120p' "$TEST_DIR/nats.log" >&2 + exit 1 +fi + +SECURE_NATS_PORT="$SECURE_NATS_PORT" \ +SECURE_NATS_CA_FILE="$TEST_DIR/server.crt" \ + node packages/broker-nats/integration/secure-transport.live.mjs diff --git a/packages/broker-nats/integration/secure-transport.live.mjs b/packages/broker-nats/integration/secure-transport.live.mjs new file mode 100644 index 0000000..51812a2 --- /dev/null +++ b/packages/broker-nats/integration/secure-transport.live.mjs @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { setTimeout as sleep } from "node:timers/promises"; +import { connect, StringCodec } from "nats"; +import { buildSecureNatsConnectionOptions } from "../../core/dist/src/index.js"; + +const port = process.env.SECURE_NATS_PORT; +const caFile = process.env.SECURE_NATS_CA_FILE; +assert.ok(port && caFile, "secure NATS test environment is incomplete"); + +const url = `tls://127.0.0.1:${port}`; +const connectPeer = (user, password) => connect(buildSecureNatsConnectionOptions({ + url, + user, + password, + tls: { caFile, serverName: "localhost" }, +})); + +const peerA = await connectPeer("agent-a", "test-password-a"); +const peerB = await connectPeer("agent-b", "test-password-b"); +const codec = StringCodec(); + +try { + const allowed = peerB.subscribe("msg.agent-b", { max: 1 }); + await peerB.flush(); + peerA.publish("msg.agent-b", codec.encode("allowed")); + await peerA.flush(); + const received = await Promise.race([ + (async () => { + for await (const message of allowed) return codec.decode(message.data); + return undefined; + })(), + sleep(2_000).then(() => "timeout"), + ]); + assert.equal(received, "allowed", "allowed per-peer message was not delivered"); + + const deniedStatus = (async () => { + for await (const event of peerA.status()) { + if (event.type === "error" && String(event.data).includes("PERMISSIONS_VIOLATION")) { + return event; + } + } + return undefined; + })(); + peerA.publish("msg.agent-a", codec.encode("denied")); + await peerA.flush().catch(() => undefined); + assert.ok( + await Promise.race([deniedStatus, sleep(2_000).then(() => undefined)]), + "forbidden publish did not produce a permission violation", + ); + + await assert.rejects( + connect(buildSecureNatsConnectionOptions({ + url, + token: "retired-shared-token", + tls: { caFile, serverName: "localhost" }, + })), + /authorization|authentication/i, + ); + + await assert.rejects( + connect(buildSecureNatsConnectionOptions({ + url, + user: "agent-a", + password: "test-password-a", + tls: { caFile, serverName: "wrong-host.example" }, + })), + /certificate|hostname|IP/i, + ); + + console.log("secure NATS transport/ACL integration passed"); +} finally { + await Promise.allSettled([peerA.drain(), peerB.drain()]); +} diff --git a/packages/broker-nats/package.json b/packages/broker-nats/package.json index 42de7e4..aed01ca 100644 --- a/packages/broker-nats/package.json +++ b/packages/broker-nats/package.json @@ -1,6 +1,6 @@ { "name": "@murmurv2/broker-nats", - "version": "0.2.0", + "version": "0.2.1", "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -9,7 +9,7 @@ "prepack": "npm run build" }, "dependencies": { - "@murmurv2/core": "^0.2.0", + "@murmurv2/core": "^0.3.2", "nats": "^2.28.2" }, "license": "MIT", diff --git a/packages/broker-nats/src/index.ts b/packages/broker-nats/src/index.ts index f27c38f..b6de499 100644 --- a/packages/broker-nats/src/index.ts +++ b/packages/broker-nats/src/index.ts @@ -23,17 +23,17 @@ import { type AckV1, type SecurityPolicy, streamBackpressureAllowsSend, + buildSecureNatsConnectionOptions, + type SecureNatsClientConfig, validateEnvelopePolicy, } from "@murmurv2/core"; -export interface BrokerConfig { - url: string; +export interface BrokerConfig extends SecureNatsClientConfig { jetstream?: boolean; stream?: string; streamSubjects?: string[]; jetstreamMaxDeliver?: number; jetstreamAckWaitMs?: number; - token?: string; connectMaxAttempts?: number; connectBaseBackoffMs?: number; connectJitterRatio?: number; @@ -79,8 +79,7 @@ interface JetStreamConsumerAdvisory { } export const buildNatsConnectionOptions = (config: BrokerConfig): ConnectionOptions => ({ - servers: config.url, - token: config.token, + ...buildSecureNatsConnectionOptions(config), maxReconnectAttempts: config.maxReconnectAttempts ?? -1, reconnectTimeWait: config.reconnectTimeWait ?? 2000, reconnectJitter: config.reconnectJitter ?? 500, diff --git a/packages/core/package.json b/packages/core/package.json index b0099d9..a00cd83 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@murmurv2/core", - "version": "0.3.1", + "version": "0.3.2", "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af54641..6e22f98 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -12,6 +12,9 @@ export * from "./lease.js"; // Phase N — typed channel roster (channelId distinct from legacy conversationId) export * from "./channel.js"; +// NATS endpoint/auth policy shared by every production NATS client. +export * from "./nats-security.js"; + export type DeliveryMode = "at-least-once"; export interface EnvelopeV1 { diff --git a/packages/core/src/nats-security.ts b/packages/core/src/nats-security.ts new file mode 100644 index 0000000..8cc89b5 --- /dev/null +++ b/packages/core/src/nats-security.ts @@ -0,0 +1,98 @@ +import { isIP } from "node:net"; + +export interface NatsTlsOptions { + handshakeFirst?: boolean; + caFile?: string; + certFile?: string; + keyFile?: string; + /** Certificate DNS identity to verify when the connection URL uses an IP address. */ + serverName?: string; +} + +export interface SecureNatsClientConfig { + url: string; + token?: string; + user?: string; + password?: string; + tls?: NatsTlsOptions; +} + +export interface SecureNatsConnectionOptions { + servers: string; + token?: string; + user?: string; + pass?: string; + tls?: Omit & { servername?: string }; +} + +const isLoopbackHost = (host: string): boolean => { + const normalized = host.toLowerCase().replace(/^\[|\]$/g, ""); + return normalized === "localhost" + || normalized.endsWith(".localhost") + || normalized === "::1" + || (isIP(normalized) === 4 && normalized.split(".")[0] === "127"); +}; + +/** + * Builds the security-sensitive subset of nats.js ConnectionOptions. + * + * Plaintext is permitted only on loopback. A tls:// endpoint always sets an + * explicit tls object, which makes nats.js require TLS and validate the server + * certificate/hostname. Secrets in URLs and mixed token/user auth are rejected. + */ +export const buildSecureNatsConnectionOptions = ( + config: SecureNatsClientConfig, +): SecureNatsConnectionOptions => { + let endpoint: URL; + try { + endpoint = new URL(config.url); + } catch { + throw new Error("nats-url-invalid"); + } + + if (endpoint.protocol !== "nats:" && endpoint.protocol !== "tls:") { + throw new Error("nats-url-scheme-invalid"); + } + if (endpoint.username || endpoint.password) { + throw new Error("nats-url-embedded-credentials-rejected"); + } + if ((endpoint.pathname && endpoint.pathname !== "/") || endpoint.search || endpoint.hash) { + throw new Error("nats-url-components-invalid"); + } + + const token = config.token; + const user = config.user; + const password = config.password; + if (token && (user || password)) { + throw new Error("nats-auth-methods-conflict"); + } + if (!!user !== !!password) { + throw new Error("nats-user-password-pair-required"); + } + + if (endpoint.protocol === "nats:" && !isLoopbackHost(endpoint.hostname)) { + throw new Error("nats-plaintext-non-loopback-rejected"); + } + if (endpoint.protocol === "nats:" && config.tls) { + throw new Error("nats-tls-options-require-tls-url"); + } + + const serverName = config.tls?.serverName?.trim(); + if (endpoint.protocol === "tls:" && isIP(endpoint.hostname) !== 0 && !serverName) { + throw new Error("nats-tls-server-name-required-for-ip"); + } + if (serverName && (isIP(serverName) !== 0 || !/^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/i.test(serverName))) { + throw new Error("nats-tls-server-name-invalid"); + } + + const { serverName: _configuredServerName, ...tlsOptions } = config.tls ?? {}; + + return { + servers: config.url, + ...(token ? { token } : {}), + ...(user && password ? { user, pass: password } : {}), + ...(endpoint.protocol === "tls:" + ? { tls: { ...tlsOptions, ...(serverName ? { servername: serverName } : {}) } } + : {}), + }; +}; diff --git a/packages/core/test/nats-security.test.mjs b/packages/core/test/nats-security.test.mjs new file mode 100644 index 0000000..71f476f --- /dev/null +++ b/packages/core/test/nats-security.test.mjs @@ -0,0 +1,102 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { buildSecureNatsConnectionOptions } from "../dist/src/index.js"; + +test("requires TLS and preserves CA/client certificate files for remote NATS", () => { + assert.deepEqual( + buildSecureNatsConnectionOptions({ + url: "tls://broker.example:4222", + user: "peer-a", + password: "secret-a", + tls: { + caFile: "/run/secrets/nats-ca.pem", + certFile: "/run/secrets/nats-client.pem", + keyFile: "/run/secrets/nats-client.key", + }, + }), + { + servers: "tls://broker.example:4222", + user: "peer-a", + pass: "secret-a", + tls: { + caFile: "/run/secrets/nats-ca.pem", + certFile: "/run/secrets/nats-client.pem", + keyFile: "/run/secrets/nats-client.key", + }, + }, + ); +}); + +test("tls URL requires certificate validation even without custom CA files", () => { + assert.deepEqual( + buildSecureNatsConnectionOptions({ url: "tls://broker.example:4222", token: "secret" }), + { servers: "tls://broker.example:4222", token: "secret", tls: {} }, + ); +}); + +test("literal IP TLS endpoints require an explicit DNS certificate identity", () => { + assert.deepEqual( + buildSecureNatsConnectionOptions({ + url: "tls://192.0.2.10:4222", + tls: { caFile: "/run/secrets/nats-ca.pem", serverName: "murmur-broker.example" }, + }), + { + servers: "tls://192.0.2.10:4222", + tls: { caFile: "/run/secrets/nats-ca.pem", servername: "murmur-broker.example" }, + }, + ); + assert.throws( + () => buildSecureNatsConnectionOptions({ url: "tls://192.0.2.10:4222" }), + /nats-tls-server-name-required-for-ip/, + ); + assert.throws( + () => buildSecureNatsConnectionOptions({ + url: "tls://192.0.2.10:4222", + tls: { serverName: "192.0.2.10" }, + }), + /nats-tls-server-name-invalid/, + ); +}); + +test("allows plaintext only on loopback", () => { + for (const url of [ + "nats://127.0.0.1:4222", + "nats://127.20.30.40:4222", + "nats://localhost:4222", + "nats://[::1]:4222", + ]) { + assert.deepEqual(buildSecureNatsConnectionOptions({ url }), { servers: url }); + } + assert.throws( + () => buildSecureNatsConnectionOptions({ url: "nats://broker.example:4222" }), + /nats-plaintext-non-loopback-rejected/, + ); + assert.throws( + () => buildSecureNatsConnectionOptions({ url: "nats://127.attacker.example:4222" }), + /nats-plaintext-non-loopback-rejected/, + ); +}); + +test("rejects ambiguous URLs and auth", () => { + assert.throws( + () => buildSecureNatsConnectionOptions({ url: "https://broker.example:4222" }), + /nats-url-scheme-invalid/, + ); + assert.throws( + () => buildSecureNatsConnectionOptions({ url: "tls://user:pass@broker.example:4222" }), + /nats-url-embedded-credentials-rejected/, + ); + assert.throws( + () => buildSecureNatsConnectionOptions({ + url: "tls://broker.example:4222", + token: "token", + user: "peer", + password: "password", + }), + /nats-auth-methods-conflict/, + ); + assert.throws( + () => buildSecureNatsConnectionOptions({ url: "tls://broker.example:4222", user: "peer" }), + /nats-user-password-pair-required/, + ); +}); diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index c6ac834..799defa 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -1,6 +1,6 @@ { "name": "@murmurv2/mcp-server", - "version": "0.1.0", + "version": "0.1.1", "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -9,8 +9,8 @@ "prepack": "npm run build" }, "dependencies": { - "@murmurv2/broker-nats": "^0.2.0", - "@murmurv2/core": "^0.3.0", + "@murmurv2/broker-nats": "^0.2.1", + "@murmurv2/core": "^0.3.2", "@murmurv2/security": "^0.1.0" }, "license": "MIT", diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 647c14c..26f4a30 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -25,6 +25,15 @@ interface AgentConfig { agentId: string; natsUrl: string; natsToken?: string; + natsUser?: string; + natsPassword?: string; + natsTls?: { + handshakeFirst?: boolean; + caFile?: string; + certFile?: string; + keyFile?: string; + serverName?: string; + }; subject: string; dataDir: string; keys: { @@ -78,6 +87,9 @@ const getWakeBroker = async (now: () => number = Date.now): Promise { const agentId = process.env.AGENT_ID || await ask("Agent ID", "my-agent"); const natsUrl = process.env.NATS_URL || await ask("NATS URL", "nats://127.0.0.1:4222"); - const natsToken = process.env.NATS_TOKEN || await ask("NATS token", ""); + const natsToken = process.env.NATS_TOKEN || ""; + const natsUser = process.env.NATS_USER || ""; + const natsPassword = process.env.NATS_PASSWORD || ""; + const natsTls = natsUrl.startsWith("tls://") + ? { + ...(process.env.NATS_CA_FILE ? { caFile: process.env.NATS_CA_FILE } : {}), + ...(process.env.NATS_CERT_FILE ? { certFile: process.env.NATS_CERT_FILE } : {}), + ...(process.env.NATS_KEY_FILE ? { keyFile: process.env.NATS_KEY_FILE } : {}), + ...(process.env.NATS_SERVER_NAME ? { serverName: process.env.NATS_SERVER_NAME } : {}), + } + : undefined; console.log("[init] Generating keypairs..."); const encryption = await createKeyPair(); @@ -50,6 +61,9 @@ const run = async () => { agentId, natsUrl, natsToken: natsToken || undefined, + natsUser: natsUser || undefined, + natsPassword: natsPassword || undefined, + natsTls, subject: `msg.${agentId}`, dataDir, cryptoProvider: getCryptoProvider().name, @@ -58,7 +72,8 @@ const run = async () => { }; await mkdir(dataDir, { recursive: true }); - await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); + await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + await chmod(configPath, 0o600); console.log(`[init] Config written to ${configPath}`); console.log(""); diff --git a/scripts/demo-consumer.mjs b/scripts/demo-consumer.mjs index 10eac14..3b76674 100644 --- a/scripts/demo-consumer.mjs +++ b/scripts/demo-consumer.mjs @@ -7,7 +7,13 @@ import { ensureDemoKeys, loadDemoConfig, policyFromConfig, stableEnvelopePayload const cfg = loadDemoConfig(); const keys = await ensureDemoKeys(cfg.keysPath); -const broker = new NatsBroker({ url: cfg.natsUrl, token: cfg.natsToken }); +const broker = new NatsBroker({ + url: cfg.natsUrl, + token: cfg.natsToken, + user: cfg.natsUser, + password: cfg.natsPassword, + tls: cfg.natsTls, +}); const dedupe = new SQLiteDedupeOutboxStore(cfg.dedupeDbPath); const policy = policyFromConfig(cfg); diff --git a/scripts/demo-producer.mjs b/scripts/demo-producer.mjs index 28d9147..9db7396 100644 --- a/scripts/demo-producer.mjs +++ b/scripts/demo-producer.mjs @@ -9,7 +9,13 @@ import { ensureDemoKeys, loadDemoConfig, policyFromConfig, stableEnvelopePayload const cfg = loadDemoConfig(); const keys = await ensureDemoKeys(cfg.keysPath); -const broker = new NatsBroker({ url: cfg.natsUrl, token: cfg.natsToken }); +const broker = new NatsBroker({ + url: cfg.natsUrl, + token: cfg.natsToken, + user: cfg.natsUser, + password: cfg.natsPassword, + tls: cfg.natsTls, +}); const outbox = new SQLiteDedupeOutboxStore(cfg.outboxDbPath); const waitForAck = async (dbPath, msgId, timeoutMs) => { diff --git a/scripts/demo-secure-common.mjs b/scripts/demo-secure-common.mjs index 0348bbb..4854ff6 100644 --- a/scripts/demo-secure-common.mjs +++ b/scripts/demo-secure-common.mjs @@ -20,6 +20,16 @@ export const loadDemoConfig = () => { return { natsUrl: process.env.NATS_URL || "nats://127.0.0.1:4222", natsToken: process.env.NATS_TOKEN || undefined, + natsUser: process.env.NATS_USER || undefined, + natsPassword: process.env.NATS_PASSWORD || undefined, + natsTls: process.env.NATS_URL?.startsWith("tls://") + ? { + ...(process.env.NATS_CA_FILE ? { caFile: process.env.NATS_CA_FILE } : {}), + ...(process.env.NATS_CERT_FILE ? { certFile: process.env.NATS_CERT_FILE } : {}), + ...(process.env.NATS_KEY_FILE ? { keyFile: process.env.NATS_KEY_FILE } : {}), + ...(process.env.NATS_SERVER_NAME ? { serverName: process.env.NATS_SERVER_NAME } : {}), + } + : undefined, subject: process.env.SUBJECT || "msg.demo.secure", consumerId: process.env.CONSUMER_ID || recipientAgentId, outboxDbPath: process.env.OUTBOX_DB_PATH || ".data/demo-outbox.db", diff --git a/scripts/murmur-daemon.mjs b/scripts/murmur-daemon.mjs index 6b2c455..20cd6a5 100644 --- a/scripts/murmur-daemon.mjs +++ b/scripts/murmur-daemon.mjs @@ -33,7 +33,7 @@ try { process.exit(1); } -const { agentId, natsUrl, natsToken, subject, peers, keys } = config; +const { agentId, natsUrl, natsToken, natsUser, natsPassword, natsTls, subject, peers, keys } = config; const dbPath = path.join(dataDir, "murmur.db"); const flushIntervalMs = Number(process.env.FLUSH_INTERVAL_MS) || 2000; const jetstreamConfig = config.jetstream || {}; @@ -129,6 +129,9 @@ if (channelRosterEnabled) log("info", "Channel roster thread-start binding enabl const broker = new NatsBroker({ url: natsUrl, token: natsToken, + user: natsUser, + password: natsPassword, + tls: natsTls, jetstream: jetstreamEnabled, stream: jetstreamEnabled ? jetstreamStream : undefined, streamSubjects: jetstreamSubjects, diff --git a/scripts/murmur-invite.mjs b/scripts/murmur-invite.mjs index 8a97b8f..b848f57 100644 --- a/scripts/murmur-invite.mjs +++ b/scripts/murmur-invite.mjs @@ -21,17 +21,39 @@ try { process.exit(1); } +const inviteNatsUser = process.env.MURMUR_INVITE_NATS_USER || undefined; +const inviteNatsPassword = process.env.MURMUR_INVITE_NATS_PASSWORD || undefined; const invite = { v: 1, type: "invite", agentId: config.agentId, natsUrl: config.natsUrl, - natsToken: config.natsToken || undefined, + natsToken: inviteNatsUser + ? undefined + : process.env.MURMUR_INVITE_NATS_TOKEN || config.natsToken || undefined, + natsUser: inviteNatsUser, + natsPassword: inviteNatsPassword, + natsCaPem: config.natsTls?.caFile + ? await readFile(config.natsTls.caFile, "utf8") + : undefined, + natsServerName: config.natsTls?.serverName || undefined, subject: config.subject, encryption: { publicKey: config.keys.encryption.publicKey }, signing: { publicKey: config.keys.signing.publicKey }, }; +if (config.natsUser && (!invite.natsUser || !invite.natsPassword)) { + console.error( + "[invite] Per-peer broker auth requires dedicated MURMUR_INVITE_NATS_USER and " + + "MURMUR_INVITE_NATS_PASSWORD values. Refusing to share this agent's credential.", + ); + process.exit(1); +} +if (!!invite.natsUser !== !!invite.natsPassword) { + console.error("[invite] Both MURMUR_INVITE_NATS_USER and MURMUR_INVITE_NATS_PASSWORD are required."); + process.exit(1); +} + const blob = "MURMUR:" + Buffer.from(JSON.stringify(invite)).toString("base64"); console.log(""); diff --git a/scripts/murmur-join.mjs b/scripts/murmur-join.mjs index ebfc38d..da3bbb3 100644 --- a/scripts/murmur-join.mjs +++ b/scripts/murmur-join.mjs @@ -8,7 +8,7 @@ * Env: AGENT_ID (default: prompted), DATA_DIR (default: .data) */ import { createInterface } from "node:readline/promises"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { chmod, mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { createKeyPair, createSigningKeyPair, getCryptoProvider } from "@murmurv2/security"; @@ -34,6 +34,23 @@ console.log(`[join] NATS: ${invite.natsUrl}`); const dataDir = process.env.DATA_DIR || ".data"; const configPath = path.join(dataDir, "agent-config.json"); +await mkdir(dataDir, { recursive: true }); +let natsTls; +if (invite.natsCaPem) { + if (typeof invite.natsCaPem !== "string" || invite.natsCaPem.length > 131_072) { + console.error("[join] Invalid NATS CA certificate in invite."); + process.exit(1); + } + const caPath = path.resolve(dataDir, "nats-ca.pem"); + await writeFile(caPath, invite.natsCaPem, { encoding: "utf8", mode: 0o600 }); + await chmod(caPath, 0o600); + natsTls = { + caFile: caPath, + ...(invite.natsServerName ? { serverName: invite.natsServerName } : {}), + }; +} else if (invite.natsServerName) { + natsTls = { serverName: invite.natsServerName }; +} // Check if config exists let config; @@ -57,6 +74,9 @@ try { agentId, natsUrl: invite.natsUrl, natsToken: invite.natsToken || undefined, + natsUser: invite.natsUser || undefined, + natsPassword: invite.natsPassword || undefined, + natsTls, subject: `msg.${agentId}`, dataDir, cryptoProvider: getCryptoProvider().name, @@ -64,8 +84,8 @@ try { peers: {}, }; - await mkdir(dataDir, { recursive: true }); - await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); + await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); + await chmod(configPath, 0o600); console.log(`[join] Config created: ${configPath}`); } @@ -83,7 +103,8 @@ if (config.natsUrl !== invite.natsUrl) { console.log(`[join] Keeping yours. Edit .data/agent-config.json if needed.`); } -await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", "utf8"); +await writeFile(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); +await chmod(configPath, 0o600); console.log(`[join] Added peer: ${invite.agentId}`); // Generate reply blob diff --git a/scripts/murmur-mcp-channel-server.mjs b/scripts/murmur-mcp-channel-server.mjs index 1d04cda..26f302f 100755 --- a/scripts/murmur-mcp-channel-server.mjs +++ b/scripts/murmur-mcp-channel-server.mjs @@ -83,7 +83,13 @@ const emitToSession = envFlag("MURMUR_MCP_TO_SESSION", true); const textPrefix = process.env.MURMUR_MCP_TEXT_PREFIX || ""; const { SessionLeaseStore } = await import(leaseModuleUrl); -const broker = new NatsBroker({ url: config.natsUrl, token: config.natsToken }); +const broker = new NatsBroker({ + url: config.natsUrl, + token: config.natsToken, + user: config.natsUser, + password: config.natsPassword, + tls: config.natsTls, +}); const dedupe = new SQLiteDedupeOutboxStore(dbPath); const lease = new SessionLeaseStore(leaseDbPath); diff --git a/tests/broker-ack-subject.test.mjs b/tests/broker-ack-subject.test.mjs index 12ac826..cfdb208 100644 --- a/tests/broker-ack-subject.test.mjs +++ b/tests/broker-ack-subject.test.mjs @@ -38,7 +38,7 @@ test("subscribeWithAck publishes ack to original sender ack subject", async () = async seen() { return false; }, async markSeen() {}, }; - const broker = new NatsBroker({ url: "nats://example.invalid" }); + const broker = new NatsBroker({ url: "tls://example.invalid" }); broker.nc = fakeNc; await broker.subscribeWithAck({ diff --git a/tests/broker-auth-enforce.test.mjs b/tests/broker-auth-enforce.test.mjs index d803291..a266e2a 100644 --- a/tests/broker-auth-enforce.test.mjs +++ b/tests/broker-auth-enforce.test.mjs @@ -40,7 +40,7 @@ function harness() { async drain() {}, }; const dedupe = { async seen() { return false; }, async markSeen() {} }; - const broker = new NatsBroker({ url: "nats://example.invalid" }); + const broker = new NatsBroker({ url: "tls://example.invalid" }); broker.nc = fakeNc; return { broker, dedupe, published }; } diff --git a/tests/broker-flush.test.mjs b/tests/broker-flush.test.mjs index 1ca1b1f..2ec122e 100644 --- a/tests/broker-flush.test.mjs +++ b/tests/broker-flush.test.mjs @@ -49,7 +49,7 @@ test("flushOutbox marks policy failures as DLQ", async () => { const outbox = makeOutbox([ { msgId: envelope.msgId, subject: "s", envelope, attempts: 0, status: "pending", nextAttemptAt: new Date().toISOString() }, ]); - const broker = new NatsBroker({ url: "nats://invalid:4222" }); + const broker = new NatsBroker({ url: "tls://invalid:4222" }); broker.publish = async () => { throw new Error("policy-rejected:recipient-not-allowed:agent.b"); }; @@ -62,7 +62,7 @@ test("flushOutbox retries transient failures with failed status", async () => { const outbox = makeOutbox([ { msgId: envelope.msgId, subject: "s", envelope, attempts: 0, status: "pending", nextAttemptAt: new Date().toISOString() }, ]); - const broker = new NatsBroker({ url: "nats://invalid:4222" }); + const broker = new NatsBroker({ url: "tls://invalid:4222" }); broker.publish = async () => { throw new Error("network-timeout"); }; @@ -90,7 +90,7 @@ test("flushOutbox respects durable ACK window before publishing more chunks", as { msgId: pendingEnvelope.msgId, subject: "s", envelope: pendingEnvelope, attempts: 0, status: "pending", nextAttemptAt: new Date().toISOString() }, ]); const published = []; - const broker = new NatsBroker({ url: "nats://invalid:4222" }); + const broker = new NatsBroker({ url: "tls://invalid:4222" }); broker.publish = async (_subject, env) => { published.push(env.msgId); }; diff --git a/tests/broker-jetstream.test.mjs b/tests/broker-jetstream.test.mjs index c2ba6ff..5a68e25 100644 --- a/tests/broker-jetstream.test.mjs +++ b/tests/broker-jetstream.test.mjs @@ -125,7 +125,7 @@ const makeJetStreamBroker = ({ }; const broker = new NatsBroker({ - url: "nats://example.invalid", + url: "tls://example.invalid", jetstream: true, stream: "MURMUR", streamSubjects: ["msg.>", "ack.>"], @@ -150,7 +150,7 @@ test("JetStream publish ensures stream and uses envelope msgId as dedupe id", as test("JetStream disabled keeps core NATS publish path", async () => { const published = []; - const broker = new NatsBroker({ url: "nats://example.invalid" }); + const broker = new NatsBroker({ url: "tls://example.invalid" }); broker.nc = { publish(subject, data) { published.push({ subject, body: JSON.parse(sc.decode(data)) }); diff --git a/tests/broker-reconnect-options.test.mjs b/tests/broker-reconnect-options.test.mjs index d96a8e0..218c679 100644 --- a/tests/broker-reconnect-options.test.mjs +++ b/tests/broker-reconnect-options.test.mjs @@ -4,11 +4,12 @@ import { buildNatsConnectionOptions } from "../packages/broker-nats/dist/src/ind test("buildNatsConnectionOptions enables resilient reconnect defaults", () => { const options = buildNatsConnectionOptions({ - url: "nats://example.invalid:4222", + url: "tls://example.invalid:4222", token: "secret", }); - assert.equal(options.servers, "nats://example.invalid:4222"); + assert.equal(options.servers, "tls://example.invalid:4222"); + assert.deepEqual(options.tls, {}); assert.equal(options.token, "secret"); assert.equal(options.maxReconnectAttempts, -1); assert.equal(options.reconnectTimeWait, 2000); @@ -20,7 +21,7 @@ test("buildNatsConnectionOptions enables resilient reconnect defaults", () => { test("buildNatsConnectionOptions allows bounded operator overrides", () => { const options = buildNatsConnectionOptions({ - url: "nats://example.invalid:4222", + url: "tls://example.invalid:4222", maxReconnectAttempts: 10, reconnectTimeWait: 5000, reconnectJitter: 1000,