Skip to content
Merged
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
1 change: 1 addition & 0 deletions .npmrc
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
minimum-release-age=10080
minimum-release-age-exclude[]=@mariozechner/*
minimum-release-age-exclude[]=wire-mesh-core
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
10 changes: 5 additions & 5 deletions pnpm-lock.yaml

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

3 changes: 3 additions & 0 deletions src/core/bridge-mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -45,6 +47,7 @@ export function createBridgeMeshSync(
identity: await toIdentityPort(identity),
clock: createSystemClock(),
slot,
revocation,
});
},
};
Expand Down
111 changes: 105 additions & 6 deletions src/core/mesh-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -83,6 +91,7 @@ export interface MeshStoreIdentity {
identity: IdentityPort;
clock: Clock;
slot: IdentitySlot;
revocation: RevocationCheck;
}

/**
Expand Down Expand Up @@ -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 },
Expand All @@ -1242,9 +1251,99 @@ export class MeshStore implements CommsStore {
get roomVerbHandlers(): Partial<Record<string, RoomVerbHandler>> {
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<ManageOutcome> {
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<void> {
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.
*/
Expand Down Expand Up @@ -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 },
Expand Down
6 changes: 6 additions & 0 deletions src/core/random-id.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer> {
const bytes = new Uint8Array(16);
crypto.getRandomValues(bytes);
return bytes;
}
6 changes: 0 additions & 6 deletions src/core/token-id.ts

This file was deleted.

2 changes: 2 additions & 0 deletions src/test/downtime-replay.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -44,6 +45,7 @@ async function makePeer(
identity: await toIdentityPort(identity),
clock: createSystemClock(),
slot,
revocation: createRevocationView(),
});
const deliveries: DeliveryEvent[] = [];
return { store, deliveries };
Expand Down
2 changes: 2 additions & 0 deletions src/test/identity-restart.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -44,6 +45,7 @@ async function makePeer(
identity: await toIdentityPort(identity),
clock: createSystemClock(),
slot,
revocation: createRevocationView(),
});
const deliveries: DeliveryEvent[] = [];
return { store, deliveries };
Expand Down
5 changes: 3 additions & 2 deletions src/test/mesh-smoke.runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");`,
Expand All @@ -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) => {`,
Expand Down
10 changes: 5 additions & 5 deletions src/test/token-id.test.ts → src/test/random-id.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
2 changes: 2 additions & 0 deletions src/test/room-join-admission.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -174,6 +175,7 @@ describe("joinRoom (requester side, remote path)", () => {
identity: identityPort,
clock: createSystemClock(),
slot,
revocation: createRevocationView(),
});

const ownerId = "f".repeat(64);
Expand Down
Loading