diff --git a/CHANGELOG.md b/CHANGELOG.md index a322097..2c7a27f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **Signed, outbox-bound delivery ACKs** — NATS and WebSocket brokers now fail + closed on legacy unsigned ACKs; Ed25519 signatures bind every ACK/NACK to the + exact envelope, conversation, original sender, intended recipient, timestamp, + and durable replay nonce. Per-agent ACK subjects are enforced, invalid frames + emit metadata-only counters/logs, and SQLite/JSON stores apply verified ACKs + exactly once. This is a coordinated, wire-breaking peer upgrade; see + `docs/signed-acks.md`. - **Production file-level deploy tooling** — `deploy/production-file-deploy.sh` now builds gitignored `dist/` artifacts before copying the live-runtime allowlist, includes Phase N core/MCP/channel roster files, and refuses to diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index 2ab2d9d..920413e 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -63,11 +63,22 @@ forthcoming in auth/authz #47 PR-D. | Field | Required | Type | Constraint | |-------|----------|------|------------| +| `ackVersion` | ✅ | string | `const "1.0"` | +| `ackId` | ✅ | string | non-empty, replay nonce | | `msgId` | ✅ | string | non-empty | +| `messageDigest` | ✅ | string | lowercase SHA-256 hex of the exact signed envelope | +| `conversationId` | ✅ | string | non-empty, must match the pending envelope | | `consumerId` | ✅ | string | non-empty | +| `senderAgentId` | ✅ | string | non-empty, trusted signing peer and envelope recipient | +| `recipientAgentId` | ✅ | string | non-empty, original envelope sender | | `status` | ✅ | string | enum `ack` \| `nack` | | `at` | ✅ | string | ISO-8601 date-time | | `reason` | — | string | | +| `signature` | ✅ | string | non-empty Ed25519 signature over canonical ACK fields | + +Legacy unsigned ACKs are wire-incompatible and rejected. ACK correlation additionally +enforces freshness, the exact per-agent ACK subject, pinned-peer signature verification, +outbox binding, and durable nonce replay protection. ## PresenceFrameV1 (discovery) @@ -154,9 +165,9 @@ and are intentionally **outside** the schema↔guard agreement matrices: - **`createdAt` / `ts` date-time validity** — `format: date-time` is an advisory annotation in Draft 2020-12; the runtime guards enforce it via `Date.parse` (`isEnvelopeV1` for `EnvelopeV1.createdAt`, `isPresenceFrameV1` for `PresenceFrameV1.ts`). -- **`AckV1.at`** — generated as an ISO-8601 string by `createAck`, but there is **no - `isAckV1` guard**: its `format: date-time` is advisory only (validator-dependent) with - no runtime enforcement on read. +- **`AckV1.at`** — generated as an ISO-8601 string by `createAck` and enforced by + `isAckV1` via `Date.parse`. Brokers additionally reject ACKs outside their configured + age/future-skew window before signature and outbox correlation can change state. - **signature verification & payload decryption** — `@murmurv2/security`, not shape. - **stream semantics** — `chunkIndex` bounds, `totalBytes` accounting, and `digest`/`sha256` matching are the reassembler's job, not the frame guards'. diff --git a/docs/protocol-v1.md b/docs/protocol-v1.md index e4b8622..abd14bc 100644 --- a/docs/protocol-v1.md +++ b/docs/protocol-v1.md @@ -12,7 +12,7 @@ the guards cannot drift. Versioning and forward-compatibility rules live in | Type | Purpose | Schema `$def` | Runtime guard | |------|---------|---------------|---------------| | `EnvelopeV1` | encrypted inbound message | document root (`#/$defs/EnvelopeV1`) | `isEnvelopeV1` | -| `AckV1` | delivery acknowledgement | `#/$defs/AckV1` | — | +| `AckV1` | signed, envelope-bound delivery acknowledgement | `#/$defs/AckV1` | `isAckV1` | | `PresenceFrameV1` | discovery announcement (public metadata) | `#/$defs/PresenceFrameV1` | `isPresenceFrameV1` | | `SignedPresenceFrameV1` | Ed25519-signed presence | `#/$defs/SignedPresenceFrameV1` | `isSignedPresenceFrameV1` | | `StreamStart` / `StreamChunk` / `StreamEnd` | chunked payload streaming | `#/$defs/Stream*` (+ `StreamFrame` union) | `isStreamStart` / `isStreamChunk` / `isStreamEnd` / `isStreamFrame` | @@ -28,7 +28,7 @@ Envelope message payloads are encrypted on the wire; presence frames are intenti 3. Publish to subject `msg.` 4. Consumer validates schema+signature 5. Consumer processes idempotently using `msgId` -6. Consumer emits ACK or NACK +6. Consumer emits an Ed25519-signed ACK or NACK bound to the exact envelope and peer pair 7. Retry policy moves failed messages; terminal failures go to DLQ An optional `authToken` (bearer `MURMUR-AUTH:…`) authorizes the sender. When present it diff --git a/docs/signed-acks.md b/docs/signed-acks.md new file mode 100644 index 0000000..8a110f6 --- /dev/null +++ b/docs/signed-acks.md @@ -0,0 +1,50 @@ +# Signed ACK rollout + +Murmur delivery acknowledgements are signed application messages. An ACK is +accepted only when its Ed25519 signature is trusted, its timestamp is fresh, its +nonce has not been processed before, and its sender, recipient, conversation, +message id, and envelope digest match the pending outbox record. + +This is a wire-breaking security upgrade. Legacy `{msgId,status}` ACKs fail +closed, and older consumers cannot produce the signed `AckV1` shape. Upgrade all +active Murmur producers and consumers in one coordinated maintenance window. + +## Required configuration + +Every `NatsBroker` and `WebSocketBroker` delivery or correlation instance must +receive `ackSecurity` with: + +- the real local agent id; +- a signer backed by that agent's existing Ed25519 private key; and +- a verifier that resolves the claimed ACK sender to its pinned Ed25519 public + key and fails closed for unknown peers. + +The daemon, MCP channel server, agent runner, and demos already wire this from +their existing key configuration. Private key material must not be copied into +logs, ACK frames, environment diagnostics, or migration output. + +## Coordinated rollout + +1. Back up the Murmur SQLite/JSON state files and confirm every active peer has a + signing private key plus pinned signing public keys for its recipients. +2. Stop message-producing and message-consuming Murmur services on every peer. +3. Deploy the upgraded core and broker packages to every peer. +4. Start the brokers and consumers, then the producers. +5. Send one real message in each direction and confirm a signed ACK moves the + matching outbox row to `acked` exactly once. +6. Replay the captured ACK in an isolated test and confirm it is rejected as + `replay`; submit an unsigned legacy ACK and confirm it is rejected as + `unsigned-or-invalid`. +7. Confirm rejection telemetry contains only reason, message id, and sender id, + never raw frames or decrypted content. + +Keep per-peer NATS publish/subscribe ACLs enabled as a separate defense. Signed +ACKs make forged frames ineffective; broker ACLs reduce who can send frames to +the ACK subjects in the first place. + +## Rollback + +Rollback must also be coordinated across all peers. Stop all Murmur traffic, +restore the prior package set everywhere, and restart consumers before producers. +Do not run mixed legacy and signed-ACK peers: that causes legitimate deliveries +to retry because their acknowledgements are mutually incompatible. diff --git a/examples/agent-runner/agent-runner.mjs b/examples/agent-runner/agent-runner.mjs index 23a0299..a5de0e5 100644 --- a/examples/agent-runner/agent-runner.mjs +++ b/examples/agent-runner/agent-runner.mjs @@ -18,7 +18,7 @@ import { randomUUID } from "node:crypto"; import { readFileSync } from "node:fs"; import { NatsBroker } from "@murmurv2/broker-nats"; -import { SQLiteDedupeOutboxStore, SQLiteMessageStore } from "@murmurv2/core"; +import { SQLiteDedupeOutboxStore, SQLiteMessageStore, stableEnvelopePayload } from "@murmurv2/core"; import { decryptPayload, encryptPayload, @@ -38,30 +38,19 @@ const log = (level, msg, data = {}) => const store = new SQLiteMessageStore(dbPath); const outbox = new SQLiteDedupeOutboxStore(dbPath); -const broker = new NatsBroker({ url: natsUrl, token: natsToken }); - -// Stable payload that gets signed. The canonical source of truth is -// `stableEnvelopePayload` in @murmurv2/core, golden-locked in -// packages/core/test/stable-envelope-payload.test.mjs — this MUST stay byte-identical -// (field set + order) or signatures will not verify against the mesh. -// -// It is intentionally INLINED here (not imported from core) because this example pins -// the PUBLISHED `@murmurv2/core@^0.1.0`, which predates the exported helper; importing -// it would break a standalone `npm install` of this template. Switch to -// `import { stableEnvelopePayload } from "@murmurv2/core"` when bumping to the core -// version that exports it — tracked for auth/authz #47 PR-C (when authToken enters the -// signed payload and core is re-published). -const stableEnvelopePayload = (e) => - JSON.stringify({ - schemaVersion: e.schemaVersion, - msgId: e.msgId, - conversationId: e.conversationId, - senderAgentId: e.senderAgentId, - recipients: [...e.recipients], - createdAt: e.createdAt, - payloadCiphertext: e.payloadCiphertext, - payloadNonce: e.payloadNonce, - }); +const broker = new NatsBroker({ + url: natsUrl, + token: natsToken, + ackSecurity: { + localAgentId: agentId, + sign: (payload) => signEnvelope(payload, keys.signing.privateKey), + verify: async (senderAgentId, payload, signature) => { + const peer = peers[senderAgentId]; + return !!peer?.signing?.publicKey && verifyEnvelopeSignature(payload, signature, peer.signing.publicKey); + }, + onRejected: (event) => log("warn", "ack-rejected", event), + }, +}); // --- send: encrypt -> sign -> enqueue to outbox (daemon flushes to NATS) --- async function sendMessage(to, text, conversationId) { diff --git a/examples/agent-runner/package.json b/examples/agent-runner/package.json index 69203eb..914918f 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.3.0", + "@murmurv2/core": "^0.4.0", "@murmurv2/security": "^0.1.1" } } diff --git a/package-lock.json b/package-lock.json index dbe18c0..2261e98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1839,43 +1839,25 @@ }, "packages/broker-nats": { "name": "@murmurv2/broker-nats", - "version": "0.2.0", + "version": "0.3.0", "license": "MIT", "dependencies": { - "@murmurv2/core": "^0.2.0", + "@murmurv2/core": "^0.4.0", "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", - "license": "MIT", - "dependencies": { - "@murmurv2/core": "^0.2.0", - "ws": "^8.21.0" - } - }, - "packages/broker-ws/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" + "@murmurv2/core": "^0.4.0", + "ws": "^8.21.0" } }, "packages/core": { "name": "@murmurv2/core", - "version": "0.3.1", + "version": "0.4.0", "license": "MIT", "dependencies": { "pg": "^8.16.3" @@ -1921,6 +1903,34 @@ "@murmurv2/security": "^0.1.0" } }, + "packages/mcp-server/node_modules/@murmurv2/broker-nats": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@murmurv2/broker-nats/-/broker-nats-0.2.0.tgz", + "integrity": "sha512-JPhM++PYYVHHx8gY5R39YgbZTQJzj1xJUvQ9lPhKMZZjuvD9MIJ6ED8J3laKQPXNLf5ttnmJmJvsSBLwZleBgw==", + "license": "MIT", + "dependencies": { + "@murmurv2/core": "^0.2.0", + "nats": "^2.28.2" + } + }, + "packages/mcp-server/node_modules/@murmurv2/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/mcp-server/node_modules/@murmurv2/core": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@murmurv2/core/-/core-0.3.1.tgz", + "integrity": "sha512-LQa1hbyCrIKNvlUrrVsq8BCdKVaw/F3vE2Mxv2ZcJI+9WsRfgUdOrtnSksqbRSre1DYcI0eo7aJ2eEpyqEapYA==", + "license": "MIT", + "dependencies": { + "pg": "^8.16.3" + } + }, "packages/observability": { "name": "@murmurv2/observability", "version": "0.1.2", diff --git a/packages/bridge-a2a/src/index.ts b/packages/bridge-a2a/src/index.ts index 69a29d4..fed9ae9 100644 --- a/packages/bridge-a2a/src/index.ts +++ b/packages/bridge-a2a/src/index.ts @@ -26,7 +26,7 @@ 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 { isEnvelopeV1, stableEnvelopePayload, type EnvelopeV1 } from "@murmurv2/core"; import { decryptPayload, encryptPayload, signEnvelope } from "@murmurv2/security"; import { DefaultRequestHandler, @@ -284,7 +284,7 @@ export class A2AMurmurBridge { return reply; } - /** Internal reply (fresh EnvelopeV1 with parentMsgId) or AckV1 -> resolve A2A task. */ + /** Internal reply envelope -> resolve A2A task. ACK frames never resolve tasks. */ private async handleInternalReply(raw: string): Promise { const parsed: unknown = JSON.parse(raw); @@ -303,15 +303,9 @@ export class A2AMurmurBridge { return; } - const ack = parsed as AckV1; - if (ack?.msgId && ack.status === "nack") { - const resolve = this.pending.get(ack.msgId); - if (resolve) { - this.pending.delete(ack.msgId); - resolve(`[murmur nack] ${ack.reason ?? "rejected"}`); - } - } - // A positive AckV1 only confirms delivery; the real answer arrives as an envelope. + // Delivery ACKs are handled only by the broker's signed, outbox-bound + // correlation path. This bridge waits for the real reply envelope or timeout; + // unverified ACK-shaped JSON cannot suppress or resolve the task. } async stop(): Promise { diff --git a/packages/bridge-a2a/test/bridge-a2a.test.mjs b/packages/bridge-a2a/test/bridge-a2a.test.mjs index 0e268d5..73deb97 100644 --- a/packages/bridge-a2a/test/bridge-a2a.test.mjs +++ b/packages/bridge-a2a/test/bridge-a2a.test.mjs @@ -135,6 +135,24 @@ test("dispatchInboundTask: rejects a non-allowlisted external agent (no NATS nee ); }); +test("unsigned ACK-shaped JSON cannot resolve or suppress a pending A2A task", async () => { + const { cfg } = await fixture(); + const bridge = new A2AMurmurBridge(cfg); + let resolved = false; + bridge.pending.set("original-message", () => { resolved = true; }); + + await bridge.handleInternalReply(JSON.stringify({ + msgId: "original-message", + consumerId: "claimed-peer", + status: "nack", + reason: "attacker-controlled", + at: new Date().toISOString(), + })); + + assert.equal(resolved, false); + assert.equal(bridge.pending.has("original-message"), true); +}); + test("sealTaskEnvelope: throws when target has no recipient key", async () => { const { cfg } = await fixture(); await assert.rejects( diff --git a/packages/broker-nats/package.json b/packages/broker-nats/package.json index 42de7e4..564d381 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.3.0", "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.4.0", "nats": "^2.28.2" }, "license": "MIT", diff --git a/packages/broker-nats/src/index.ts b/packages/broker-nats/src/index.ts index f27c38f..1f7cdb0 100644 --- a/packages/broker-nats/src/index.ts +++ b/packages/broker-nats/src/index.ts @@ -13,9 +13,11 @@ import { applyJitter, computeBackoffMs, createAck, + envelopeDigest, estimateBase64DecodedBytes, type EnvelopeV1, isEnvelopeV1, + isAckV1, isSignedPresenceFrameV1, type SignedPresenceFrameV1, type DedupeStore, @@ -23,6 +25,7 @@ import { type AckV1, type SecurityPolicy, streamBackpressureAllowsSend, + stableAckPayload, validateEnvelopePolicy, } from "@murmurv2/core"; @@ -44,6 +47,22 @@ export interface BrokerConfig { maxPingOut?: number; waitOnFirstConnect?: boolean; onStatus?: (status: BrokerStatusEvent) => void; + ackSecurity?: AckSecurityConfig; +} + +export interface AckRejectedEvent { + reason: string; + msgId?: string; + senderAgentId?: string; +} + +export interface AckSecurityConfig { + localAgentId: string; + sign(payload: string): Promise; + verify(senderAgentId: string, payload: string, signature: string): Promise; + maxAgeMs?: number; + maxFutureSkewMs?: number; + onRejected?: (event: AckRejectedEvent) => void; } export type MessageHandler = (envelope: EnvelopeV1) => Promise; @@ -95,6 +114,7 @@ export class NatsBroker { private jsm?: JetStreamManager; private readonly sc = StringCodec(); private readonly failedDeliveries = new Map(); + private readonly ackRejections = new Map(); private reconnects = 0; private statusLoop?: Promise; @@ -138,6 +158,45 @@ export class NatsBroker { return this.reconnects; } + getAckRejectionCounts(): Record { + return Object.fromEntries(this.ackRejections.entries()); + } + + private requireAckSecurity(): AckSecurityConfig { + const security = this.config.ackSecurity; + if (!security || !security.localAgentId || typeof security.sign !== "function" || typeof security.verify !== "function") { + throw new Error("ack-security-required"); + } + return security; + } + + private rejectAck(reason: string, ack?: Partial): void { + this.ackRejections.set(reason, (this.ackRejections.get(reason) ?? 0) + 1); + const event: AckRejectedEvent = { + reason, + ...(typeof ack?.msgId === "string" ? { msgId: ack.msgId } : {}), + ...(typeof ack?.senderAgentId === "string" ? { senderAgentId: ack.senderAgentId } : {}), + }; + this.config.ackSecurity?.onRejected?.(event); + console.warn("[NatsBroker.ack] rejected", event); + } + + private async signedAck( + envelope: EnvelopeV1, + consumerId: string, + status: AckV1["status"], + reason?: string, + ): Promise { + const security = this.requireAckSecurity(); + if (!envelope.recipients.includes(security.localAgentId)) { + throw new Error("ack-local-agent-not-recipient"); + } + const ack = createAck(envelope, consumerId, security.localAgentId, status, reason); + ack.signature = await security.sign(stableAckPayload(ack)); + if (!isAckV1(ack)) throw new Error("ack-signing-failed"); + return ack; + } + private jetStreamEnabled(): boolean { return this.config.jetstream === true || !!this.config.stream; } @@ -240,11 +299,15 @@ export class NatsBroker { } async publishAck(subject: string, envelope: ReturnType): Promise { + if (!isAckV1(envelope)) throw new Error("signed-ack-required"); + const security = this.requireAckSecurity(); + if (envelope.senderAgentId !== security.localAgentId) throw new Error("ack-sender-mismatch"); + if (subject !== `ack.${envelope.recipientAgentId}`) throw new Error("ack-subject-mismatch"); await this.connect(); const payload = this.sc.encode(JSON.stringify(envelope)); if (this.js) { await this.js.publish(subject, payload, { - msgID: `ack:${envelope.msgId}:${envelope.consumerId}:${envelope.status}`, + msgID: `ack:${envelope.ackId}`, }); return; } @@ -266,7 +329,6 @@ export class NatsBroker { try { const decoded = JSON.parse(this.sc.decode(data)); if (!isEnvelopeV1(decoded)) { - await this.publishAck(ackSubject, createAck("unknown", params.consumerId, "nack", "invalid-envelope")); return "ack"; } @@ -274,7 +336,7 @@ export class NatsBroker { ackSubject = `ack.${decoded.senderAgentId}`; const isDup = await params.dedupe.seen(decoded.msgId, params.consumerId); if (isDup) { - await this.publishAck(ackSubject, createAck(decoded.msgId, params.consumerId, "ack", "duplicate-ignored")); + await this.publishAck(ackSubject, await this.signedAck(decoded, params.consumerId, "ack", "duplicate-ignored")); return "ack"; } @@ -286,7 +348,7 @@ export class NatsBroker { if (!authz.accepted) { await this.publishAck( ackSubject, - createAck(decoded.msgId, params.consumerId, "nack", `auth-rejected:${authz.reason ?? "denied"}`), + await this.signedAck(decoded, params.consumerId, "nack", `auth-rejected:${authz.reason ?? "denied"}`), ); return "ack"; } @@ -295,7 +357,7 @@ export class NatsBroker { await params.onMessage(decoded); await params.dedupe.markSeen(decoded.msgId, params.consumerId); this.failedDeliveries.delete(`${params.consumerId}:${decoded.msgId}`); - await this.publishAck(ackSubject, createAck(decoded.msgId, params.consumerId, "ack")); + await this.publishAck(ackSubject, await this.signedAck(decoded, params.consumerId, "ack")); return "ack"; } catch (err) { const reason = err instanceof Error ? err.message : "handler-failed"; @@ -306,10 +368,16 @@ export class NatsBroker { if (msgId !== "unknown" && failures >= maxPoisonAttempts) { await params.dedupe.markSeen(msgId, params.consumerId); this.failedDeliveries.delete(key); - await this.publishAck(ackSubject, createAck(msgId, params.consumerId, "nack", `poison-message:${reason}`)); + const decoded = JSON.parse(this.sc.decode(data)); + if (isEnvelopeV1(decoded)) { + await this.publishAck(ackSubject, await this.signedAck(decoded, params.consumerId, "nack", `poison-message:${reason}`)); + } return "ack"; } - await this.publishAck(ackSubject, createAck(msgId, params.consumerId, "nack", reason)); + const decoded = JSON.parse(this.sc.decode(data)); + if (isEnvelopeV1(decoded)) { + await this.publishAck(ackSubject, await this.signedAck(decoded, params.consumerId, "nack", reason)); + } return "retry"; } } @@ -391,6 +459,7 @@ export class NatsBroker { * (wire @murmurv2/federation authorizeInbound here behind MURMUR_ENFORCE_AUTH). */ authorize?: InboundAuthorizer; }): Promise { + this.requireAckSecurity(); await this.connect(); if (this.js) { @@ -497,6 +566,8 @@ export class NatsBroker { ackSubject: string; consumerId?: string; }): Promise { + const security = this.requireAckSecurity(); + if (params.ackSubject !== `ack.${security.localAgentId}`) throw new Error("ack-subject-mismatch"); await this.connect(); if (this.js) { @@ -521,30 +592,64 @@ export class NatsBroker { } private async processAckFrame(data: Uint8Array, outbox: OutboxStore): Promise { + let decoded: unknown; try { - const decoded = JSON.parse(this.sc.decode(data)) as AckV1; - if (typeof decoded.msgId !== "string" || decoded.msgId.length === 0) return; + decoded = JSON.parse(this.sc.decode(data)); + } catch { + this.rejectAck("malformed"); + return; + } + if (!isAckV1(decoded)) { + this.rejectAck("unsigned-or-invalid", decoded && typeof decoded === "object" ? decoded as Partial : undefined); + return; + } - if (decoded.status === "ack") { - await outbox.markAcked(decoded.msgId); - return; - } + const security = this.config.ackSecurity; + if (!security) { + this.rejectAck("security-unavailable", decoded); + return; + } + const at = Date.parse(decoded.at); + const now = Date.now(); + const maxAgeMs = security.maxAgeMs ?? 10 * 60_000; + const maxFutureSkewMs = security.maxFutureSkewMs ?? 60_000; + if (now - at > maxAgeMs || at - now > maxFutureSkewMs) { + this.rejectAck("stale-or-future", decoded); + return; + } - if (decoded.status === "nack") { - await outbox.markFailed( - decoded.msgId, - decoded.reason ?? "nack", - new Date().toISOString(), - ); - } - } catch (err) { - const e = err instanceof Error ? err : new Error(String(err)); - console.error("[NatsBroker.startAckCorrelation] malformed ack frame", { - message: e.message, - stack: e.stack, - raw: this.sc.decode(data), - }); + let signatureValid = false; + try { + signatureValid = await security.verify(decoded.senderAgentId, stableAckPayload(decoded), decoded.signature); + } catch { + signatureValid = false; + } + if (!signatureValid) { + this.rejectAck("signature-invalid", decoded); + return; + } + + const pending = await outbox.get(decoded.msgId); + if (!pending) { + this.rejectAck("message-unknown", decoded); + return; + } + if ( + decoded.recipientAgentId !== security.localAgentId || + decoded.recipientAgentId !== pending.envelope.senderAgentId || + !pending.envelope.recipients.includes(decoded.senderAgentId) || + decoded.conversationId !== pending.envelope.conversationId || + decoded.messageDigest !== envelopeDigest(pending.envelope) + ) { + this.rejectAck("binding-mismatch", decoded); + return; + } + + const result = await outbox.applyVerifiedAck(decoded); + if (result === "applied") { + return; } + this.rejectAck(result === "replay" ? "replay" : result, decoded); } async startJetStreamAdvisoryDlq(params: { diff --git a/packages/broker-ws/package.json b/packages/broker-ws/package.json index 2f9da31..1ef2494 100644 --- a/packages/broker-ws/package.json +++ b/packages/broker-ws/package.json @@ -1,6 +1,6 @@ { "name": "@murmurv2/broker-ws", - "version": "0.1.0", + "version": "0.2.0", "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", @@ -10,7 +10,7 @@ "prepack": "npm run build" }, "dependencies": { - "@murmurv2/core": "^0.2.0", + "@murmurv2/core": "^0.4.0", "ws": "^8.21.0" }, "license": "MIT", diff --git a/packages/broker-ws/src/index.ts b/packages/broker-ws/src/index.ts index d8a2df8..88d4612 100644 --- a/packages/broker-ws/src/index.ts +++ b/packages/broker-ws/src/index.ts @@ -3,12 +3,15 @@ import { applyJitter, computeBackoffMs, createAck, + envelopeDigest, type AckV1, type DedupeStore, type EnvelopeV1, + isAckV1, isEnvelopeV1, type OutboxStore, type SecurityPolicy, + stableAckPayload, validateEnvelopePolicy, } from "@murmurv2/core"; @@ -56,6 +59,22 @@ export interface WebSocketRelayListenResult { export interface WebSocketBrokerConfig { url: string; + ackSecurity?: AckSecurityConfig; +} + +export interface AckRejectedEvent { + reason: string; + msgId?: string; + senderAgentId?: string; +} + +export interface AckSecurityConfig { + localAgentId: string; + sign(payload: string): Promise; + verify(senderAgentId: string, payload: string, signature: string): Promise; + maxAgeMs?: number; + maxFutureSkewMs?: number; + onRejected?: (event: AckRejectedEvent) => void; } export type WebSocketMessageHandler = (envelope: EnvelopeV1) => Promise; @@ -175,9 +194,49 @@ export class WebSocketBroker { private readonly messageSubscriptions = new Set(); private readonly ackSubscriptions = new Set(); private readonly failedDeliveries = new Map(); + private readonly ackRejections = new Map(); constructor(private readonly config: WebSocketBrokerConfig) {} + getAckRejectionCounts(): Record { + return Object.fromEntries(this.ackRejections.entries()); + } + + private requireAckSecurity(): AckSecurityConfig { + const security = this.config.ackSecurity; + if (!security || !security.localAgentId || typeof security.sign !== "function" || typeof security.verify !== "function") { + throw new Error("ack-security-required"); + } + return security; + } + + private rejectAck(reason: string, ack?: Partial): void { + this.ackRejections.set(reason, (this.ackRejections.get(reason) ?? 0) + 1); + const event: AckRejectedEvent = { + reason, + ...(typeof ack?.msgId === "string" ? { msgId: ack.msgId } : {}), + ...(typeof ack?.senderAgentId === "string" ? { senderAgentId: ack.senderAgentId } : {}), + }; + this.config.ackSecurity?.onRejected?.(event); + console.warn("[WebSocketBroker.ack] rejected", event); + } + + private async signedAck( + envelope: EnvelopeV1, + consumerId: string, + status: AckV1["status"], + reason?: string, + ): Promise { + const security = this.requireAckSecurity(); + if (!envelope.recipients.includes(security.localAgentId)) { + throw new Error("ack-local-agent-not-recipient"); + } + const ack = createAck(envelope, consumerId, security.localAgentId, status, reason); + ack.signature = await security.sign(stableAckPayload(ack)); + if (!isAckV1(ack)) throw new Error("ack-signing-failed"); + return ack; + } + async connect(): Promise { if (this.socket?.readyState === WebSocket.OPEN) return; if (this.connectPromise) return this.connectPromise; @@ -221,6 +280,10 @@ export class WebSocketBroker { } async publishAck(subject: string, ack: AckV1): Promise { + if (!isAckV1(ack)) throw new Error("signed-ack-required"); + const security = this.requireAckSecurity(); + if (ack.senderAgentId !== security.localAgentId) throw new Error("ack-sender-mismatch"); + if (subject !== `ack.${ack.recipientAgentId}`) throw new Error("ack-subject-mismatch"); await this.send({ type: "ack", subject, ack }); } @@ -231,6 +294,7 @@ export class WebSocketBroker { onMessage: WebSocketMessageHandler; maxPoisonAttempts?: number; }): Promise { + this.requireAckSecurity(); await this.connect(); const sub: MessageSubscription = { ...params, active: true, queue: Promise.resolve() }; this.messageSubscriptions.add(sub); @@ -247,6 +311,8 @@ export class WebSocketBroker { outbox: OutboxStore; ackSubject: string; }): Promise { + const security = this.requireAckSecurity(); + if (params.ackSubject !== `ack.${security.localAgentId}`) throw new Error("ack-subject-mismatch"); await this.connect(); const sub: AckSubscription = { subject: params.ackSubject, outbox: params.outbox, active: true }; this.ackSubscriptions.add(sub); @@ -294,10 +360,6 @@ export class WebSocketBroker { private async processEnvelopeFrame(raw: unknown, params: MessageSubscription): Promise { if (!isEnvelopeV1(raw)) { - const sender = typeof raw === "object" && raw && typeof (raw as Record).senderAgentId === "string" - ? String((raw as Record).senderAgentId) - : params.consumerId; - await this.publishAck(`ack.${sender}`, createAck("unknown", params.consumerId, "nack", "invalid-envelope")); return; } @@ -305,7 +367,7 @@ export class WebSocketBroker { const ackSubject = `ack.${envelope.senderAgentId}`; const isDup = await params.dedupe.seen(envelope.msgId, params.consumerId); if (isDup) { - await this.publishAck(ackSubject, createAck(envelope.msgId, params.consumerId, "ack", "duplicate-ignored")); + await this.publishAck(ackSubject, await this.signedAck(envelope, params.consumerId, "ack", "duplicate-ignored")); return; } @@ -313,7 +375,7 @@ export class WebSocketBroker { await params.onMessage(envelope); await params.dedupe.markSeen(envelope.msgId, params.consumerId); this.failedDeliveries.delete(`${params.consumerId}:${envelope.msgId}`); - await this.publishAck(ackSubject, createAck(envelope.msgId, params.consumerId, "ack")); + await this.publishAck(ackSubject, await this.signedAck(envelope, params.consumerId, "ack")); } catch (err) { const reason = err instanceof Error ? err.message : "handler-failed"; const maxPoisonAttempts = params.maxPoisonAttempts ?? 3; @@ -323,22 +385,56 @@ export class WebSocketBroker { if (failures >= maxPoisonAttempts) { await params.dedupe.markSeen(envelope.msgId, params.consumerId); this.failedDeliveries.delete(key); - await this.publishAck(ackSubject, createAck(envelope.msgId, params.consumerId, "nack", `poison-message:${reason}`)); + await this.publishAck(ackSubject, await this.signedAck(envelope, params.consumerId, "nack", `poison-message:${reason}`)); return; } - await this.publishAck(ackSubject, createAck(envelope.msgId, params.consumerId, "nack", reason)); + await this.publishAck(ackSubject, await this.signedAck(envelope, params.consumerId, "nack", reason)); } } private async processAckFrame(ack: AckV1, outbox: OutboxStore): Promise { - if (!ack || typeof ack.msgId !== "string" || ack.msgId.length === 0) return; - if (ack.status === "ack") { - await outbox.markAcked(ack.msgId); + if (!isAckV1(ack)) { + this.rejectAck("unsigned-or-invalid", ack && typeof ack === "object" ? ack : undefined); + return; + } + const security = this.config.ackSecurity; + if (!security) { + this.rejectAck("security-unavailable", ack); return; } - if (ack.status === "nack") { - await outbox.markFailed(ack.msgId, ack.reason ?? "nack", new Date().toISOString()); + const at = Date.parse(ack.at); + const now = Date.now(); + if (now - at > (security.maxAgeMs ?? 10 * 60_000) || at - now > (security.maxFutureSkewMs ?? 60_000)) { + this.rejectAck("stale-or-future", ack); + return; + } + let signatureValid = false; + try { + signatureValid = await security.verify(ack.senderAgentId, stableAckPayload(ack), ack.signature); + } catch { + signatureValid = false; + } + if (!signatureValid) { + this.rejectAck("signature-invalid", ack); + return; + } + const pending = await outbox.get(ack.msgId); + if (!pending) { + this.rejectAck("message-unknown", ack); + return; + } + if ( + ack.recipientAgentId !== security.localAgentId || + ack.recipientAgentId !== pending.envelope.senderAgentId || + !pending.envelope.recipients.includes(ack.senderAgentId) || + ack.conversationId !== pending.envelope.conversationId || + ack.messageDigest !== envelopeDigest(pending.envelope) + ) { + this.rejectAck("binding-mismatch", ack); + return; } + const result = await outbox.applyVerifiedAck(ack); + if (result !== "applied") this.rejectAck(result === "replay" ? "replay" : result, ack); } async flushOutbox(params: { diff --git a/packages/broker-ws/test/broker-ws.test.mjs b/packages/broker-ws/test/broker-ws.test.mjs index 84c025b..93c9bff 100644 --- a/packages/broker-ws/test/broker-ws.test.mjs +++ b/packages/broker-ws/test/broker-ws.test.mjs @@ -19,6 +19,12 @@ function envelope(overrides = {}) { }; } +const ackSecurity = (localAgentId) => ({ + localAgentId, + sign: async () => `test-signature:${localAgentId}`, + verify: async () => true, +}); + async function eventually(fn, timeoutMs = 1500) { const start = Date.now(); let lastErr; @@ -47,12 +53,33 @@ class MemoryOutbox { acked = []; failed = []; - async markAcked(msgId) { - this.acked.push(msgId); + constructor(env) { + this.record = { + msgId: env.msgId, + subject: "msg.bob", + envelope: env, + status: "sent", + attempts: 1, + nextAttemptAt: env.createdAt, + createdAt: env.createdAt, + updatedAt: env.createdAt, + }; + } + + async get(msgId) { + return msgId === this.record.msgId ? this.record : undefined; } - async markFailed(msgId, error) { - this.failed.push({ msgId, error }); + async applyVerifiedAck(ack) { + if (this.record.status === "acked" || this.record.status === "dlq") return "terminal"; + if (ack.status === "ack") { + this.record.status = "acked"; + this.acked.push(ack.msgId); + } else { + this.record.status = "failed"; + this.failed.push({ msgId: ack.msgId, error: ack.reason ?? "nack" }); + } + return "applied"; } } @@ -66,10 +93,10 @@ test("wsSubjectMatches supports exact, star, and tail wildcards", () => { test("WebSocketBroker publishes envelopes through a relay and correlates ACKs", async () => { await withRelay(async (url) => { - const alice = new WebSocketBroker({ url }); - const bob = new WebSocketBroker({ url }); + const alice = new WebSocketBroker({ url, ackSecurity: ackSecurity("alice") }); + const bob = new WebSocketBroker({ url, ackSecurity: ackSecurity("bob") }); const seen = []; - const outbox = new MemoryOutbox(); + const outbox = new MemoryOutbox(envelope()); await alice.startAckCorrelation({ ackSubject: "ack.alice", outbox }); await bob.subscribeWithAck({ @@ -93,10 +120,10 @@ test("WebSocketBroker publishes envelopes through a relay and correlates ACKs", test("WebSocketBroker dedupes duplicate envelope delivery and still ACKs the duplicate", async () => { await withRelay(async (url) => { - const alice = new WebSocketBroker({ url }); - const bob = new WebSocketBroker({ url }); + const alice = new WebSocketBroker({ url, ackSecurity: ackSecurity("alice") }); + const bob = new WebSocketBroker({ url, ackSecurity: ackSecurity("bob") }); const seen = []; - const outbox = new MemoryOutbox(); + const outbox = new MemoryOutbox(envelope()); await alice.startAckCorrelation({ ackSubject: "ack.alice", outbox }); await bob.subscribeWithAck({ @@ -112,18 +139,18 @@ test("WebSocketBroker dedupes duplicate envelope delivery and still ACKs the dup await alice.publish("msg.bob", envelope()); await eventually(() => assert.deepEqual(seen, ["msg-1"])); - await eventually(() => assert.deepEqual(outbox.acked, ["msg-1", "msg-1"])); + await eventually(() => assert.deepEqual(outbox.acked, ["msg-1"])); await alice.close(); await bob.close(); }); }); -test("WebSocketBroker NACKs invalid envelope frames", async () => { +test("WebSocketBroker drops invalid envelope frames without emitting an unbound ACK", async () => { await withRelay(async (url) => { - const alice = new WebSocketBroker({ url }); - const bob = new WebSocketBroker({ url }); - const outbox = new MemoryOutbox(); + const alice = new WebSocketBroker({ url, ackSecurity: ackSecurity("alice") }); + const bob = new WebSocketBroker({ url, ackSecurity: ackSecurity("bob") }); + const outbox = new MemoryOutbox(envelope()); await alice.startAckCorrelation({ ackSubject: "ack.alice", outbox }); await bob.subscribeWithAck({ @@ -139,9 +166,22 @@ test("WebSocketBroker NACKs invalid envelope frames", async () => { await new Promise((resolve) => raw.once("open", resolve)); raw.send(JSON.stringify({ type: "message", subject: "msg.bob", envelope: { msgId: "bad", senderAgentId: "alice" } })); - await eventually(() => assert.deepEqual(outbox.failed, [{ msgId: "unknown", error: "invalid-envelope" }])); + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.deepEqual(outbox.failed, []); raw.close(); await alice.close(); await bob.close(); }); }); + +test("WebSocketBroker enforces the local per-agent ACK subject before connecting", async () => { + const broker = new WebSocketBroker({ + url: "ws://example.invalid", + ackSecurity: ackSecurity("alice"), + }); + const outbox = new MemoryOutbox(envelope()); + await assert.rejects( + () => broker.startAckCorrelation({ ackSubject: "ack.someone-else", outbox }), + /ack-subject-mismatch/, + ); +}); diff --git a/packages/core/package.json b/packages/core/package.json index b0099d9..a0d2058 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@murmurv2/core", - "version": "0.3.1", + "version": "0.4.0", "type": "module", "main": "dist/src/index.js", "types": "dist/src/index.d.ts", diff --git a/packages/core/schema/protocol-v1.schema.json b/packages/core/schema/protocol-v1.schema.json index d552088..88064a9 100644 --- a/packages/core/schema/protocol-v1.schema.json +++ b/packages/core/schema/protocol-v1.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/alexfrmn/murmur/schema/protocol-v1.schema.json", "title": "Murmur V2 Protocol v1 — EnvelopeV1 (canonical inbound-envelope schema)", - "description": "Machine-readable schema for the Murmur V2 wire protocol (schemaVersion 1.0). The document ROOT validates an EnvelopeV1 (the canonical inbound-message target), via $ref to #/$defs/EnvelopeV1. Validate ACKs against #/$defs/AckV1, discovery presence against #/$defs/{PresenceFrameV1,SignedPresenceFrameV1}, and stream frames against #/$defs/{StreamStart,StreamChunk,StreamEnd} or the discriminated #/$defs/StreamFrame. Mirrors the runtime guards (isEnvelopeV1, isPresenceFrameV1, isSignedPresenceFrameV1, isStreamStart/Chunk/End/isStreamFrame) in @murmurv2/core. Unknown top-level fields are permitted for forward compatibility (consumers ignore them). Numeric bounds (ttlMs > 0), value finiteness, and date-time validity are advisory here and enforced at runtime by the guards.", + "description": "Machine-readable schema for the Murmur V2 wire protocol (schemaVersion 1.0). The document ROOT validates an EnvelopeV1 (the canonical inbound-message target), via $ref to #/$defs/EnvelopeV1. Validate ACKs against #/$defs/AckV1, discovery presence against #/$defs/{PresenceFrameV1,SignedPresenceFrameV1}, and stream frames against #/$defs/{StreamStart,StreamChunk,StreamEnd} or the discriminated #/$defs/StreamFrame. Mirrors the runtime guards (isEnvelopeV1, isAckV1, isPresenceFrameV1, isSignedPresenceFrameV1, isStreamStart/Chunk/End/isStreamFrame) in @murmurv2/core. Unknown top-level fields are permitted for forward compatibility (consumers ignore them). Numeric bounds (ttlMs > 0), value finiteness, and date-time validity are advisory here and enforced at runtime by the guards.", "$ref": "#/$defs/EnvelopeV1", "$defs": { "EnvelopeV1": { @@ -40,14 +40,34 @@ } }, "AckV1": { + "description": "Ed25519-signed application ACK bound to one exact envelope and peer pair.", "type": "object", - "required": ["msgId", "consumerId", "status", "at"], + "required": [ + "ackVersion", + "ackId", + "msgId", + "messageDigest", + "conversationId", + "consumerId", + "senderAgentId", + "recipientAgentId", + "status", + "at", + "signature" + ], "properties": { + "ackVersion": { "const": "1.0" }, + "ackId": { "type": "string", "minLength": 1 }, "msgId": { "type": "string", "minLength": 1 }, + "messageDigest": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "conversationId": { "type": "string", "minLength": 1 }, "consumerId": { "type": "string", "minLength": 1 }, + "senderAgentId": { "type": "string", "minLength": 1 }, + "recipientAgentId": { "type": "string", "minLength": 1 }, "status": { "enum": ["ack", "nack"] }, "reason": { "type": "string" }, - "at": { "type": "string", "format": "date-time" } + "at": { "type": "string", "format": "date-time" }, + "signature": { "type": "string", "minLength": 1 } } }, "PresenceFrameV1": { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index af54641..ad2182b 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -38,11 +38,18 @@ export interface EnvelopeV1 { } export interface AckV1 { + ackVersion: "1.0"; + ackId: string; msgId: string; + messageDigest: string; + conversationId: string; consumerId: string; + senderAgentId: string; + recipientAgentId: string; status: "ack" | "nack"; reason?: string; at: string; + signature: string; } /** @@ -69,6 +76,31 @@ export const stableEnvelopePayload = (envelope: EnvelopeV1): string => ...(envelope.authToken !== undefined ? { authToken: envelope.authToken } : {}), }); +/** Digest bound into every ACK so a valid signature cannot be moved to a + * different envelope that happens to reuse a message id. */ +export const envelopeDigest = (envelope: EnvelopeV1): string => + createHash("sha256") + .update(stableEnvelopePayload(envelope), "utf8") + .update("\n", "utf8") + .update(envelope.signature, "utf8") + .digest("hex"); + +/** Canonical signed representation for AckV1. */ +export const stableAckPayload = (ack: AckV1): string => + JSON.stringify({ + ackVersion: ack.ackVersion, + ackId: ack.ackId, + msgId: ack.msgId, + messageDigest: ack.messageDigest, + conversationId: ack.conversationId, + consumerId: ack.consumerId, + senderAgentId: ack.senderAgentId, + recipientAgentId: ack.recipientAgentId, + status: ack.status, + reason: ack.reason ?? null, + at: ack.at, + }); + export interface DedupeStore { seen(msgId: string, consumerId: string): Promise; markSeen(msgId: string, consumerId: string): Promise; @@ -168,12 +200,17 @@ export interface OutboxRecord { createdAt: string; updatedAt: string; version?: number; + processedAckIds?: string[]; } +export type AckApplyResult = "applied" | "replay" | "not-found" | "terminal"; + export interface OutboxStore { enqueue(subject: string, envelope: EnvelopeV1): Promise; claimDue(limit?: number): Promise; + get(msgId: string): Promise; listInFlight?(): Promise; + applyVerifiedAck(ack: AckV1): Promise; markSent(msgId: string): Promise; markAcked(msgId: string): Promise; markFailed(msgId: string, error: string, nextAttemptAt: string): Promise; @@ -232,6 +269,11 @@ export class JsonFileOutboxStore implements OutboxStore { .slice(0, limit); } + async get(msgId: string): Promise { + const state = await this.load(); + return state.records.find((row) => row.msgId === msgId); + } + async listInFlight(): Promise { const state = await this.load(); return state.records.filter((r) => r.status === "sent"); @@ -240,7 +282,7 @@ export class JsonFileOutboxStore implements OutboxStore { async markSent(msgId: string): Promise { const state = await this.load(); const row = state.records.find((r) => r.msgId === msgId); - if (!row) return; + if (!row || !["pending", "failed"].includes(row.status)) return; row.status = "sent"; row.attempts += 1; row.updatedAt = new Date().toISOString(); @@ -281,6 +323,25 @@ export class JsonFileOutboxStore implements OutboxStore { await this.save(state); } + async applyVerifiedAck(ack: AckV1): Promise { + const state = await this.load(); + const row = state.records.find((record) => record.msgId === ack.msgId); + if (!row) return "not-found"; + if (row.processedAckIds?.includes(ack.ackId)) return "replay"; + if (row.status === "acked" || row.status === "dlq" || (row.status === "failed" && ack.status === "nack")) { + return "terminal"; + } + + row.processedAckIds = [...(row.processedAckIds ?? []), ack.ackId]; + row.status = ack.status === "ack" ? "acked" : "failed"; + row.lastError = ack.status === "nack" ? (ack.reason ?? "nack") : undefined; + if (ack.status === "nack") row.nextAttemptAt = new Date().toISOString(); + row.updatedAt = new Date().toISOString(); + row.version = (row.version ?? 0) + 1; + await this.save(state); + return "applied"; + } + async requeueStaleSent(ackTimeoutMs: number, reason = "ack-timeout"): Promise { const state = await this.load(); const now = Date.now(); @@ -334,6 +395,11 @@ export class SQLiteDedupeOutboxStore implements DedupeStore, OutboxStore { updated_at TEXT NOT NULL, version INTEGER NOT NULL DEFAULT 1 ); + CREATE TABLE IF NOT EXISTS ack_receipts ( + ack_id TEXT PRIMARY KEY, + msg_id TEXT NOT NULL, + processed_at TEXT NOT NULL + ); CREATE INDEX IF NOT EXISTS idx_outbox_due ON outbox(status, next_attempt_at); `); } @@ -377,6 +443,10 @@ export class SQLiteDedupeOutboxStore implements DedupeStore, OutboxStore { return rows.map((row) => this.toOutboxRecord(row)); } + async get(msgId: string): Promise { + return this.getOutboxRow(msgId); + } + async listInFlight(): Promise { const rows = this.db .prepare("SELECT * FROM outbox WHERE status = 'sent' ORDER BY updated_at ASC") @@ -385,11 +455,16 @@ export class SQLiteDedupeOutboxStore implements DedupeStore, OutboxStore { } async markSent(msgId: string): Promise { - await this.updateOutboxOptimistic(msgId, (row) => ({ - status: "sent", - attempts: row.attempts + 1, - updatedAt: new Date().toISOString(), - })); + // A fast consumer can ACK between publish() and this call. Never overwrite + // that terminal transition with "sent"; the conditional update makes either + // ordering safe across processes sharing the SQLite outbox. + this.db + .prepare( + `UPDATE outbox + SET status = 'sent', attempts = attempts + 1, updated_at = ?, version = version + 1 + WHERE msg_id = ? AND status IN ('pending', 'failed')`, + ) + .run(new Date().toISOString(), msgId); } async markAcked(msgId: string): Promise { @@ -416,6 +491,60 @@ export class SQLiteDedupeOutboxStore implements DedupeStore, OutboxStore { })); } + async applyVerifiedAck(ack: AckV1): Promise { + this.db.exec("BEGIN IMMEDIATE"); + try { + const replay = this.db + .prepare("SELECT 1 FROM ack_receipts WHERE ack_id = ? LIMIT 1") + .get(ack.ackId); + if (replay) { + this.db.exec("ROLLBACK"); + return "replay"; + } + + const current = this.getOutboxRow(ack.msgId); + if (!current) { + this.db.exec("ROLLBACK"); + return "not-found"; + } + if ( + current.status === "acked" || + current.status === "dlq" || + (current.status === "failed" && ack.status === "nack") + ) { + this.db.exec("ROLLBACK"); + return "terminal"; + } + + const now = new Date().toISOString(); + this.db + .prepare("INSERT INTO ack_receipts (ack_id, msg_id, processed_at) VALUES (?, ?, ?)") + .run(ack.ackId, ack.msgId, now); + this.db + .prepare( + `UPDATE outbox + SET status = ?, last_error = ?, next_attempt_at = ?, updated_at = ?, version = version + 1 + WHERE msg_id = ?`, + ) + .run( + ack.status === "ack" ? "acked" : "failed", + ack.status === "nack" ? (ack.reason ?? "nack") : null, + ack.status === "nack" ? now : current.nextAttemptAt, + now, + ack.msgId, + ); + this.db.exec("COMMIT"); + return "applied"; + } catch (error) { + try { + this.db.exec("ROLLBACK"); + } catch { + // Preserve the original database error. + } + throw error; + } + } + async requeueStaleSent(ackTimeoutMs: number, reason = "ack-timeout"): Promise { const threshold = new Date(Date.now() - ackTimeoutMs).toISOString(); const res = this.db @@ -1215,17 +1344,44 @@ export const isEnvelopeV1 = (v: unknown): v is EnvelopeV1 => { ); }; +export const isAckV1 = (v: unknown): v is AckV1 => { + if (!v || typeof v !== "object") return false; + const o = v as Record; + return ( + o.ackVersion === "1.0" && + typeof o.ackId === "string" && o.ackId.length > 0 && + typeof o.msgId === "string" && o.msgId.length > 0 && + typeof o.messageDigest === "string" && /^[0-9a-f]{64}$/.test(o.messageDigest) && + typeof o.conversationId === "string" && o.conversationId.length > 0 && + typeof o.consumerId === "string" && o.consumerId.length > 0 && + typeof o.senderAgentId === "string" && o.senderAgentId.length > 0 && + typeof o.recipientAgentId === "string" && o.recipientAgentId.length > 0 && + (o.status === "ack" || o.status === "nack") && + (o.reason === undefined || typeof o.reason === "string") && + typeof o.at === "string" && !Number.isNaN(Date.parse(o.at)) && + typeof o.signature === "string" && o.signature.length > 0 + ); +}; + export const createAck = ( - msgId: string, + envelope: EnvelopeV1, consumerId: string, + senderAgentId: string, status: AckV1["status"], reason?: string, ): AckV1 => ({ - msgId, + ackVersion: "1.0", + ackId: randomUUID(), + msgId: envelope.msgId, + messageDigest: envelopeDigest(envelope), + conversationId: envelope.conversationId, consumerId, + senderAgentId, + recipientAgentId: envelope.senderAgentId, status, reason, at: new Date().toISOString(), + signature: "", }); export const computeBackoffMs = (attempt: number, baseMs = 500, maxMs = 60_000): number => { diff --git a/packages/core/test/ack-outbox.test.mjs b/packages/core/test/ack-outbox.test.mjs new file mode 100644 index 0000000..edd233f --- /dev/null +++ b/packages/core/test/ack-outbox.test.mjs @@ -0,0 +1,78 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + JsonFileOutboxStore, + SQLiteDedupeOutboxStore, + createAck, +} from "../dist/src/index.js"; + +const envelope = Object.freeze({ + schemaVersion: "1.0", + msgId: "persistent-ack-message", + conversationId: "persistent-ack-conversation", + senderAgentId: "sender", + recipients: ["receiver"], + createdAt: new Date().toISOString(), + payloadCiphertext: "ciphertext", + payloadNonce: "nonce", + signature: "envelope-signature", +}); + +function nack() { + const ack = createAck(envelope, "receiver-consumer", "receiver", "nack", "retry-me"); + ack.signature = "verified-before-store"; + return ack; +} + +async function provePersistentReplayProtection(makeStore) { + const first = makeStore(); + await first.enqueue("msg.receiver", envelope); + await first.markSent(envelope.msgId); + + const ack = nack(); + assert.equal(await first.applyVerifiedAck(ack), "applied"); + assert.equal((await first.get(envelope.msgId)).status, "failed"); + + // Simulate a retry plus process restart. The durable receipt, rather than the + // current outbox status, must prevent the same signed ACK from transitioning it. + await first.markSent(envelope.msgId); + const reopened = makeStore(); + assert.equal(await reopened.applyVerifiedAck(ack), "replay"); + assert.equal((await reopened.get(envelope.msgId)).status, "sent"); +} + +test("SQLite outbox persists ACK nonces across retries and process restarts", async () => { + const dir = mkdtempSync(join(tmpdir(), "murmur-ack-sqlite-")); + const dbPath = join(dir, "murmur.db"); + await provePersistentReplayProtection(() => new SQLiteDedupeOutboxStore(dbPath)); +}); + +test("JSON outbox persists ACK nonces across retries and process restarts", async () => { + const dir = mkdtempSync(join(tmpdir(), "murmur-ack-json-")); + const filePath = join(dir, "outbox.json"); + await provePersistentReplayProtection(() => new JsonFileOutboxStore(filePath)); +}); + +for (const [name, makeStore] of [ + ["SQLite", () => { + const dir = mkdtempSync(join(tmpdir(), "murmur-fast-ack-sqlite-")); + return new SQLiteDedupeOutboxStore(join(dir, "murmur.db")); + }], + ["JSON", () => { + const dir = mkdtempSync(join(tmpdir(), "murmur-fast-ack-json-")); + return new JsonFileOutboxStore(join(dir, "outbox.json")); + }], +]) { + test(`${name} outbox does not overwrite an ACK that arrives before markSent`, async () => { + const store = makeStore(); + await store.enqueue("msg.receiver", envelope); + const ack = createAck(envelope, "receiver-consumer", "receiver", "ack"); + ack.signature = "verified-before-store"; + assert.equal(await store.applyVerifiedAck(ack), "applied"); + await store.markSent(envelope.msgId); + assert.equal((await store.get(envelope.msgId)).status, "acked"); + }); +} diff --git a/packages/core/test/conformance.test.mjs b/packages/core/test/conformance.test.mjs index cc01b6d..1e49cbc 100644 --- a/packages/core/test/conformance.test.mjs +++ b/packages/core/test/conformance.test.mjs @@ -16,6 +16,7 @@ import { readFileSync } from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { + isAckV1, isEnvelopeV1, isPresenceFrameV1, isSignedPresenceFrameV1, @@ -60,6 +61,7 @@ function validate(def, value, p = "$") { case "string": if (typeof value !== "string") errs.push(`${p}: string`); else if (def.minLength != null && value.length < def.minLength) errs.push(`${p}: minLength`); + else if (def.pattern != null && !(new RegExp(def.pattern)).test(value)) errs.push(`${p}: pattern`); break; case "number": if (typeof value !== "number") errs.push(`${p}: number`); @@ -102,6 +104,19 @@ const GOOD = Object.freeze({ signature: "sig", }); const without = (key) => { const e = { ...GOOD }; delete e[key]; return e; }; +const GOOD_ACK = Object.freeze({ + ackVersion: "1.0", + ackId: "ack-1", + msgId: "m1", + messageDigest: "a".repeat(64), + conversationId: "c1", + consumerId: "agent-b-consumer", + senderAgentId: "agent-b", + recipientAgentId: "agent-a", + status: "ack", + at: "2026-06-21T00:00:00.000Z", + signature: "sig", +}); test("schema bundle is a valid Draft 2020-12 $defs registry", () => { assert.equal(schema.$schema, "https://json-schema.org/draft/2020-12/schema"); @@ -151,11 +166,20 @@ test("optional fields + forward-compatible unknown fields are accepted by both", assert.equal(isEnvelopeV1(e), true); }); -test("AckV1: required fields + status enum", () => { - assert.equal(ackOk({ msgId: "m", consumerId: "c", status: "ack", at: "2026-06-21T00:00:00Z" }), true); - assert.equal(ackOk({ msgId: "m", consumerId: "c", status: "nack", reason: "x", at: "2026-06-21T00:00:00Z" }), true); - assert.equal(ackOk({ msgId: "m", consumerId: "c", status: "maybe", at: "2026-06-21T00:00:00Z" }), false); - assert.equal(ackOk({ msgId: "m", status: "ack", at: "2026-06-21T00:00:00Z" }), false); +test("AckV1: schema and runtime require the complete signed binding", () => { + assert.equal(ackOk(GOOD_ACK), true); + assert.equal(isAckV1(GOOD_ACK), true); + for (const [label, ack] of [ + ["unsigned legacy ACK", { msgId: "m", consumerId: "c", status: "ack", at: GOOD_ACK.at }], + ["wrong version", { ...GOOD_ACK, ackVersion: "2.0" }], + ["missing peer", { ...GOOD_ACK, senderAgentId: "" }], + ["bad digest", { ...GOOD_ACK, messageDigest: "not-a-digest" }], + ["bad status", { ...GOOD_ACK, status: "maybe" }], + ["missing signature", { ...GOOD_ACK, signature: "" }], + ]) { + assert.equal(ackOk(ack), false, `schema must reject: ${label}`); + assert.equal(isAckV1(ack), false, `runtime must reject: ${label}`); + } }); // Note: `createdAt` carries JSON-Schema `format: date-time` (advisory) and is enforced at diff --git a/scripts/demo-consumer.mjs b/scripts/demo-consumer.mjs index 10eac14..d9a1d3e 100644 --- a/scripts/demo-consumer.mjs +++ b/scripts/demo-consumer.mjs @@ -1,13 +1,24 @@ import { setTimeout as sleep } from "node:timers/promises"; import { NatsBroker } from "@murmurv2/broker-nats"; import { SQLiteDedupeOutboxStore, validateEnvelopePolicy } from "@murmurv2/core"; -import { decryptPayload, getCryptoProvider, verifyEnvelopeSignature } from "@murmurv2/security"; +import { decryptPayload, getCryptoProvider, signEnvelope, verifyEnvelopeSignature } from "@murmurv2/security"; import { ensureDemoKeys, loadDemoConfig, policyFromConfig, stableEnvelopePayload } from "./demo-secure-common.mjs"; 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, + ackSecurity: { + localAgentId: cfg.recipientAgentId, + sign: (payload) => signEnvelope(payload, keys.recipient.signing.privateKey), + verify: (senderAgentId, payload, signature) => + senderAgentId === cfg.senderAgentId + ? verifyEnvelopeSignature(payload, signature, keys.sender.signing.publicKey) + : Promise.resolve(false), + }, +}); const dedupe = new SQLiteDedupeOutboxStore(cfg.dedupeDbPath); const policy = policyFromConfig(cfg); diff --git a/scripts/demo-producer.mjs b/scripts/demo-producer.mjs index 28d9147..30cb3c5 100644 --- a/scripts/demo-producer.mjs +++ b/scripts/demo-producer.mjs @@ -3,13 +3,24 @@ import { setTimeout as sleep } from "node:timers/promises"; import { DatabaseSync } from "node:sqlite"; import { NatsBroker } from "@murmurv2/broker-nats"; import { SQLiteDedupeOutboxStore } from "@murmurv2/core"; -import { encryptPayload, getCryptoProvider, signEnvelope } from "@murmurv2/security"; +import { encryptPayload, getCryptoProvider, signEnvelope, verifyEnvelopeSignature } from "@murmurv2/security"; import { ensureDemoKeys, loadDemoConfig, policyFromConfig, stableEnvelopePayload } from "./demo-secure-common.mjs"; 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, + ackSecurity: { + localAgentId: cfg.senderAgentId, + sign: (payload) => signEnvelope(payload, keys.sender.signing.privateKey), + verify: (senderAgentId, payload, signature) => + senderAgentId === cfg.recipientAgentId + ? verifyEnvelopeSignature(payload, signature, keys.recipient.signing.publicKey) + : Promise.resolve(false), + }, +}); const outbox = new SQLiteDedupeOutboxStore(cfg.outboxDbPath); const waitForAck = async (dbPath, msgId, timeoutMs) => { @@ -55,7 +66,7 @@ const run = async () => { await broker.startAckCorrelation({ outbox, - ackSubject: `ack.${cfg.consumerId}`, + ackSubject: `ack.${cfg.senderAgentId}`, }); await outbox.enqueue(cfg.subject, envelope); diff --git a/scripts/demo-secure-common.mjs b/scripts/demo-secure-common.mjs index 0348bbb..8d18583 100644 --- a/scripts/demo-secure-common.mjs +++ b/scripts/demo-secure-common.mjs @@ -48,11 +48,20 @@ export const policyFromConfig = (cfg) => ({ export const ensureDemoKeys = async (keysPath = DEFAULT_KEYS_PATH) => { try { const raw = await readFile(keysPath, "utf8"); - return JSON.parse(raw); + const existing = JSON.parse(raw); + if (!existing.recipient?.signing?.privateKey || !existing.recipient?.signing?.publicKey) { + existing.recipient = { + ...existing.recipient, + signing: await createSigningKeyPair(), + }; + await writeFile(keysPath, `${JSON.stringify(existing, null, 2)}\n`, "utf8"); + } + return existing; } catch { const senderEncryption = await createKeyPair(); const recipientEncryption = await createKeyPair(); const senderSigning = await createSigningKeyPair(); + const recipientSigning = await createSigningKeyPair(); const keys = { cryptoProvider: getCryptoProvider().name, @@ -63,6 +72,7 @@ export const ensureDemoKeys = async (keysPath = DEFAULT_KEYS_PATH) => { }, recipient: { encryption: recipientEncryption, + signing: recipientSigning, }, }; diff --git a/scripts/murmur-daemon.mjs b/scripts/murmur-daemon.mjs index 6b2c455..43fd477 100644 --- a/scripts/murmur-daemon.mjs +++ b/scripts/murmur-daemon.mjs @@ -8,7 +8,7 @@ import path from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; import { NatsBroker } from "@murmurv2/broker-nats"; import { ChannelRosterStore, SQLiteDedupeOutboxStore, SQLiteMessageStore, stableEnvelopePayload } from "@murmurv2/core"; -import { decryptPayload, verifyEnvelopeSignature } from "@murmurv2/security"; +import { decryptPayload, signEnvelope, verifyEnvelopeSignature } from "@murmurv2/security"; import { NotifyQueue, flushNotifyQueue, normalizeNotifyTargets } from "./notify-router.mjs"; import { createChannelThreadStartBindingResolver, createCodexAppServerInjector } from "./codex-app-server-wake.mjs"; import { startJetStreamAdvisoryDlqIfEnabled } from "./murmur-jetstream-advisory.mjs"; @@ -134,6 +134,15 @@ const broker = new NatsBroker({ streamSubjects: jetstreamSubjects, jetstreamMaxDeliver, jetstreamAckWaitMs, + ackSecurity: { + localAgentId: agentId, + sign: (payload) => signEnvelope(payload, keys.signing.privateKey), + verify: async (senderAgentId, payload, signature) => { + const peer = peers[senderAgentId]; + return !!peer?.signing?.publicKey && verifyEnvelopeSignature(payload, signature, peer.signing.publicKey); + }, + onRejected: (event) => log("warn", "ACK rejected", event), + }, }); const durableSafe = (value) => value.replace(/[^A-Za-z0-9_-]/g, "-"); diff --git a/scripts/murmur-mcp-channel-server.mjs b/scripts/murmur-mcp-channel-server.mjs index 1d04cda..cb478dc 100755 --- a/scripts/murmur-mcp-channel-server.mjs +++ b/scripts/murmur-mcp-channel-server.mjs @@ -8,6 +8,7 @@ import { NatsBroker } from "../packages/broker-nats/dist/src/index.js"; import { SQLiteDedupeOutboxStore } from "../packages/core/dist/src/index.js"; import { decryptPayload, + signEnvelope, verifyEnvelopeSignature, } from "../packages/security/dist/src/index.js"; @@ -83,7 +84,19 @@ 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, + ackSecurity: { + localAgentId: config.agentId, + sign: (payload) => signEnvelope(payload, config.keys.signing.privateKey), + verify: async (senderAgentId, payload, signature) => { + const peer = config.peers?.[senderAgentId]; + return !!peer?.signing?.publicKey && verifyEnvelopeSignature(payload, signature, peer.signing.publicKey); + }, + onRejected: (event) => log("warn", "ACK rejected", event), + }, +}); const dedupe = new SQLiteDedupeOutboxStore(dbPath); const lease = new SessionLeaseStore(leaseDbPath); diff --git a/tests/broker-ack-correlation.integration.mjs b/tests/broker-ack-correlation.integration.mjs index 5079ee7..73e70d6 100644 --- a/tests/broker-ack-correlation.integration.mjs +++ b/tests/broker-ack-correlation.integration.mjs @@ -4,7 +4,16 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { NatsBroker } from "../packages/broker-nats/dist/src/index.js"; -import { SQLiteDedupeOutboxStore, createAck } from "../packages/core/dist/src/index.js"; +import { + SQLiteDedupeOutboxStore, + createAck, + stableAckPayload, +} from "../packages/core/dist/src/index.js"; +import { + createSigningKeyPair, + signEnvelope, + verifyEnvelopeSignature, +} from "../packages/security/dist/src/index.js"; const envelope = { schemaVersion: "1.0", @@ -22,17 +31,39 @@ test("ack correlation handles nack as immediate failed requeue", async () => { const dir = mkdtempSync(join(tmpdir(), "murmur-int-")); const dbPath = join(dir, "murmur.db"); const store = new SQLiteDedupeOutboxStore(dbPath); - const broker = new NatsBroker({ url: process.env.NATS_URL ?? "nats://127.0.0.1:4222" }); + const senderKeys = await createSigningKeyPair(); + const receiverKeys = await createSigningKeyPair(); + const broker = new NatsBroker({ + url: process.env.NATS_URL ?? "nats://127.0.0.1:4222", + ackSecurity: { + localAgentId: "agent.a", + sign: (payload) => signEnvelope(payload, senderKeys.privateKey), + verify: (senderAgentId, payload, signature) => + senderAgentId === "agent.b" + ? verifyEnvelopeSignature(payload, signature, receiverKeys.publicKey) + : Promise.resolve(false), + }, + }); await store.enqueue("msg.demo.secure", envelope); await store.markSent(envelope.msgId); const sub = await broker.startAckCorrelation({ outbox: store, - ackSubject: "ack.integration-test", + ackSubject: "ack.agent.a", }); - await broker.publishAck("ack.integration-test", createAck(envelope.msgId, "integration", "nack", "forced-nack")); + const ack = createAck(envelope, "integration", "agent.b", "nack", "forced-nack"); + ack.signature = await signEnvelope(stableAckPayload(ack), receiverKeys.privateKey); + const publisher = new NatsBroker({ + url: process.env.NATS_URL ?? "nats://127.0.0.1:4222", + ackSecurity: { + localAgentId: "agent.b", + sign: (payload) => signEnvelope(payload, receiverKeys.privateKey), + verify: async () => false, + }, + }); + await publisher.publishAck("ack.agent.a", ack); await new Promise((r) => setTimeout(r, 150)); const due = await store.claimDue(10); @@ -41,5 +72,6 @@ test("ack correlation handles nack as immediate failed requeue", async () => { assert.equal(due[0].lastError, "forced-nack"); sub.unsubscribe(); + await publisher.close(); await broker.close(); }); diff --git a/tests/broker-ack-security.test.mjs b/tests/broker-ack-security.test.mjs new file mode 100644 index 0000000..140d1bd --- /dev/null +++ b/tests/broker-ack-security.test.mjs @@ -0,0 +1,198 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { StringCodec } from "nats"; +import { NatsBroker } from "../packages/broker-nats/dist/src/index.js"; +import { createAck, stableAckPayload } from "../packages/core/dist/src/index.js"; +import { + createSigningKeyPair, + signEnvelope, + verifyEnvelopeSignature, +} from "../packages/security/dist/src/index.js"; + +const sc = StringCodec(); +const senderKeys = await createSigningKeyPair(); +const receiverKeys = await createSigningKeyPair(); +const malloryKeys = await createSigningKeyPair(); + +const envelope = Object.freeze({ + schemaVersion: "1.0", + msgId: "known-message-id", + conversationId: "known-conversation", + senderAgentId: "sender", + recipients: ["receiver"], + createdAt: new Date().toISOString(), + payloadCiphertext: "ciphertext", + payloadNonce: "nonce", + signature: "envelope-signature", +}); + +class MemoryOutbox { + constructor() { + this.record = { + msgId: envelope.msgId, + subject: "msg.receiver", + envelope, + status: "sent", + attempts: 1, + nextAttemptAt: new Date().toISOString(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + this.receipts = new Set(); + this.transitions = []; + } + + async get(msgId) { + return msgId === this.record.msgId ? this.record : undefined; + } + + async applyVerifiedAck(ack) { + if (this.receipts.has(ack.ackId)) return "replay"; + if (this.record.status === "acked" || this.record.status === "dlq") return "terminal"; + this.receipts.add(ack.ackId); + this.record.status = ack.status === "ack" ? "acked" : "failed"; + this.transitions.push([ack.status, ack.msgId]); + return "applied"; + } +} + +function brokerHarness() { + const rejected = []; + const publicKeys = { + receiver: receiverKeys.publicKey, + mallory: malloryKeys.publicKey, + }; + const broker = new NatsBroker({ + url: "nats://example.invalid", + ackSecurity: { + localAgentId: "sender", + sign: (payload) => signEnvelope(payload, senderKeys.privateKey), + verify: (senderAgentId, payload, signature) => { + const key = publicKeys[senderAgentId]; + return key ? verifyEnvelopeSignature(payload, signature, key) : Promise.resolve(false); + }, + onRejected: (event) => rejected.push(event), + }, + }); + return { broker, rejected }; +} + +async function signedAck(overrides = {}, privateKey = receiverKeys.privateKey) { + const ack = Object.assign(createAck(envelope, "receiver-consumer", "receiver", "ack"), overrides); + ack.signature = await signEnvelope(stableAckPayload(ack), privateKey); + return ack; +} + +async function process(broker, outbox, ack) { + await broker.processAckFrame(sc.encode(JSON.stringify(ack)), outbox); +} + +test("unsigned ACKs cannot transition the outbox", async () => { + const { broker, rejected } = brokerHarness(); + const outbox = new MemoryOutbox(); + await process(broker, outbox, { + msgId: envelope.msgId, + consumerId: "claimed-peer", + status: "ack", + at: new Date().toISOString(), + }); + assert.deepEqual(outbox.transitions, []); + assert.equal(rejected[0].reason, "unsigned-or-invalid"); +}); + +test("a valid signed and bound ACK transitions exactly once", async () => { + const { broker } = brokerHarness(); + const outbox = new MemoryOutbox(); + await process(broker, outbox, await signedAck()); + assert.deepEqual(outbox.transitions, [["ack", envelope.msgId]]); + assert.equal(outbox.record.status, "acked"); +}); + +test("wrong peer, conversation, digest, and recipient bindings are rejected", async () => { + for (const ackFactory of [ + () => signedAck({ senderAgentId: "mallory" }, malloryKeys.privateKey), + () => signedAck({ conversationId: "other-conversation" }), + () => signedAck({ messageDigest: "b".repeat(64) }), + () => signedAck({ recipientAgentId: "other-sender" }), + ]) { + const { broker, rejected } = brokerHarness(); + const outbox = new MemoryOutbox(); + await process(broker, outbox, await ackFactory()); + assert.deepEqual(outbox.transitions, []); + assert.equal(rejected.at(-1).reason, "binding-mismatch"); + } +}); + +test("an ACK signed by an untrusted key is rejected", async () => { + const { broker, rejected } = brokerHarness(); + const outbox = new MemoryOutbox(); + await process(broker, outbox, await signedAck({}, malloryKeys.privateKey)); + assert.deepEqual(outbox.transitions, []); + assert.equal(rejected.at(-1).reason, "signature-invalid"); +}); + +test("replaying one valid signed ACK creates no second transition", async () => { + const { broker, rejected } = brokerHarness(); + const outbox = new MemoryOutbox(); + const ack = await signedAck(); + await process(broker, outbox, ack); + // Keep the row non-terminal to prove ackId persistence, not terminal status, blocks it. + outbox.record.status = "sent"; + await process(broker, outbox, ack); + assert.deepEqual(outbox.transitions, [["ack", envelope.msgId]]); + assert.equal(rejected.at(-1).reason, "replay"); +}); + +test("stale signed ACKs are rejected before correlation", async () => { + const { broker, rejected } = brokerHarness(); + const outbox = new MemoryOutbox(); + const old = new Date(Date.now() - 11 * 60_000).toISOString(); + await process(broker, outbox, await signedAck({ at: old })); + assert.deepEqual(outbox.transitions, []); + assert.equal(rejected.at(-1).reason, "stale-or-future"); +}); + +test("a valid signed NACK preserves legitimate retry behavior", async () => { + const { broker } = brokerHarness(); + const outbox = new MemoryOutbox(); + await process(broker, outbox, await signedAck({ status: "nack", reason: "handler-down" })); + assert.deepEqual(outbox.transitions, [["nack", envelope.msgId]]); + assert.equal(outbox.record.status, "failed"); +}); + +test("invalid ACK alerts contain metadata but never raw frame contents", async () => { + const { broker } = brokerHarness(); + const outbox = new MemoryOutbox(); + const warnings = []; + const original = console.warn; + console.warn = (...args) => warnings.push(args); + try { + await process(broker, outbox, { msgId: envelope.msgId, rawSecret: "DO-NOT-LOG-ME" }); + } finally { + console.warn = original; + } + assert.equal(JSON.stringify(warnings).includes("DO-NOT-LOG-ME"), false); + assert.equal(broker.getAckRejectionCounts()["unsigned-or-invalid"], 1); +}); + +test("ACK correlation and publishing enforce the local per-agent subject", async () => { + const { broker } = brokerHarness(); + const outbox = new MemoryOutbox(); + await assert.rejects( + () => broker.startAckCorrelation({ outbox, ackSubject: "ack.someone-else" }), + /ack-subject-mismatch/, + ); + + const claimedRemoteAck = await signedAck(); + await assert.rejects( + () => broker.publishAck("ack.sender", claimedRemoteAck), + /ack-sender-mismatch/, + ); + + const localAck = createAck(envelope, "sender-consumer", "sender", "ack"); + localAck.signature = await signEnvelope(stableAckPayload(localAck), senderKeys.privateKey); + await assert.rejects( + () => broker.publishAck("ack.someone-else", localAck), + /ack-subject-mismatch/, + ); +}); diff --git a/tests/broker-ack-subject.test.mjs b/tests/broker-ack-subject.test.mjs index 12ac826..815fa4f 100644 --- a/tests/broker-ack-subject.test.mjs +++ b/tests/broker-ack-subject.test.mjs @@ -4,6 +4,11 @@ import { StringCodec } from "nats"; import { NatsBroker } from "../packages/broker-nats/dist/src/index.js"; const sc = StringCodec(); +const ackSecurity = { + localAgentId: "agent-receiver", + sign: async () => "test-signature", + verify: async () => true, +}; const envelope = { schemaVersion: "1.0", @@ -38,7 +43,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: "nats://example.invalid", ackSecurity }); broker.nc = fakeNc; await broker.subscribeWithAck({ @@ -54,5 +59,8 @@ test("subscribeWithAck publishes ack to original sender ack subject", async () = assert.equal(published[0].subject, "ack.agent-sender"); assert.equal(published[0].body.msgId, envelope.msgId); assert.equal(published[0].body.consumerId, "agent-receiver"); + assert.equal(published[0].body.senderAgentId, "agent-receiver"); + assert.equal(published[0].body.recipientAgentId, "agent-sender"); + assert.equal(published[0].body.signature, "test-signature"); assert.equal(published[0].body.status, "ack"); }); diff --git a/tests/broker-auth-enforce.test.mjs b/tests/broker-auth-enforce.test.mjs index d803291..98f2926 100644 --- a/tests/broker-auth-enforce.test.mjs +++ b/tests/broker-auth-enforce.test.mjs @@ -9,6 +9,11 @@ import { StringCodec } from "nats"; import { NatsBroker } from "../packages/broker-nats/dist/src/index.js"; const sc = StringCodec(); +const ackSecurity = { + localAgentId: "agent-receiver", + sign: async () => "test-signature", + verify: async () => true, +}; const envelope = { schemaVersion: "1.0", @@ -40,7 +45,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: "nats://example.invalid", ackSecurity }); broker.nc = fakeNc; return { broker, dedupe, published }; } diff --git a/tests/broker-jetstream.test.mjs b/tests/broker-jetstream.test.mjs index c2ba6ff..6c5a796 100644 --- a/tests/broker-jetstream.test.mjs +++ b/tests/broker-jetstream.test.mjs @@ -5,6 +5,11 @@ import { NatsBroker } from "../packages/broker-nats/dist/src/index.js"; import { createAck } from "../packages/core/dist/src/index.js"; const sc = StringCodec(); +const receiverAckSecurity = { + localAgentId: "agent-receiver", + sign: async () => "test-signature", + verify: async () => true, +}; const envelope = { schemaVersion: "1.0", @@ -129,6 +134,7 @@ const makeJetStreamBroker = ({ jetstream: true, stream: "MURMUR", streamSubjects: ["msg.>", "ack.>"], + ackSecurity: receiverAckSecurity, ...brokerConfig, }); broker.nc = fakeNc; @@ -193,7 +199,7 @@ test("JetStream subscribeWithAck creates durable explicit-ack consumer", async ( assert.equal(consumersAdded[0].config.ack_wait, 30_000_000_000); assert.equal(published[0].subject, "ack.agent-sender"); assert.equal(published[0].body.status, "ack"); - assert.equal(published[0].opts.msgID, `ack:${envelope.msgId}:agent-receiver:ack`); + assert.equal(published[0].opts.msgID, `ack:${published[0].body.ackId}`); assert.equal(acked.length, 1); }); @@ -311,17 +317,28 @@ test("JetStream poison-message terminal failure is acked", async () => { }); test("JetStream ACK correlation consumes durable ack subject and updates outbox", async () => { - const ack = createAck(envelope.msgId, "agent-receiver", "ack"); + const ack = createAck(envelope, "agent-receiver", "agent-receiver", "ack"); + ack.signature = "test-signature"; const { broker, consumersAdded, acked } = makeJetStreamBroker({ messages: [sc.encode(JSON.stringify(ack))], + brokerConfig: { + ackSecurity: { + localAgentId: "agent-sender", + sign: async () => "sender-signature", + verify: async () => true, + }, + }, }); const marked = []; const outbox = { - async markAcked(msgId) { - marked.push(["acked", msgId]); + async get(msgId) { + return msgId === envelope.msgId + ? { msgId, envelope, status: "sent", subject: "msg.agent-receiver", attempts: 1, nextAttemptAt: envelope.createdAt, createdAt: envelope.createdAt, updatedAt: envelope.createdAt } + : undefined; }, - async markFailed(msgId, reason) { - marked.push(["failed", msgId, reason]); + async applyVerifiedAck(frame) { + marked.push([frame.status === "ack" ? "acked" : "failed", frame.msgId]); + return "applied"; }, };