diff --git a/.npmrc b/.npmrc index 04b657d..773d1e0 100644 --- a/.npmrc +++ b/.npmrc @@ -1,2 +1,3 @@ minimum-release-age=10080 minimum-release-age-exclude[]=@mariozechner/* +minimum-release-age-exclude[]=wire-mesh-core diff --git a/package.json b/package.json index 1a85a59..3e9691b 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,7 @@ "cbor2": "2.3.0", "preact": "10.29.7", "typebox": "1.3.6", - "wire-mesh-core": "1.0.2", + "wire-mesh-core": "1.0.3", "ws": "8.21.1", "zod": "4.4.3" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9c48da2..7190d36 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,8 +36,8 @@ importers: specifier: 1.3.6 version: 1.3.6 wire-mesh-core: - specifier: 1.0.2 - version: 1.0.2 + specifier: 1.0.3 + version: 1.0.3 ws: specifier: 8.21.1 version: 8.21.1 @@ -3235,8 +3235,8 @@ packages: engines: {node: '>= 8'} hasBin: true - wire-mesh-core@1.0.2: - resolution: {integrity: sha512-MV+odiI9DQXsGWJ9FiOs8hbZxseLEua2JfosRlPdPIByOMcM8/PYkgtbPt8hyPuYyYl21QHTQVputzGkyL69Zw==} + wire-mesh-core@1.0.3: + resolution: {integrity: sha512-jauVnnL5c9EJynIvvztXa0V4eRXZbMz2a6zGKCTh1ZHB9srwTRg03rE49FltC+2MXtOT//dpOIZFTshEAHwKhg==} word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} @@ -6780,7 +6780,7 @@ snapshots: dependencies: isexe: 2.0.0 - wire-mesh-core@1.0.2: + wire-mesh-core@1.0.3: dependencies: cbor2: 2.3.0 cddl.js: 1.0.1 diff --git a/src/core/bridge-mesh.ts b/src/core/bridge-mesh.ts index f7ce547..0417b2c 100644 --- a/src/core/bridge-mesh.ts +++ b/src/core/bridge-mesh.ts @@ -8,6 +8,7 @@ import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; import { MeshStore } from "./mesh-store.js"; import { CommsTool } from "./tool.js"; import { WireMeshTransport } from "./wire-mesh-transport.js"; @@ -37,6 +38,7 @@ export function createBridgeMeshSync( new WireMeshTransport(store.events, identity, store.roomVerbHandlers), ); const tool = new CommsTool(store, store.discovery); + const revocation = createRevocationView(); return { store, tool, @@ -45,6 +47,7 @@ export function createBridgeMeshSync( identity: await toIdentityPort(identity), clock: createSystemClock(), slot, + revocation, }); }, }; diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index ba60296..878b3d7 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -32,22 +32,30 @@ import { FederationManager } from "./federation.js"; import type { FedLink } from "./federation.js"; import { getCertificateFingerprint } from "./identity.js"; import { + bytesToHex, deviceIdFromHex, deviceIdToHex, } from "wire-mesh-core/domain/device-id"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; +import type { RevocationCheck } from "wire-mesh-core/domain/tokens"; import type { IdentityPort } from "wire-mesh-core/ports/identity"; import type { Clock } from "wire-mesh-core/ports/clock"; import type { IncomingManageRequest, ManageOutcome, } from "wire-mesh-core/domain/mesh-session"; -import { roomJoinOkSchema } from "wire-mesh-core/generated/protocol"; +import { + roomJoinOkSchema, + roomSendSchema, +} from "wire-mesh-core/generated/protocol"; import type { RoomVerbHandler } from "./room-router.js"; -import { ROOM_MEMBER_CAPABILITY } from "./room-token-verification.js"; -import { saveRoomToken } from "./identity-store.js"; +import { + ROOM_MEMBER_CAPABILITY, + verifyRoomToken, +} from "./room-token-verification.js"; +import { loadRoomTokens, saveRoomToken } from "./identity-store.js"; import type { IdentitySlot } from "./identity-store.js"; -import { randomTokenId } from "./token-id.js"; +import { randomId } from "./random-id.js"; import type { MeshMessage, MeshStatePatch, PeerInfo } from "./wire-protocol.js"; import type { ConnectionHandle, @@ -83,6 +91,7 @@ export interface MeshStoreIdentity { identity: IdentityPort; clock: Clock; slot: IdentitySlot; + revocation: RevocationCheck; } /** @@ -1221,7 +1230,7 @@ export class MeshStore implements CommsStore { const verdict = await mintCapabilityToken({ identity, clock, - tokenId: randomTokenId(), + tokenId: randomId(), bearer: deviceIdFromHex(owner), capability: "room:member", scope: { kind: "room", path: roomPath }, @@ -1242,9 +1251,99 @@ export class MeshStore implements CommsStore { get roomVerbHandlers(): Partial> { return { "room.join": (request, handle) => this.handleRoomJoin(request, handle), + "room.send": (request, handle) => this.handleRoomSend(request, handle), }; } + /** + * Receiving side of a directed room.send (P3.5): verifies the presented token against all six of core/room's own obligations, then delivers the message locally exactly once -- the manage-response this returns IS the delivery receipt, so there is no separate "delivered" event to emit the way the legacy broadcastPatch path needed one. + */ + private async handleRoomSend( + request: IncomingManageRequest, + handle: ConnectionHandle, + ): Promise { + const roomPath = request.scope.path; + if (roomPath === undefined) { + return { result: "error", code: "missing_scope_path" }; + } + if (request.token === undefined) { + return { result: "error", code: "unauthorized" }; + } + const { identity, clock, revocation } = this.requireIdentity(); + const verdict = await verifyRoomToken(request.token, { + identity, + clock, + revocation, + expectedBearer: deviceIdFromHex(handle.id), + roomPath, + }); + if (!verdict.ok) { + return { result: "error", code: "unauthorized" }; + } + + const parsedParams = roomSendSchema.safeParse(request.command.params); + if (!parsedParams.success) { + return { result: "error", code: "malformed_params" }; + } + const params = parsedParams.data; + + const message: RoomMessage = { + id: bytesToHex(params["message-id"]), + from: handle.id, + room: roomPath, + content: params.text, + timestamp: new Date(params["sent-at"]).toISOString(), + readBy: [handle.id], + }; + const history = this.messages.get(roomPath) ?? []; + history.push(message); + this.messages.set(roomPath, history); + + const event: DeliveryEvent = { type: "room_message", message }; + this.queueDelivery(this.peerId, event); + this.fireLocalDelivery(this.peerId, event); + + return { result: "ok" }; + } + + /** + * Sends one directed room.send to a single member's own session, attaching this store's own persisted room:member token for the given room path -- the primitive P3.5's own directed fan-out (deliverToRoom) will loop over per member once it replaces the legacy broadcastPatch path this store still uses for message delivery today. Throws if this store holds no token for the room: never a member, or a token that expired or was revoked with nothing fresh persisted in its place. + */ + async sendRoomMessageDirected( + roomPath: string, + memberId: string, + text: string, + ): Promise { + const { slot, clock } = this.requireIdentity(); + const token = loadRoomTokens(slot)[roomPath]; + if (token === undefined) { + throw new CommsError( + `No room:member token for ${roomPath}`, + "NOT_A_MEMBER", + ); + } + const outcome = await this.requireTransport().sendRoomRequest( + memberId, + { + verb: ROOM_MEMBER_CAPABILITY, + params: { + verb: "room.send", + "message-id": randomId(), + "sent-at": clock.now(), + text, + }, + }, + { kind: "room", path: roomPath }, + token, + ); + if (outcome.result !== "ok") { + throw new CommsError( + `room.send to ${memberId} for ${roomPath} failed (${outcome.code})`, + "SEND_FAILED", + ); + } + } + /** * Owner-side admission for an incoming room.join request, against either a named room this store owns or a DM path this store is a participant of. Named-room admission always needs a human decision; DM admission needs one only for the party being contacted first -- the reply half of the two-round consent flow (section 6) auto-approves, since a reply on a path this node itself opened is not unsolicited contact. */ @@ -1302,7 +1401,7 @@ export class MeshStore implements CommsStore { const verdict = await mintCapabilityToken({ identity, clock, - tokenId: randomTokenId(), + tokenId: randomId(), bearer: deviceIdFromHex(handle.id), capability: "room:member", scope: { kind: "room", path: roomPath }, diff --git a/src/core/random-id.ts b/src/core/random-id.ts new file mode 100644 index 0000000..4824052 --- /dev/null +++ b/src/core/random-id.ts @@ -0,0 +1,6 @@ +/** Generates a random identifier: several of core/room's own fields (token-id, message-id) are defined as an arbitrary-length bstr, and 16 random bytes (128 bits) is the conventional size for an unguessable identifier, matching a UUIDv4's own random payload. */ +export function randomId(): Uint8Array { + const bytes = new Uint8Array(16); + crypto.getRandomValues(bytes); + return bytes; +} diff --git a/src/core/token-id.ts b/src/core/token-id.ts deleted file mode 100644 index c5d0227..0000000 --- a/src/core/token-id.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Generates a random token-id: tokens.cddl defines token-id as an arbitrary-length bstr, and 16 random bytes (128 bits) is the conventional size for an unguessable identifier, matching a UUIDv4's own random payload. */ -export function randomTokenId(): Uint8Array { - const bytes = new Uint8Array(16); - crypto.getRandomValues(bytes); - return bytes; -} diff --git a/src/test/downtime-replay.integration.test.ts b/src/test/downtime-replay.integration.test.ts index b77cf00..08841c6 100644 --- a/src/test/downtime-replay.integration.test.ts +++ b/src/test/downtime-replay.integration.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; import { MeshStore } from "../core/mesh-store.js"; import { WireMeshTransport } from "../core/wire-mesh-transport.js"; import { @@ -44,6 +45,7 @@ async function makePeer( identity: await toIdentityPort(identity), clock: createSystemClock(), slot, + revocation: createRevocationView(), }); const deliveries: DeliveryEvent[] = []; return { store, deliveries }; diff --git a/src/test/identity-restart.integration.test.ts b/src/test/identity-restart.integration.test.ts index b7282e1..0101326 100644 --- a/src/test/identity-restart.integration.test.ts +++ b/src/test/identity-restart.integration.test.ts @@ -10,6 +10,7 @@ import { tmpdir } from "node:os"; import path from "node:path"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; import { MeshStore } from "../core/mesh-store.js"; import { WireMeshTransport } from "../core/wire-mesh-transport.js"; import type { PeerIdentity } from "../core/identity.js"; @@ -44,6 +45,7 @@ async function makePeer( identity: await toIdentityPort(identity), clock: createSystemClock(), slot, + revocation: createRevocationView(), }); const deliveries: DeliveryEvent[] = []; return { store, deliveries }; diff --git a/src/test/mesh-smoke.runner.ts b/src/test/mesh-smoke.runner.ts index 973d185..0f3c3a8 100644 --- a/src/test/mesh-smoke.runner.ts +++ b/src/test/mesh-smoke.runner.ts @@ -114,6 +114,7 @@ function buildScript(name: string, actions: string): string { `const { toIdentityPort } = require("./dist/core/wire-mesh-identity.js");`, `const { deviceIdToHex } = require("wire-mesh-core/domain/device-id");`, `const { createSystemClock } = require("wire-mesh-core/adapters/system-clock");`, + `const { createRevocationView } = require("wire-mesh-core/domain/revocation-view");`, `const os = require("node:os");`, `const path = require("node:path");`, `const fs = require("node:fs");`, @@ -123,8 +124,8 @@ function buildScript(name: string, actions: string): string { ` const slot = { harness: "smoke-${name}", cwd: "/test/${name}", dir: fs.mkdtempSync(path.join(os.tmpdir(), "agent-comms-smoke-${name}-")) };`, ` const identity = loadOrCreateIdentity(slot);`, ` store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId));`, - ` store.setTransport(new WireMeshTransport(store.events, identity));`, - ` store.setIdentity({ identity: await toIdentityPort(identity), clock: createSystemClock(), slot });`, + ` store.setTransport(new WireMeshTransport(store.events, identity, store.roomVerbHandlers));`, + ` store.setIdentity({ identity: await toIdentityPort(identity), clock: createSystemClock(), slot, revocation: createRevocationView() });`, ` const tool = new CommsTool(store);`, ` const deliveries = [];`, ` store.onDelivery = (_id, event) => {`, diff --git a/src/test/token-id.test.ts b/src/test/random-id.test.ts similarity index 58% rename from src/test/token-id.test.ts rename to src/test/random-id.test.ts index a943ccc..5ff1dfd 100644 --- a/src/test/token-id.test.ts +++ b/src/test/random-id.test.ts @@ -1,16 +1,16 @@ import { describe, it } from "node:test"; import assert from "node:assert/strict"; -import { randomTokenId } from "../core/token-id.js"; +import { randomId } from "../core/random-id.js"; -describe("randomTokenId", () => { +describe("randomId", () => { it("returns 16 bytes", () => { - const id = randomTokenId(); + const id = randomId(); assert.equal(id.length, 16); }); it("returns a different value on each call", () => { - const a = randomTokenId(); - const b = randomTokenId(); + const a = randomId(); + const b = randomId(); assert.notDeepEqual(a, b); }); }); diff --git a/src/test/room-join-admission.test.ts b/src/test/room-join-admission.test.ts index d5524d0..825febe 100644 --- a/src/test/room-join-admission.test.ts +++ b/src/test/room-join-admission.test.ts @@ -18,6 +18,7 @@ import { deviceIdToHex, } from "wire-mesh-core/domain/device-id"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; import { MeshStore } from "../core/mesh-store.js"; import { ownerNamedRoomPath } from "../core/room-path.js"; @@ -174,6 +175,7 @@ describe("joinRoom (requester side, remote path)", () => { identity: identityPort, clock: createSystemClock(), slot, + revocation: createRevocationView(), }); const ownerId = "f".repeat(64); diff --git a/src/test/room-send-directed.integration.test.ts b/src/test/room-send-directed.integration.test.ts new file mode 100644 index 0000000..374958f --- /dev/null +++ b/src/test/room-send-directed.integration.test.ts @@ -0,0 +1,171 @@ +/** + * Integration test for room.send's directed delivery (P3.5's own foundational primitive): a real, wire-authenticated room:member token authorises exactly one message to exactly one member's own session, verified against all six of core/room's obligations on the receiving end, delivered locally with no separate "delivered" event since the manage-response itself is the receipt. + * + * Membership is set up by minting and persisting the member's own token directly, not by driving a real room.join round trip: MeshStore's legacy full-state-sync (still active per the "both paths coexist" transition) makes two mesh-connected peers instantly aware of any room the moment it's created, so member.joinRoom(room.id, ...) always takes the already-known-locally branch rather than the wire-level remote-join path -- the same race #73's own tests had to route around, and irrelevant to what this file actually tests (room.send, not room.join). + */ + +import * as assert from "node:assert/strict"; +import { test } from "node:test"; +import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { deviceIdFromHex } from "wire-mesh-core/domain/device-id"; +import { MeshStore } from "../core/mesh-store.js"; +import type { DeliveryEvent } from "../core/types.js"; +import { loadOrCreateIdentity, saveRoomToken } from "../core/identity-store.js"; +import type { IdentitySlot } from "../core/identity-store.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { waitFor, wireTestTransport } from "./test-transport.js"; + +let nextPort = 20_970; +function freshPort(): number { + nextPort += 1; + return nextPort; +} + +async function makeConnectedPair(port: number): Promise<{ + owner: MeshStore; + ownerSlot: IdentitySlot; + member: MeshStore; + memberSlot: IdentitySlot; +}> { + const owner = new MeshStore(port); + const ownerSlot = await wireTestTransport(owner); + await owner.init(); + await owner.registerAgent({ + name: "owner", + harness: "test", + cwd: "/test/owner", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + const member = new MeshStore(port); + const memberSlot = await wireTestTransport(member); + await member.init(); + await member.registerAgent({ + name: "member", + harness: "test", + cwd: "/test/member", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + await waitFor( + () => owner.serialise().agents[member.peerId] !== undefined, + "owner sees the member agent", + ); + return { owner, ownerSlot, member, memberSlot }; +} + +/** Mints a member grant directly, under the owner's own persisted identity, and persists it into the member's own slot -- bypassing the wire-level room.join round trip entirely, per this file's own header comment on why that round trip can't be exercised in a live two-peer test here. */ +async function grantMembership( + ownerSlot: IdentitySlot, + memberSlot: IdentitySlot, + roomPath: string, + memberDeviceHex: string, +): Promise { + const ownerIdentity = await toIdentityPort(loadOrCreateIdentity(ownerSlot)); + const clock = createSystemClock(); + const verdict = await mintCapabilityToken({ + identity: ownerIdentity, + clock, + tokenId: new Uint8Array([1]), + bearer: deviceIdFromHex(memberDeviceHex), + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: clock.now() + 60_000, + delegationsRemaining: 0, + }); + assert.ok(verdict.ok, "expected the fixture grant to mint successfully"); + if (!verdict.ok) return; + saveRoomToken(memberSlot, roomPath, verdict.token); +} + +void test("a directed room.send delivers to the recipient's own onDelivery", async () => { + const { owner, ownerSlot, member, memberSlot } = + await makeConnectedPair(freshPort()); + + try { + const room = await owner.createRoom({ + name: "general", + type: "public", + owner: owner.peerId, + description: "", + }); + await grantMembership(ownerSlot, memberSlot, room.id, member.peerId); + + const deliveries: DeliveryEvent[] = []; + member.onDelivery = (_agentId, event) => { + deliveries.push(event); + }; + + await owner.sendRoomMessageDirected(room.id, member.peerId, "hello there"); + + await waitFor( + () => deliveries.some((event) => event.type === "room_message"), + "member receives the directed room.send", + ); + const delivered = deliveries.find((event) => event.type === "room_message"); + assert.ok(delivered); + if (delivered.type !== "room_message") return; + assert.equal(delivered.message.content, "hello there"); + assert.equal(delivered.message.from, owner.peerId); + assert.equal(delivered.message.room, room.id); + } finally { + await member.shutdown(); + await owner.shutdown(); + } +}); + +void test("a directed room.send from a member (not just the owner) also delivers", async () => { + const { owner, ownerSlot, member, memberSlot } = + await makeConnectedPair(freshPort()); + + try { + const room = await owner.createRoom({ + name: "general", + type: "public", + owner: owner.peerId, + description: "", + }); + await grantMembership(ownerSlot, memberSlot, room.id, member.peerId); + + const deliveries: DeliveryEvent[] = []; + owner.onDelivery = (_agentId, event) => { + deliveries.push(event); + }; + + await member.sendRoomMessageDirected(room.id, owner.peerId, "hi back"); + + await waitFor( + () => deliveries.some((event) => event.type === "room_message"), + "owner receives the directed room.send", + ); + } finally { + await member.shutdown(); + await owner.shutdown(); + } +}); + +void test("sendRoomMessageDirected throws when this store holds no token for the room", async () => { + const { owner, member } = await makeConnectedPair(freshPort()); + + try { + const room = await owner.createRoom({ + name: "general", + type: "public", + owner: owner.peerId, + description: "", + }); + // Deliberately not granted -- member has no persisted token for this room. + await assert.rejects( + member.sendRoomMessageDirected(room.id, owner.peerId, "uninvited"), + /No room:member token/, + ); + } finally { + await member.shutdown(); + await owner.shutdown(); + } +}); diff --git a/src/test/test-transport.ts b/src/test/test-transport.ts index 9f47fe9..1d655a0 100644 --- a/src/test/test-transport.ts +++ b/src/test/test-transport.ts @@ -6,6 +6,7 @@ import { tmpdir } from "node:os"; import { WireMeshTransport } from "../core/wire-mesh-transport.js"; import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { createRevocationView } from "wire-mesh-core/domain/revocation-view"; import { loadOrCreateIdentity } from "../core/identity-store.js"; import type { IdentitySlot } from "../core/identity-store.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; @@ -32,6 +33,7 @@ export async function wireTestTransport( identity: await toIdentityPort(identity), clock: createSystemClock(), slot: resolvedSlot, + revocation: createRevocationView(), }); // Surface transport-level errors instead of leaving them silent — a genuine socket failure during a test run is signal worth seeing even when the test's own assertions still pass, since it can point at a real race the assertions don't happen to catch. store.onError = (e) => {