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
6 changes: 6 additions & 0 deletions src/core/bridge-mesh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -60,6 +61,9 @@ export function createBridgeMeshSyncFromIdentity(
hubUrl?: string,
fetchLatestVersion?: () => Promise<string | undefined>,
): 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.
Expand Down Expand Up @@ -96,6 +100,8 @@ export function createBridgeMeshSyncFromIdentity(
slot,
revocation,
dataStorage,
userIdentity: await toIdentityPort(userIdentity),
userIdentityOptions,
});
},
};
Expand Down
76 changes: 76 additions & 0 deletions src/core/dm-token-verification.ts
Original file line number Diff line number Diff line change
@@ -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<VerifyDmSendTokenOptions>,
): Promise<DmSendTokenVerdict> {
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 };
}
14 changes: 13 additions & 1 deletion src/core/mesh-store-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<UserIdentityOptions>;
}

/**
Expand All @@ -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 };
Expand Down
20 changes: 17 additions & 3 deletions src/core/mesh-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<void> {
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<void> {
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<CapabilityToken> {
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<void> {
return this.roomLifecycle.revokeAgentDmAccess(bearerId);
}

async joinRoom(roomId: string, agentId: string): Promise<Room> {
Expand Down
66 changes: 64 additions & 2 deletions src/core/room-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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<void> {
async requestDmAccess(
counterpart: string,
dmSendGrant?: CapabilityToken,
): Promise<void> {
const dmPath = dmRoomPath(this.deps.getPeerId(), counterpart);
this.deps.dmRequestsInitiatedByMe.add(dmPath);
const outcome = await this.deps
Expand All @@ -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(
Expand Down Expand Up @@ -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<CapabilityToken> {
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<void> {
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);
}
}
Loading
Loading