Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 14 additions & 3 deletions docs/protocol-compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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'.
Expand Down
4 changes: 2 additions & 2 deletions docs/protocol-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand All @@ -28,7 +28,7 @@ Envelope message payloads are encrypted on the wire; presence frames are intenti
3. Publish to subject `msg.<conversationId>`
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
Expand Down
50 changes: 50 additions & 0 deletions docs/signed-acks.md
Original file line number Diff line number Diff line change
@@ -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.
39 changes: 14 additions & 25 deletions examples/agent-runner/agent-runner.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
4 changes: 2 additions & 2 deletions examples/agent-runner/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
56 changes: 33 additions & 23 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 5 additions & 11 deletions packages/bridge-a2a/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
const parsed: unknown = JSON.parse(raw);

Expand All @@ -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<void> {
Expand Down
18 changes: 18 additions & 0 deletions packages/bridge-a2a/test/bridge-a2a.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 2 additions & 2 deletions packages/broker-nats/package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand All @@ -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",
Expand Down
Loading