From 03b2fdff4e608b8ba000d129f413ba73a82b04a6 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 17:23:01 +0100 Subject: [PATCH 1/5] feat(core): verify dm:send capability tokens against the user principal Adds verifyDmSendToken, mirroring room-token-verification.ts's own verifyRoomToken but rooted at the local user principal's device-id rather than a room owner. A dm:send token must carry the dm:send capability, scope kind "user" with path equal to the user principal, and a delegation chain that actually roots at that principal -- a self-issued or third-party token naming the right scope by coincidence still fails, since rootIssuer is checked independently of scope.path. --- src/core/dm-token-verification.ts | 76 ++++++++++ src/test/dm-token-verification.test.ts | 188 +++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 src/core/dm-token-verification.ts create mode 100644 src/test/dm-token-verification.test.ts diff --git a/src/core/dm-token-verification.ts b/src/core/dm-token-verification.ts new file mode 100644 index 00000000..c3cb0081 --- /dev/null +++ b/src/core/dm-token-verification.ts @@ -0,0 +1,76 @@ +/** + * Receiver-side verification of a `dm:send` capability token (agent-comms#162): the receiver-controlled admission list a user principal issues, parallel to room-token-verification.ts's own `room:member` obligations but rooted at the user principal's device-id (user-identity.ts) rather than a room's own owner -- a `dm:send` grant admits a bearer device into THIS user's communication scope across every bridge slot sharing that principal, not into one specific room path. Layered on verifyCapabilityToken the same way verifyRoomToken is: obligations 2/4/5 (bearer match, ordinary token-claims checks, delegations-remaining narrowing) live there already; this module adds the dm:send-specific obligations -- the presented token must actually carry the dm:send capability (not merely happen to share a scope shape), its scope must name this exact user principal, and its delegation chain must root at that same user principal (never at the bearer itself, or self-issued authority would let any sender simply mint its own admission). + */ + +import { + verifyCapabilityToken, + type TokenVerdictReason, + type VerifyCapabilityTokenOptions, +} from "wire-mesh-core/domain/tokens"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import type { + CapabilityToken, + DeviceId, + TokenClaims, +} from "wire-mesh-core/generated/protocol"; + +/** The one capability a user principal issues to admit a device into its own DM-communication scope (agent-comms#162) -- checked receiver-side wherever an unsolicited DM contact is admitted, the way room-token-verification.ts's ROOM_MEMBER_CAPABILITY gates every ordinary room-membership verb. */ +export const DM_SEND_CAPABILITY = "dm:send"; + +/** The scope kind a dm:send grant's own scope always carries -- the resource being granted is "communication with this user principal", named by that principal's own device-id, never a specific bridge slot's peer-id, since a dm:send grant admits a bearer into the user's whole communication scope rather than one particular device. */ +export const DM_SEND_SCOPE_KIND = "user"; + +export type DmSendTokenVerdictReason = + | TokenVerdictReason + | "wrong_capability" + | "wrong_scope_kind" + | "wrong_scope_path" + | "wrong_chain_root"; + +export type DmSendTokenVerdict = + | { ok: true; claims: TokenClaims } + | { ok: false; reason: DmSendTokenVerdictReason }; + +export interface VerifyDmSendTokenOptions extends Omit< + VerifyCapabilityTokenOptions, + "expectedBearer" +> { + /** The peer identity actually authenticated on the arriving connection -- the requester presenting this token as its own authority to contact userPrincipalDeviceId, never a relay-asserted or gossip-derived value. Mandatory here, matching verifyRoomToken's own mandatory expectedBearer: every dm:send check gates a specific counterparty, unlike verifyCapabilityToken's own optional field for a caller presenting a token to authorise itself. */ + expectedBearer: DeviceId; + /** This receiving node's own user-principal device-id (user-identity.ts's loadOrCreateUserIdentity), the only issuer a dm:send grant may validly root at. */ + userPrincipalDeviceId: DeviceId; +} + +/** + * Verifies a `dm:send` capability token against the receiver's own user-principal identity: the token must carry the dm:send capability, its scope must be kind "user" with path equal to userPrincipalDeviceId exactly, and its delegation chain must root at userPrincipalDeviceId itself -- a self-issued or third-party-issued token naming the right scope by coincidence still fails here, since rootIssuer is checked independently of scope.path. + */ +export async function verifyDmSendToken( + token: CapabilityToken, + options: Readonly, +): Promise { + const verdict = await verifyCapabilityToken(token, { + identity: options.identity, + clock: options.clock, + revocation: options.revocation, + expectedBearer: options.expectedBearer, + }); + if (!verdict.ok) { + return verdict; + } + + if (verdict.claims.capability !== DM_SEND_CAPABILITY) { + return { ok: false, reason: "wrong_capability" }; + } + if (verdict.claims.scope.kind !== DM_SEND_SCOPE_KIND) { + return { ok: false, reason: "wrong_scope_kind" }; + } + const expectedPath = deviceIdToHex(options.userPrincipalDeviceId); + if (verdict.claims.scope.path !== expectedPath) { + return { ok: false, reason: "wrong_scope_path" }; + } + if (deviceIdToHex(verdict.rootIssuer) !== expectedPath) { + return { ok: false, reason: "wrong_chain_root" }; + } + + return { ok: true, claims: verdict.claims }; +} diff --git a/src/test/dm-token-verification.test.ts b/src/test/dm-token-verification.test.ts new file mode 100644 index 00000000..655baa7f --- /dev/null +++ b/src/test/dm-token-verification.test.ts @@ -0,0 +1,188 @@ +/** + * Unit tests for verifyDmSendToken (agent-comms#162): the receiver-side check that a presented capability token genuinely proves the local user principal admitted its bearer into DM contact -- mirroring room-token-verification.test.ts's own coverage of verifyRoomToken, but rooted at a user principal rather than a room owner. + */ + +import { test, expect } from "vitest"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import { mintCapabilityToken } from "wire-mesh-core/domain/tokens"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { + DM_SEND_CAPABILITY, + verifyDmSendToken, +} from "../core/dm-token-verification.js"; +import { generateIdentity } from "../core/identity.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { randomId } from "../core/random-id.js"; + +const TOKEN_TTL_MS = 60_000; + +async function makeParties() { + const userPrincipal = await toIdentityPort(generateIdentity()); + const bearer = await toIdentityPort(generateIdentity()); + const stranger = await toIdentityPort(generateIdentity()); + const clock = createSystemClock(); + const revocation = { entriesFor: async () => [] }; + return { userPrincipal, bearer, stranger, clock, revocation }; +} + +test("a dm:send token minted by the user principal, scoped to itself, verifies", async () => { + const { userPrincipal, bearer, clock, revocation } = await makeParties(); + + const verdict = await mintCapabilityToken({ + identity: userPrincipal, + clock, + tokenId: randomId(), + bearer: bearer.deviceId, + capability: DM_SEND_CAPABILITY, + scope: { kind: "user", path: deviceIdToHex(userPrincipal.deviceId) }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + expect(verdict.ok).toBe(true); + if (!verdict.ok) return; + + const result = await verifyDmSendToken(verdict.token, { + identity: bearer, + clock, + revocation, + expectedBearer: bearer.deviceId, + userPrincipalDeviceId: userPrincipal.deviceId, + }); + + expect(result.ok, JSON.stringify(result)).toBe(true); + if (!result.ok) return; + expect(result.claims.capability).toBe(DM_SEND_CAPABILITY); +}); + +test("a token for a different capability is refused as wrong_capability", async () => { + const { userPrincipal, bearer, clock, revocation } = await makeParties(); + + const verdict = await mintCapabilityToken({ + identity: userPrincipal, + clock, + tokenId: randomId(), + bearer: bearer.deviceId, + capability: "room:member", + scope: { kind: "user", path: deviceIdToHex(userPrincipal.deviceId) }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) throw new Error("mint failed"); + + const result = await verifyDmSendToken(verdict.token, { + identity: bearer, + clock, + revocation, + expectedBearer: bearer.deviceId, + userPrincipalDeviceId: userPrincipal.deviceId, + }); + + expect(result).toEqual({ ok: false, reason: "wrong_capability" }); +}); + +test("a token scoped to the wrong kind is refused as wrong_scope_kind", async () => { + const { userPrincipal, bearer, clock, revocation } = await makeParties(); + + const verdict = await mintCapabilityToken({ + identity: userPrincipal, + clock, + tokenId: randomId(), + bearer: bearer.deviceId, + capability: DM_SEND_CAPABILITY, + scope: { kind: "room", path: deviceIdToHex(userPrincipal.deviceId) }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) throw new Error("mint failed"); + + const result = await verifyDmSendToken(verdict.token, { + identity: bearer, + clock, + revocation, + expectedBearer: bearer.deviceId, + userPrincipalDeviceId: userPrincipal.deviceId, + }); + + expect(result).toEqual({ ok: false, reason: "wrong_scope_kind" }); +}); + +test("a token scoped to a different user principal is refused as wrong_scope_path", async () => { + const { userPrincipal, bearer, stranger, clock, revocation } = + await makeParties(); + + const verdict = await mintCapabilityToken({ + identity: userPrincipal, + clock, + tokenId: randomId(), + bearer: bearer.deviceId, + capability: DM_SEND_CAPABILITY, + scope: { kind: "user", path: deviceIdToHex(stranger.deviceId) }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) throw new Error("mint failed"); + + const result = await verifyDmSendToken(verdict.token, { + identity: bearer, + clock, + revocation, + expectedBearer: bearer.deviceId, + userPrincipalDeviceId: userPrincipal.deviceId, + }); + + expect(result).toEqual({ ok: false, reason: "wrong_scope_path" }); +}); + +test("a self-issued token naming the right scope by coincidence is refused as wrong_chain_root", async () => { + const { userPrincipal, bearer, clock, revocation } = await makeParties(); + + // The bearer mints its own token, claiming the user principal's own scope path -- proving scope.path alone is never sufficient; the chain must actually root at the user principal's own issuing key. + const verdict = await mintCapabilityToken({ + identity: bearer, + clock, + tokenId: randomId(), + bearer: bearer.deviceId, + capability: DM_SEND_CAPABILITY, + scope: { kind: "user", path: deviceIdToHex(userPrincipal.deviceId) }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) throw new Error("mint failed"); + + const result = await verifyDmSendToken(verdict.token, { + identity: bearer, + clock, + revocation, + expectedBearer: bearer.deviceId, + userPrincipalDeviceId: userPrincipal.deviceId, + }); + + expect(result).toEqual({ ok: false, reason: "wrong_chain_root" }); +}); + +test("a token presented by a different bearer than it names is refused (bearer_mismatch, from verifyCapabilityToken)", async () => { + const { userPrincipal, bearer, stranger, clock, revocation } = + await makeParties(); + + const verdict = await mintCapabilityToken({ + identity: userPrincipal, + clock, + tokenId: randomId(), + bearer: bearer.deviceId, + capability: DM_SEND_CAPABILITY, + scope: { kind: "user", path: deviceIdToHex(userPrincipal.deviceId) }, + expires: clock.now() + TOKEN_TTL_MS, + delegationsRemaining: 0, + }); + if (!verdict.ok) throw new Error("mint failed"); + + const result = await verifyDmSendToken(verdict.token, { + identity: bearer, + clock, + revocation, + expectedBearer: stranger.deviceId, + userPrincipalDeviceId: userPrincipal.deviceId, + }); + + expect(result).toEqual({ ok: false, reason: "bearer_mismatch" }); +}); From 444227dd38aa2ff8579949c156ec378551b86278 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 17:31:27 +0100 Subject: [PATCH 2/5] feat(core): persist issued dm:send grants on the user principal Adds loadIssuedDmGrant/saveIssuedDmGrant/deleteIssuedDmGrant, keyed by bearer device-id hex, mirroring identity-store.ts's own issuedGrants bookkeeping for room:member grants. A user principal needs to remember the token-id of each dm:send grant it mints so a later revocation can name which one to revoke -- a token-id is never presented back on the wire, so this is the only record of it. Also fixes renewIfNeeded to preserve issuedDmGrants across a near-expiry certificate renewal, which previously always wrote a bare {privateKey, certificate, expiresAt} record. --- src/core/user-identity.ts | 75 +++++++++++++++++++++++++---- src/test/user-identity.test.ts | 86 ++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+), 9 deletions(-) diff --git a/src/core/user-identity.ts b/src/core/user-identity.ts index 758d9ee8..8c604ced 100644 --- a/src/core/user-identity.ts +++ b/src/core/user-identity.ts @@ -31,6 +31,13 @@ interface StoredUserIdentity { expiresAt: string; /** The token-id (base64) of each group:member grant this principal has itself issued to a device it admitted (agent-comms#161), keyed by the device's own device-id in hex -- the bookkeeping the principal needs to revoke a specific device's own membership later (removeDevice), mirroring identity-store.ts's own issuedGrants field for room membership. */ issuedDeviceGrants?: Record; + /** The token-id (base64) of each dm:send grant this principal has itself issued to a bearer device (agent-comms#162), keyed by the bearer's device-id hex -- the bookkeeping a user needs to revoke a specific agent's own DM access later, mirroring identity-store.ts's own issuedGrants field for room:member grants. A token-id is never presented back on the wire, so this identity's own memory of having minted it is the only record. */ + issuedDmGrants?: Record; +} + +function isStringRecord(value: unknown): value is Record { + if (typeof value !== "object" || value === null) return false; + return Object.values(value).every((v) => typeof v === "string"); } function isStoredUserIdentity(value: unknown): value is StoredUserIdentity { @@ -47,13 +54,10 @@ function isStoredUserIdentity(value: unknown): value is StoredUserIdentity { typeof value.expiresAt !== "string" ) return false; - if ("issuedDeviceGrants" in value) { - const { issuedDeviceGrants } = value; - if (typeof issuedDeviceGrants !== "object" || issuedDeviceGrants === null) - return false; - if (!Object.values(issuedDeviceGrants).every((v) => typeof v === "string")) - return false; - } + if ("issuedDeviceGrants" in value && !isStringRecord(value.issuedDeviceGrants)) + return false; + if ("issuedDmGrants" in value && !isStringRecord(value.issuedDmGrants)) + return false; return true; } @@ -98,7 +102,7 @@ function writeStoredUserIdentity( }); } -/** Reads this principal's raw stored record, or undefined if the file is missing or unparseable. Used by the issued-device-grant functions below to read-modify-write only the issuedDeviceGrants field, leaving the persisted key material exactly as it is. */ +/** Reads this principal's raw stored record, or undefined if the file is missing or unparseable. Used by the issued-grant functions below to read-modify-write only their own field, leaving the persisted key material exactly as it is. */ function readStoredUserIdentity(file: string): StoredUserIdentity | undefined { const raw = readRawFile(file); return raw === undefined ? undefined : parseStoredUserIdentity(raw); @@ -125,7 +129,7 @@ function parseStoredUserIdentity(raw: string): StoredUserIdentity | undefined { } /** - * Renews a stored identity nearing certificate expiry by re-certifying its existing key pair (preserving device-id) rather than replacing it -- see identity.ts's own certifyKeyPair doc comment for why generating a fresh key pair here would be wrong. Returns the existing identity unchanged when it is not yet near expiry. A plain persistedRecord() write here would silently drop this principal's own issuedDeviceGrants (it always builds a bare privateKey/certificate/expiresAt record with nothing else) -- spreading `stored` first preserves every other field on renewal, the same fix identity-store.ts's own loadStoredIdentity/writeStoredIdentity pairing already applies for roomTokens/issuedGrants. + * Renews a stored identity nearing certificate expiry by re-certifying its existing key pair (preserving device-id) rather than replacing it -- see identity.ts's own certifyKeyPair doc comment for why generating a fresh key pair here would be wrong. Returns the existing identity unchanged when it is not yet near expiry. A plain persistedRecord() write here would silently drop this principal's own issuedDeviceGrants/issuedDmGrants (it always builds a bare privateKey/certificate/expiresAt record with nothing else) -- spreading `stored` first preserves every other field on renewal, the same fix identity-store.ts's own loadStoredIdentity/writeStoredIdentity pairing already applies for roomTokens/issuedGrants. */ function renewIfNeeded( file: string, @@ -234,3 +238,56 @@ export function deleteIssuedDeviceGrant( ); writeStoredUserIdentity(file, { ...stored, issuedDeviceGrants }); } + +/** + * The token-id this principal has itself minted for bearerDeviceHex's own dm:send grant (agent-comms#162), or undefined if none is on record (this principal has never admitted that device, or the record predates this bookkeeping, or the principal identity has never been created at all). A user consults this to revoke a specific agent's own DM access later -- a token-id, unlike the token itself, is never presented on the wire and so is never obtainable except from this identity's own memory of having minted it. Mirrors loadIssuedDeviceGrant above. + */ +export function loadIssuedDmGrant( + options: Readonly | undefined, + bearerDeviceHex: string, +): Uint8Array | undefined { + const file = userIdentityFile(options); + const stored = readStoredUserIdentity(file); + const encoded = stored?.issuedDmGrants?.[bearerDeviceHex]; + return encoded === undefined + ? undefined + : Uint8Array.from(Buffer.from(encoded, "base64")); +} + +/** + * Records the token-id of a dm:send grant this principal has just minted for bearerDeviceHex, surviving a restart the same way the identity itself does. Overwrites any earlier record for the same bearer -- a fresh admission always supersedes the grant it replaces, so only the current token-id is ever worth revoking. Throws if this principal has never been created (call loadOrCreateUserIdentity first). Mirrors saveIssuedDeviceGrant above. + */ +export function saveIssuedDmGrant( + options: Readonly | undefined, + bearerDeviceHex: string, + tokenId: Uint8Array, +): void { + const file = userIdentityFile(options); + const stored = readStoredUserIdentity(file); + if (stored === undefined) { + throw new Error( + `no user-principal identity persisted yet -- call loadOrCreateUserIdentity first (${file})`, + ); + } + const issuedDmGrants = { + ...stored.issuedDmGrants, + [bearerDeviceHex]: Buffer.from(tokenId).toString("base64"), + }; + writeStoredUserIdentity(file, { ...stored, issuedDmGrants }); +} + +/** Removes the recorded token-id for one bearer's dm:send grant, if any -- called once a revocation has taken effect, so a later re-admission mints and records a genuinely fresh one rather than leaving a stale entry alongside it. A no-op if none was recorded, or if this principal has never been created. Mirrors deleteIssuedDeviceGrant above. */ +export function deleteIssuedDmGrant( + options: Readonly | undefined, + bearerDeviceHex: string, +): void { + const file = userIdentityFile(options); + const stored = readStoredUserIdentity(file); + if (stored?.issuedDmGrants === undefined) return; + const issuedDmGrants = Object.fromEntries( + Object.entries(stored.issuedDmGrants).filter( + ([hex]) => hex !== bearerDeviceHex, + ), + ); + writeStoredUserIdentity(file, { ...stored, issuedDmGrants }); +} diff --git a/src/test/user-identity.test.ts b/src/test/user-identity.test.ts index 4815d421..97fae63a 100644 --- a/src/test/user-identity.test.ts +++ b/src/test/user-identity.test.ts @@ -12,8 +12,12 @@ import { loadIssuedDeviceGrant, loadOrCreateUserIdentity, saveIssuedDeviceGrant, + loadIssuedDmGrant, + saveIssuedDmGrant, + deleteIssuedDmGrant, } from "../core/user-identity.js"; import { CERTIFICATE_VALIDITY_MS } from "../core/identity.js"; +import { randomId } from "../core/random-id.js"; // node:fs's writeFileSync is wrapped (not replaced) so every test gets the real filesystem by default; only the one race test below overrides it, via mockImplementationOnce, to simulate a concurrent writer winning the exclusive create -- vi.spyOn cannot target an ESM named export directly ("Module namespace is not configurable"), so the wrap has to happen at vi.mock time instead. vi.mock("node:fs", async (importOriginal) => { @@ -206,3 +210,85 @@ test("a renewed identity keeps its issuedDeviceGrants record", () => { Uint8Array.from([1, 2, 1]), ); }); + +test("loadIssuedDmGrant returns undefined when no grant has been recorded for a bearer", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + + expect(loadIssuedDmGrant({ dir }, "aa")).toBeUndefined(); +}); + +test("saveIssuedDmGrant persists a token-id that loadIssuedDmGrant then returns", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + const tokenId = randomId(); + + saveIssuedDmGrant({ dir }, "aa", tokenId); + + expect(loadIssuedDmGrant({ dir }, "aa")).toEqual(tokenId); +}); + +test("saveIssuedDmGrant for a second bearer leaves the first bearer's own record untouched", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + const firstTokenId = randomId(); + const secondTokenId = randomId(); + + saveIssuedDmGrant({ dir }, "aa", firstTokenId); + saveIssuedDmGrant({ dir }, "bb", secondTokenId); + + expect(loadIssuedDmGrant({ dir }, "aa")).toEqual(firstTokenId); + expect(loadIssuedDmGrant({ dir }, "bb")).toEqual(secondTokenId); +}); + +test("saveIssuedDmGrant overwrites an earlier record for the same bearer", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + const originalTokenId = randomId(); + const freshTokenId = randomId(); + + saveIssuedDmGrant({ dir }, "aa", originalTokenId); + saveIssuedDmGrant({ dir }, "aa", freshTokenId); + + expect(loadIssuedDmGrant({ dir }, "aa")).toEqual(freshTokenId); +}); + +test("deleteIssuedDmGrant removes a recorded grant, leaving other bearers' records untouched", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + saveIssuedDmGrant({ dir }, "aa", randomId()); + const keptTokenId = randomId(); + saveIssuedDmGrant({ dir }, "bb", keptTokenId); + + deleteIssuedDmGrant({ dir }, "aa"); + + expect(loadIssuedDmGrant({ dir }, "aa")).toBeUndefined(); + expect(loadIssuedDmGrant({ dir }, "bb")).toEqual(keptTokenId); +}); + +test("deleteIssuedDmGrant is a no-op when nothing was recorded for that bearer", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + + expect(() => { + deleteIssuedDmGrant({ dir }, "aa"); + }).not.toThrow(); +}); + +test("a renewed identity keeps its issuedDmGrants record", () => { + const dir = tempDir(); + loadOrCreateUserIdentity({ dir }); + const tokenId = randomId(); + saveIssuedDmGrant({ dir }, "aa", tokenId); + + const file = identityFile(dir); + const stored = JSON.parse(fs.readFileSync(file, "utf-8")) as { + expiresAt: string; + }; + stored.expiresAt = new Date(Date.now() + NEAR_EXPIRY_OFFSET_MS).toISOString(); + fs.writeFileSync(file, JSON.stringify(stored)); + + loadOrCreateUserIdentity({ dir }); + + expect(loadIssuedDmGrant({ dir }, "aa")).toEqual(tokenId); +}); From 28a3b1716005e0a6e9c1389668596bf8a0df09e3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 17:41:20 +0100 Subject: [PATCH 3/5] feat(core): thread the user principal identity through MeshStoreIdentity MeshStoreIdentity now carries userIdentity (an IdentityPort for the local user principal, agent-comms#160) and userIdentityOptions (the on-disk location it was loaded from), alongside the existing per-bridge-slot device identity. Every setIdentity() call site is updated to load and wire it through, wiring it via loadOrCreateUserIdentity the same way bridge-mesh.ts and test-transport.ts already load the per-slot device identity. This is plumbing only: nothing yet reads userIdentity from MeshStoreIdentity. It lays the groundwork for verifying and minting dm:send capability tokens (agent-comms#162) against the same principal every bridge on this machine account shares. --- src/core/bridge-mesh.ts | 6 ++++++ src/core/mesh-store-shared.ts | 14 +++++++++++++- src/test/delivery-engine-delivery.test.ts | 2 ++ src/test/delivery-engine-directed-notify.test.ts | 2 ++ src/test/delivery-engine.test.ts | 2 ++ src/test/identity-restart.integration.test.ts | 8 ++++++++ src/test/mesh-smoke.runner.ts | 5 ++++- src/test/room-join-admission.test.ts | 10 ++++++++++ src/test/room-lifecycle-membership.test.ts | 2 ++ src/test/room-lifecycle-remote.test.ts | 2 ++ src/test/room-lifecycle.test.ts | 2 ++ src/test/room-messaging-durable-send.test.ts | 2 ++ src/test/room-messaging.test.ts | 2 ++ src/test/room-protocol-admission.test.ts | 2 ++ src/test/room-protocol-notify.test.ts | 2 ++ src/test/room-protocol.test.ts | 2 ++ src/test/room-send-retry.integration.test.ts | 8 ++++++++ src/test/test-transport.ts | 8 ++++++++ 18 files changed, 79 insertions(+), 2 deletions(-) diff --git a/src/core/bridge-mesh.ts b/src/core/bridge-mesh.ts index 2aad5909..6e613a7f 100644 --- a/src/core/bridge-mesh.ts +++ b/src/core/bridge-mesh.ts @@ -23,6 +23,7 @@ import { toIdentityPort } from "./wire-mesh-identity.js"; import type { PeerIdentity } from "./identity.js"; import { VersionDriftChecker } from "./version-check.js"; import { getOwnPackageVersion } from "./package-version.js"; +import { loadOrCreateUserIdentity } from "./user-identity.js"; export interface BridgeMesh { store: MeshStore; @@ -60,6 +61,9 @@ export function createBridgeMeshSyncFromIdentity( hubUrl?: string, fetchLatestVersion?: () => Promise, ): BridgeMeshSync { + // The user-principal identity (agent-comms#160) is shared by every bridge on this machine account -- deliberately not scoped to slot, unlike identity above. userIdentityOptions is empty (the default ~/.agent-comms location); every real bridge shares it, and only tests need an override. + const userIdentityOptions = {}; + const userIdentity = loadOrCreateUserIdentity(userIdentityOptions); const store = new MeshStore(coordinatorPort, hubUrl); store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId)); // One shared dataStorage instance for both the transport's own data-domain frame responder and the store's own durable-send mint path (P5, agent-comms#50) -- oplogDirFor(slot) needs only the slot, not the async identity below, so this can be constructed synchronously right here. @@ -96,6 +100,8 @@ export function createBridgeMeshSyncFromIdentity( slot, revocation, dataStorage, + userIdentity: await toIdentityPort(userIdentity), + userIdentityOptions, }); }, }; diff --git a/src/core/mesh-store-shared.ts b/src/core/mesh-store-shared.ts index 2938f8cc..e0b0a60e 100644 --- a/src/core/mesh-store-shared.ts +++ b/src/core/mesh-store-shared.ts @@ -7,14 +7,17 @@ import type { IdentityPort } from "wire-mesh-core/ports/identity"; import type { RevocationView } from "wire-mesh-core/domain/revocation-view"; import type { KeyValueStorage } from "wire-mesh-core/ports/storage"; import type { IdentitySlot } from "./identity-store.js"; +import type { UserIdentityOptions } from "./user-identity.js"; -/** The identity/clock/persistence collaborators MeshStore mints and persists room-membership grants against. Set via setIdentity(), mirroring the transport's own setTransport() contract. dataStorage backs this device's own room-notice oplog (P5, agent-comms#50) -- the same KeyValueStorage instance WireMeshTransport's own dataStorage constructor parameter is wired with, so a durable sendRoomMessage and the transport's own data-domain responder read and write the identical log. */ +/** The identity/clock/persistence collaborators MeshStore mints and persists room-membership grants against. Set via setIdentity(), mirroring the transport's own setTransport() contract. dataStorage backs this device's own room-notice oplog (P5, agent-comms#50) -- the same KeyValueStorage instance WireMeshTransport's own dataStorage constructor parameter is wired with, so a durable sendRoomMessage and the transport's own data-domain responder read and write the identical log. userIdentity/userIdentityOptions are the user-principal identity (user-identity.ts, agent-comms#160) this store's own bridge slot shares with every other bridge on this machine account -- distinct from `identity` above, which is this specific bridge's own per-slot device identity; userIdentity is what a dm:send grant (agent-comms#162) is minted and verified against. userIdentityOptions is threaded through purely so RoomLifecycle's own admit/revoke methods can find the same on-disk record userIdentity was loaded from, to persist issued-grant bookkeeping against it. */ export interface MeshStoreIdentity { identity: IdentityPort; clock: Clock; slot: IdentitySlot; revocation: RevocationView; dataStorage: KeyValueStorage; + userIdentity: IdentityPort; + userIdentityOptions: Readonly; } /** @@ -32,6 +35,15 @@ export const ROOM_TOKEN_LIFETIME_MS = SECONDS_PER_MINUTE * MS_PER_SECOND; +/** Lifetime of a freshly minted dm:send grant (agent-comms#162) -- the same 30-day generosity ROOM_TOKEN_LIFETIME_MS applies, for the same reason: no periodic re-issue-on-refresh mechanism exists yet for this grant kind either, so a short expiry would just let ordinary admissions go stale with nothing to renew them. */ +const DM_SEND_GRANT_LIFETIME_DAYS = 30; +export const DM_SEND_GRANT_LIFETIME_MS = + DM_SEND_GRANT_LIFETIME_DAYS * + HOURS_PER_DAY * + MINUTES_PER_HOUR * + SECONDS_PER_MINUTE * + MS_PER_SECOND; + /** A human's decision on a pending room.join request -- reject carries an optional reason, mirroring rejectConnection's own equivalent room-independent decision. */ export type RoomJoinDecision = { kind: "accept" } | { kind: "reject"; reason?: string }; diff --git a/src/test/delivery-engine-delivery.test.ts b/src/test/delivery-engine-delivery.test.ts index c88df515..bdf43849 100644 --- a/src/test/delivery-engine-delivery.test.ts +++ b/src/test/delivery-engine-delivery.test.ts @@ -151,6 +151,8 @@ function makeHarness() { identity: {} as never, revocation: { record: revocationRecord } as never, dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => transport, getOnDelivery: () => onDelivery, diff --git a/src/test/delivery-engine-directed-notify.test.ts b/src/test/delivery-engine-directed-notify.test.ts index e39cd6dd..01e502ce 100644 --- a/src/test/delivery-engine-directed-notify.test.ts +++ b/src/test/delivery-engine-directed-notify.test.ts @@ -149,6 +149,8 @@ function makeHarness() { identity: {} as never, revocation: { record: revocationRecord } as never, dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => transport, getOnDelivery: () => onDelivery, diff --git a/src/test/delivery-engine.test.ts b/src/test/delivery-engine.test.ts index d1e99331..caadc948 100644 --- a/src/test/delivery-engine.test.ts +++ b/src/test/delivery-engine.test.ts @@ -156,6 +156,8 @@ function makeHarness() { identity: {} as never, revocation: { record: revocationRecord } as never, dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => transport, getOnDelivery: () => onDelivery, diff --git a/src/test/identity-restart.integration.test.ts b/src/test/identity-restart.integration.test.ts index a5fdf3fe..09326c87 100644 --- a/src/test/identity-restart.integration.test.ts +++ b/src/test/identity-restart.integration.test.ts @@ -21,6 +21,7 @@ import { type IdentitySlot, } from "../core/identity-store.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { loadOrCreateUserIdentity } from "../core/user-identity.js"; import type { DeliveryEvent } from "../core/types.js"; import { ownerNamedRoomPath } from "../core/room-path.js"; @@ -51,12 +52,19 @@ async function makePeer( store.setTransport( new WireMeshTransport(store.events, identity, store.roomVerbHandlers), ); + const userIdentityOptions = { + dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-user-identity-")), + }; store.setIdentity({ identity: await toIdentityPort(identity), clock: createSystemClock(), slot, revocation: createRevocationView(), dataStorage: createMemoryStorage(), + userIdentity: await toIdentityPort( + loadOrCreateUserIdentity(userIdentityOptions), + ), + userIdentityOptions, }); const deliveries: DeliveryEvent[] = []; return { store, deliveries }; diff --git a/src/test/mesh-smoke.runner.ts b/src/test/mesh-smoke.runner.ts index 56d4ec2b..b6474393 100644 --- a/src/test/mesh-smoke.runner.ts +++ b/src/test/mesh-smoke.runner.ts @@ -113,6 +113,7 @@ function buildScript(name: string, actions: string): string { `const { CommsTool } = require("./dist/core/tool.js");`, `const { WireMeshTransport } = require("./dist/core/wire-mesh-transport.js");`, `const { loadOrCreateIdentity } = require("./dist/core/identity-store.js");`, + `const { loadOrCreateUserIdentity } = require("./dist/core/user-identity.js");`, `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");`, @@ -125,9 +126,11 @@ function buildScript(name: string, actions: string): string { ` const store = new MeshStore(${String(SMOKE_PORT)}, "${UNREACHABLE_HUB_URL}");`, ` const slot = { harness: "smoke-${name}", cwd: "/test/${name}", dir: fs.mkdtempSync(path.join(os.tmpdir(), "agent-comms-smoke-${name}-")) };`, ` const identity = loadOrCreateIdentity(slot);`, + ` const userIdentityOptions = { dir: fs.mkdtempSync(path.join(os.tmpdir(), "agent-comms-smoke-user-${name}-")) };`, + ` const userIdentity = loadOrCreateUserIdentity(userIdentityOptions);`, ` store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId));`, ` store.setTransport(new WireMeshTransport(store.events, identity, store.roomVerbHandlers));`, - ` store.setIdentity({ identity: await toIdentityPort(identity), clock: createSystemClock(), slot, revocation: createRevocationView() });`, + ` store.setIdentity({ identity: await toIdentityPort(identity), clock: createSystemClock(), slot, revocation: createRevocationView(), userIdentity: await toIdentityPort(userIdentity), userIdentityOptions });`, ` const tool = new CommsTool(store);`, ` const deliveries = [];`, ` store.onDelivery = (_id, event) => {`, diff --git a/src/test/room-join-admission.test.ts b/src/test/room-join-admission.test.ts index 9224199b..698295fc 100644 --- a/src/test/room-join-admission.test.ts +++ b/src/test/room-join-admission.test.ts @@ -28,6 +28,7 @@ import { } from "../core/identity-store.js"; import type { IdentitySlot } from "../core/identity-store.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { loadOrCreateUserIdentity } from "../core/user-identity.js"; import type { ConnectionHandle, MeshTransport } from "../core/transport.js"; import { wireTestTransport } from "./test-transport.js"; @@ -181,12 +182,21 @@ describe("joinRoom (requester side, remote path)", () => { const identity = loadOrCreateIdentity(slot); store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId)); const identityPort = await toIdentityPort(identity); + const userIdentityOptions = { + dir: fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-test-user-identity-"), + ), + }; store.setIdentity({ identity: identityPort, clock: createSystemClock(), slot, revocation: createRevocationView(), dataStorage: createMemoryStorage(), + userIdentity: await toIdentityPort( + loadOrCreateUserIdentity(userIdentityOptions), + ), + userIdentityOptions, }); const ownerId = "f".repeat(DEVICE_ID_HEX_LENGTH); diff --git a/src/test/room-lifecycle-membership.test.ts b/src/test/room-lifecycle-membership.test.ts index 2104fcaa..5389b11a 100644 --- a/src/test/room-lifecycle-membership.test.ts +++ b/src/test/room-lifecycle-membership.test.ts @@ -171,6 +171,8 @@ async function makeHarness(): Promise { slot, revocation: createRevocationView(), dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => ({ sendRoomRequest, broadcastRevocation }) as unknown as ReturnType< diff --git a/src/test/room-lifecycle-remote.test.ts b/src/test/room-lifecycle-remote.test.ts index e2396064..f20248b6 100644 --- a/src/test/room-lifecycle-remote.test.ts +++ b/src/test/room-lifecycle-remote.test.ts @@ -169,6 +169,8 @@ async function makeHarness(): Promise { slot, revocation: createRevocationView(), dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => ({ sendRoomRequest, broadcastRevocation }) as unknown as ReturnType< diff --git a/src/test/room-lifecycle.test.ts b/src/test/room-lifecycle.test.ts index f8a7a781..0afae91d 100644 --- a/src/test/room-lifecycle.test.ts +++ b/src/test/room-lifecycle.test.ts @@ -170,6 +170,8 @@ async function makeHarness(): Promise { slot, revocation: createRevocationView(), dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => ({ sendRoomRequest, broadcastRevocation }) as unknown as ReturnType< diff --git a/src/test/room-messaging-durable-send.test.ts b/src/test/room-messaging-durable-send.test.ts index 415bea96..30f74e67 100644 --- a/src/test/room-messaging-durable-send.test.ts +++ b/src/test/room-messaging-durable-send.test.ts @@ -86,6 +86,8 @@ async function makeHarness() { identity: ownerIdentity, revocation: createRevocationView(), dataStorage, + userIdentity: ownerIdentity, + userIdentityOptions: {}, }), roomProtocol: { sendRoomRequestToMember: async () => undefined }, }; diff --git a/src/test/room-messaging.test.ts b/src/test/room-messaging.test.ts index 0aab88ba..e0cfc3a6 100644 --- a/src/test/room-messaging.test.ts +++ b/src/test/room-messaging.test.ts @@ -77,6 +77,8 @@ function makeHarness() { identity: {} as never, revocation: {} as never, dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), roomProtocol: { sendRoomRequestToMember }, }; diff --git a/src/test/room-protocol-admission.test.ts b/src/test/room-protocol-admission.test.ts index 16d1cd24..ce14789f 100644 --- a/src/test/room-protocol-admission.test.ts +++ b/src/test/room-protocol-admission.test.ts @@ -164,6 +164,8 @@ async function makeHarness(): Promise { slot, revocation: createRevocationView(), dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => ({ sendRoomRequest }) as unknown as ReturnType< diff --git a/src/test/room-protocol-notify.test.ts b/src/test/room-protocol-notify.test.ts index c560b44e..1ac41504 100644 --- a/src/test/room-protocol-notify.test.ts +++ b/src/test/room-protocol-notify.test.ts @@ -113,6 +113,8 @@ async function makeHarness(): Promise { slot, revocation: createRevocationView(), dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => ({}) as unknown as ReturnType, diff --git a/src/test/room-protocol.test.ts b/src/test/room-protocol.test.ts index 80025756..cc777fd8 100644 --- a/src/test/room-protocol.test.ts +++ b/src/test/room-protocol.test.ts @@ -162,6 +162,8 @@ async function makeHarness(): Promise { slot, revocation: createRevocationView(), dataStorage: {} as never, + userIdentity: {} as never, + userIdentityOptions: {}, }), requireTransport: () => ({ sendRoomRequest }) as unknown as ReturnType< diff --git a/src/test/room-send-retry.integration.test.ts b/src/test/room-send-retry.integration.test.ts index d4a912d6..4be7c481 100644 --- a/src/test/room-send-retry.integration.test.ts +++ b/src/test/room-send-retry.integration.test.ts @@ -18,6 +18,7 @@ import { type IdentitySlot, } from "../core/identity-store.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { loadOrCreateUserIdentity } from "../core/user-identity.js"; import type { DeliveryEvent } from "../core/types.js"; import type { PeerIdentity } from "../core/identity.js"; import { ownerNamedRoomPath } from "../core/room-path.js"; @@ -55,12 +56,19 @@ async function makePeer( store.setTransport( new WireMeshTransport(store.events, identity, store.roomVerbHandlers), ); + const userIdentityOptions = { + dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-user-identity-")), + }; store.setIdentity({ identity: await toIdentityPort(identity), clock: createSystemClock(), slot, revocation: createRevocationView(), dataStorage: createMemoryStorage(), + userIdentity: await toIdentityPort( + loadOrCreateUserIdentity(userIdentityOptions), + ), + userIdentityOptions, }); const deliveries: DeliveryEvent[] = []; return { store, deliveries }; diff --git a/src/test/test-transport.ts b/src/test/test-transport.ts index e913ad73..b3a42a52 100644 --- a/src/test/test-transport.ts +++ b/src/test/test-transport.ts @@ -11,6 +11,7 @@ import { createMemoryStorage } from "wire-mesh-core/adapters/memory-storage"; import { loadOrCreateIdentity } from "../core/identity-store.js"; import type { IdentitySlot } from "../core/identity-store.js"; import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { loadOrCreateUserIdentity } from "../core/user-identity.js"; import { nanoid } from "../core/nanoid.js"; import type { MeshStore } from "../core/mesh-store.js"; @@ -30,6 +31,11 @@ export async function wireTestTransport( dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-identity-")), }; const identity = loadOrCreateIdentity(resolvedSlot); + // A fresh throwaway directory per call, matching resolvedSlot's own default: each test MeshStore represents a separate device belonging to a separate person, so it needs its own user-principal identity, never the real machine-wide ~/.agent-comms/user-identity.json a production bridge shares. + const userIdentityOptions = { + dir: fs.mkdtempSync(path.join(tmpdir(), "agent-comms-test-user-identity-")), + }; + const userIdentity = loadOrCreateUserIdentity(userIdentityOptions); // Every real bridge sets peerId to deviceIdToHex(identity.deviceId) before wiring the transport (createBridgeMesh) -- WireMeshTransport's own session bookkeeping is keyed by device-id, so a peer's advertised ID and the identity the other side actually authenticates the connection against must be the same value, or introduction/state-sync never recognises the peer as itself. store.peerId = deviceIdToHex(Uint8Array.from(identity.deviceId)); // One shared dataStorage instance for both the transport's own data-domain frame responder and the store's own durable-send mint path (P5, agent-comms#50) -- memory-backed, matching every other throwaway test identity here, rather than a real createNodeFsStorage a test would need to clean up afterwards. @@ -52,6 +58,8 @@ export async function wireTestTransport( slot: resolvedSlot, revocation: createRevocationView(), dataStorage, + userIdentity: await toIdentityPort(userIdentity), + userIdentityOptions, }); // 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) => { From 29de16a809dff3a71af4bfe4f8dff2146be1e468 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 17:48:10 +0100 Subject: [PATCH 4/5] feat(core): gate DM admission on a user-issued dm:send capability Wires verifyDmSendToken into handleRoomJoin's DM branch: a room.join request for a DM path that presents a token verifies it against this node's own user principal and auto-admits on success, refuses outright on a present-but-invalid token, and falls through to the existing two-round human-consent flow when no token is presented at all. This is additive -- every existing DM admission path is unchanged when the requester holds no dm:send grant. Adds the admission primitives a user needs to actually issue and withdraw that grant, mirroring room-lifecycle.ts's own invite/kick pattern: admitAgentForDm mints and persists a dm:send grant, and revokeAgentDmAccess mints a revocation entry, records and gossips it, and forgets the issued-grant record. requestDmAccess grows an optional dmSendGrant parameter so a requester can present a grant it was already admitted with. --- src/core/mesh-store.ts | 20 ++- src/core/room-lifecycle.ts | 66 +++++++++- src/core/room-protocol.ts | 21 +++- ...d-capability-admission.integration.test.ts | 115 ++++++++++++++++++ 4 files changed, 216 insertions(+), 6 deletions(-) create mode 100644 src/test/dm-send-capability-admission.integration.test.ts diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index ff09532e..ba8b12d2 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -42,6 +42,7 @@ import type { TransportEvents, } from "./transport.js"; import type { CommsStore } from "./comms-store.js"; +import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; import type { AgentIdentity, AgentStatus, @@ -536,9 +537,22 @@ export class MeshStore implements CommsStore { return this.roomLifecycle.refreshRoomMembers(roomPath); } - /** The requester's own half of section 6's two-round DM consent flow. Deliberately outside the CommsStore interface, like connection approval, since it is a wire-mesh-specific concern FileStore has no equivalent for. Concrete-only -- reached directly by tests. */ - async requestDmAccess(counterpart: string): Promise { - return this.roomLifecycle.requestDmAccess(counterpart); + /** The requester's own half of section 6's two-round DM consent flow, optionally presenting a dm:send grant (agent-comms#162) the counterpart's own user principal already minted for this device via admitAgentForDm -- when given and valid, the counterpart auto-admits immediately rather than holding the request open for a human decision. Deliberately outside the CommsStore interface, like connection approval, since it is a wire-mesh-specific concern FileStore has no equivalent for. Concrete-only -- reached directly by tests. */ + async requestDmAccess( + counterpart: string, + dmSendGrant?: CapabilityToken, + ): Promise { + return this.roomLifecycle.requestDmAccess(counterpart, dmSendGrant); + } + + /** Admits bearerId into this user's own DM-communication scope (agent-comms#162): mints and persists a dm:send grant, self-signed by this store's own user principal. Returns the minted token for the caller to deliver to bearerId out of band. Deliberately outside the CommsStore interface, like requestDmAccess above. Concrete-only -- reached directly by tests. */ + async admitAgentForDm(bearerId: string): Promise { + return this.roomLifecycle.admitAgentForDm(bearerId); + } + + /** Revokes bearerId's own dm:send grant for real (agent-comms#162), the DM-scope counterpart to kickFromRoom. A no-op if bearerId was never admitted. Deliberately outside the CommsStore interface, like requestDmAccess above. Concrete-only -- reached directly by tests. */ + async revokeAgentDmAccess(bearerId: string): Promise { + return this.roomLifecycle.revokeAgentDmAccess(bearerId); } async joinRoom(roomId: string, agentId: string): Promise { diff --git a/src/core/room-lifecycle.ts b/src/core/room-lifecycle.ts index bc36b89e..c61565ec 100644 --- a/src/core/room-lifecycle.ts +++ b/src/core/room-lifecycle.ts @@ -24,6 +24,7 @@ import { ROOM_MEMBER_CAPABILITY, ROOM_MEMBER_DELEGATION_POLICY, } from "./room-token-verification.js"; +import { DM_SEND_CAPABILITY } from "./dm-token-verification.js"; import { resolveDelegationsRemaining } from "./delegation-policy.js"; import { deleteIssuedRoomGrant, @@ -33,9 +34,17 @@ import { saveIssuedRoomGrant, saveRoomToken, } from "./identity-store.js"; +import { + deleteIssuedDmGrant, + loadIssuedDmGrant, + saveIssuedDmGrant, +} from "./user-identity.js"; import { randomId } from "./random-id.js"; import { CommsError } from "./store.js"; -import { ROOM_TOKEN_LIFETIME_MS } from "./mesh-store-shared.js"; +import { + DM_SEND_GRANT_LIFETIME_MS, + ROOM_TOKEN_LIFETIME_MS, +} from "./mesh-store-shared.js"; import type { MeshStoreIdentity } from "./mesh-store-shared.js"; import { inviterAgentExtension, @@ -45,6 +54,7 @@ import { import type { DeliveryEngine } from "./delivery-engine.js"; import type { MeshTransport } from "./transport.js"; import type { HostedRoomAdvert } from "./wire-mesh-transport.js"; +import type { CapabilityToken } from "wire-mesh-core/generated/protocol"; import type { AgentIdentity, AgentStatus, @@ -348,8 +358,13 @@ export class RoomLifecycle { /** * The requester's own half of section 6's two-round DM consent flow: sends an ungated room.join scoped to dmRoomPath(this, counterpart) directly to the counterpart, records having initiated it so the counterpart's own reciprocal room.join back auto-approves rather than surfacing as a fresh, unsolicited request, and persists whatever grant comes back. Deliberately outside the CommsStore interface, like connection approval, since it is a wire-mesh-specific concern FileStore has no equivalent for. Safe to call again for the same counterpart later (e.g. after an earlier request expired or was rejected) -- it always sends a fresh request rather than checking for an existing token first. + * + * dmSendGrant, when given, is a dm:send capability the counterpart's own user principal minted for this device (agent-comms#162, admitAgentForDm) -- attaching it here lets the counterpart's own handleRoomJoin verify durable, pre-existing admission and auto-admit immediately, without holding this request open for a fresh human decision the way an ungated request otherwise would. */ - async requestDmAccess(counterpart: string): Promise { + async requestDmAccess( + counterpart: string, + dmSendGrant?: CapabilityToken, + ): Promise { const dmPath = dmRoomPath(this.deps.getPeerId(), counterpart); this.deps.dmRequestsInitiatedByMe.add(dmPath); const outcome = await this.deps @@ -358,6 +373,7 @@ export class RoomLifecycle { counterpart, { verb: ROOM_MEMBER_CAPABILITY, params: { verb: "room.join" } }, { kind: "room", path: dmPath }, + dmSendGrant, ); if (outcome.result !== "ok") { throw new CommsError( @@ -715,4 +731,50 @@ export class RoomLifecycle { roomId, }); } + + /** + * Admits bearerId into this user's own DM-communication scope (agent-comms#162): mints a fresh dm:send grant, self-signed by this store's own user principal (userIdentity, distinct from the per-bridge-slot device identity every other room:member grant above is minted against), with no parent -- a root-level admission, exactly like mintOwnerRootGrant's own room-owner self-grant. Records the token-id the same way admitRoomJoin/inviteToRoom record theirs (saveIssuedDmGrant), so revokeAgentDmAccess can later name which one to revoke. Returns the minted token for the caller to get to bearerId out of band (there is no wire-level push here, deliberately: this issue adds the receiver-side check and the admission primitive it checks against, not a new delivery mechanism for the grant itself). + */ + async admitAgentForDm(bearerId: string): Promise { + const { userIdentity, userIdentityOptions, clock } = + this.deps.requireIdentity(); + const tokenId = randomId(); + const verdict = await mintCapabilityToken({ + identity: userIdentity, + clock, + tokenId, + bearer: deviceIdFromHex(bearerId), + capability: DM_SEND_CAPABILITY, + scope: { kind: "user", path: deviceIdToHex(userIdentity.deviceId) }, + expires: clock.now() + DM_SEND_GRANT_LIFETIME_MS, + // dm:send is a root-level, self-signed admission (issuer = userIdentity, no parent) that this API never exposes a caller-chosen delegation depth for -- unlike room:member/group:member, there is no per-agent-override mechanism here to route through resolveDelegationsRemaining, so this stays the direct literal every non-delegable root grant already used before delegation-policy.ts existed. + delegationsRemaining: 0, + }); + if (!verdict.ok) { + throw new CommsError( + `Failed to mint a dm:send grant for ${bearerId}: ${verdict.reason}`, + "MINT_FAILED", + ); + } + saveIssuedDmGrant(userIdentityOptions, bearerId, tokenId); + return verdict.token; + } + + /** + * Revokes bearerId's own dm:send grant for real, if this user principal ever recorded issuing one: mints a revocation-entry for its token-id, records it in this store's own RevocationView immediately, announces it to every connected peer, and forgets the issued-grant record (a later re-admission mints and records a genuinely fresh one rather than leaving a stale entry alongside it) -- the same revocation shape revokeMemberGrant already gives room:member grants, applied to the user principal's own dm:send grants instead of a bridge-slot device identity's room grants. Silently does nothing when no issued-grant record exists (bearerId was never admitted, or the record predates this bookkeeping). + */ + async revokeAgentDmAccess(bearerId: string): Promise { + const { userIdentity, userIdentityOptions, clock, revocation } = + this.deps.requireIdentity(); + const tokenId = loadIssuedDmGrant(userIdentityOptions, bearerId); + if (tokenId === undefined) return; + const entry = await mintRevocationEntry({ + identity: userIdentity, + tokenId, + revokedAt: clock.now(), + }); + await revocation.record(entry, { identity: userIdentity }); + await this.deps.requireTransport().broadcastRevocation([entry]); + deleteIssuedDmGrant(userIdentityOptions, bearerId); + } } diff --git a/src/core/room-protocol.ts b/src/core/room-protocol.ts index a48c6a82..f26a238e 100644 --- a/src/core/room-protocol.ts +++ b/src/core/room-protocol.ts @@ -25,6 +25,7 @@ import { verifyRoomToken, } from "./room-token-verification.js"; import { resolveDelegationsRemaining } from "./delegation-policy.js"; +import { verifyDmSendToken } from "./dm-token-verification.js"; import { loadRoomTokens, saveIssuedRoomGrant, @@ -463,7 +464,7 @@ export class RoomProtocol { } /** - * 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. + * 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, and so does a request presenting a valid dm:send capability this node's own user principal already issued the requester (agent-comms#162's own durable admission list, checked before ever falling through to a fresh human decision). */ private async handleRoomJoin( request: IncomingManageRequest, @@ -486,6 +487,24 @@ export class RoomProtocol { if (!parsed.participants.includes(peerId)) { return { result: "error", code: "not_participant" }; } + + // A presented dm:send grant is this node's own user principal admitting the requester ahead of time (agent-comms#162) -- verify it against that principal specifically (never this bridge slot's own device identity, the way an ordinary room:member grant is) and auto-admit on success. A token that fails this check is refused outright: it identifies the requester as claiming durable admission it does not actually hold, not as an ordinary unsolicited contact that still deserves a human decision. + if (request.token !== undefined) { + const { identity, clock, revocation, userIdentity } = + this.deps.requireIdentity(); + const verdict = await verifyDmSendToken(request.token, { + identity, + clock, + revocation, + expectedBearer: deviceIdFromHex(handle.id), + userPrincipalDeviceId: userIdentity.deviceId, + }); + if (!verdict.ok) { + return { result: "error", code: "unauthorized" }; + } + return this.admitRoomJoin(roomPath, handle, true); + } + // The reciprocal half of section 6's own two-round DM flow: this node's own outbound room.join to the same path (recorded by joinRemoteRoom before this response was even awaited) is the consent that makes the counterpart's own reply not unsolicited contact. const autoApprove = this.deps.dmRequestsInitiatedByMe.has(roomPath); return this.admitRoomJoin(roomPath, handle, autoApprove); diff --git a/src/test/dm-send-capability-admission.integration.test.ts b/src/test/dm-send-capability-admission.integration.test.ts new file mode 100644 index 00000000..0eae6f94 --- /dev/null +++ b/src/test/dm-send-capability-admission.integration.test.ts @@ -0,0 +1,115 @@ +/** + * Integration test for receiver-side DM gating with a user-issued capability (agent-comms#162): a receiver's own user principal can admit an agent into its DM-communication scope by minting a dm:send grant, which the agent then presents alongside its DM join request to auto-admit without needing a fresh human decision each time -- the durable admission list this issue adds, distinct from (and additive to) dm-admission.integration.test.ts's own two-round human-consent flow, which remains the fallback when no dm:send grant is presented at all. + */ + +import { test, expect } from "vitest"; +import { deviceIdFromHex } from "wire-mesh-core/domain/device-id"; +import { MeshStore } from "../core/mesh-store.js"; +import { waitFor, wireTestTransport } from "./test-transport.js"; + +/** A device-id, hex-encoded, is always exactly this many characters (32 raw bytes). */ +const DEVICE_ID_HEX_LENGTH = 64; + +let nextPort = 20_990; +function freshPort(): number { + nextPort += 1; + return nextPort; +} + +async function makeConnectedPair( + port: number, +): Promise<{ a: MeshStore; b: MeshStore }> { + const a = new MeshStore(port); + await wireTestTransport(a); + await a.init(); + await a.registerAgent({ + name: "a", + harness: "test", + cwd: "/test/a", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + const b = new MeshStore(port); + await wireTestTransport(b); + await b.init(); + await b.registerAgent({ + name: "b", + harness: "test", + cwd: "/test/b", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + await waitFor( + () => a.serialise().agents[b.peerId] !== undefined, + "a sees b's agent", + ); + return { a, b }; +} + +test("presenting a dm:send grant B minted for A auto-admits A's DM request, with no pending decision for B", async () => { + const { a, b } = await makeConnectedPair(freshPort()); + + try { + const grant = await b.admitAgentForDm(a.peerId); + + await a.requestDmAccess(b.peerId, grant); + + expect(b.listPendingRoomJoins()).toEqual([]); + expect(a.listPendingRoomJoins()).toEqual([]); + } finally { + await b.shutdown(); + await a.shutdown(); + } +}); + +test("presenting a grant minted for a different bearer is refused outright, with no pending decision left open", async () => { + const { a, b } = await makeConnectedPair(freshPort()); + + try { + // B admits some other device, never A -- A tries to present that grant as if it were its own. + const otherDeviceId = deviceIdFromHex("a".repeat(DEVICE_ID_HEX_LENGTH)); + const grantForSomeoneElse = await b.admitAgentForDm( + Buffer.from(otherDeviceId).toString("hex"), + ); + + await expect( + a.requestDmAccess(b.peerId, grantForSomeoneElse), + ).rejects.toThrow(/was refused/); + + expect(b.listPendingRoomJoins()).toEqual([]); + } finally { + await b.shutdown(); + await a.shutdown(); + } +}); + +test("after B revokes A's dm:send grant, presenting the stale token is refused", async () => { + const { a, b } = await makeConnectedPair(freshPort()); + + try { + const grant = await b.admitAgentForDm(a.peerId); + await b.revokeAgentDmAccess(a.peerId); + + await expect(a.requestDmAccess(b.peerId, grant)).rejects.toThrow( + /was refused/, + ); + } finally { + await b.shutdown(); + await a.shutdown(); + } +}); + +test("revoking a bearer that was never admitted is a harmless no-op", async () => { + const { a, b } = await makeConnectedPair(freshPort()); + + try { + await expect(b.revokeAgentDmAccess(a.peerId)).resolves.toBeUndefined(); + } finally { + await b.shutdown(); + await a.shutdown(); + } +}); From 9186fe7dd501cdf09c4a08a7e962d4323cf5b64f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 17 Sep 2026 17:59:30 +0100 Subject: [PATCH 5/5] style(core): reformat isStoredUserIdentity's issuedDeviceGrants check Prettier wraps the multi-line condition onto its own lines; this was left single-line after resolving a rebase conflict. --- src/core/user-identity.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/core/user-identity.ts b/src/core/user-identity.ts index 8c604ced..c50b7adc 100644 --- a/src/core/user-identity.ts +++ b/src/core/user-identity.ts @@ -54,7 +54,10 @@ function isStoredUserIdentity(value: unknown): value is StoredUserIdentity { typeof value.expiresAt !== "string" ) return false; - if ("issuedDeviceGrants" in value && !isStringRecord(value.issuedDeviceGrants)) + if ( + "issuedDeviceGrants" in value && + !isStringRecord(value.issuedDeviceGrants) + ) return false; if ("issuedDmGrants" in value && !isStringRecord(value.issuedDmGrants)) return false;