diff --git a/src/core/dm-send-delegation.ts b/src/core/dm-send-delegation.ts new file mode 100644 index 00000000..116b46ad --- /dev/null +++ b/src/core/dm-send-delegation.ts @@ -0,0 +1,76 @@ +/** + * Sub-delegation of a received dm:send grant to one of the recipient principal's own devices (agent-comms#187): once a remote user principal has been admitted into another user's DM-communication scope (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0 specifically to allow this), that principal mints a CHILD dm:send token -- parent = the grant it received, bearer = its own device -- so a specific device can present durable admission on the principal's behalf, without the admitting side ever needing to know that device's own device-id in advance. Mirrors device-membership.ts's admitDevice in shape (a plain mint-and-record function, no MeshStore-equivalent orchestrator), but delegates an EXISTING grant via `parent` rather than self-issuing a fresh root-level one -- the first real use of mintCapabilityToken's parent-narrowing machinery in this codebase, every grant minted before this having been an independent root-level token. + * + * Deliberately does not touch the delegated device's own identity-store.ts slot, mirroring device-membership.ts's own "no assumption about locality" stance: getting the minted token onto that device (this process, or a different one) is left entirely to the caller. + */ + +import { + mintCapabilityToken, + type MintVerdict, +} from "wire-mesh-core/domain/tokens"; +import { deviceIdToHex } from "wire-mesh-core/domain/device-id"; +import type { IdentityPort } from "wire-mesh-core/ports/identity"; +import type { Clock } from "wire-mesh-core/ports/clock"; +import type { + CapabilityToken, + DeviceId, +} from "wire-mesh-core/generated/protocol"; +import { + DM_SEND_CAPABILITY, + DM_SEND_SCOPE_KIND, +} from "./dm-token-verification.js"; +import { + saveIssuedDmGrant, + type UserIdentityOptions, +} from "./user-identity.js"; + +/** delegationsRemaining a delegated device's own token permits below it, when the caller doesn't ask for more -- 0, matching every other non-delegable grant this codebase already mints by default (device-membership.ts's own NOT_DELEGABLE): an ordinary device is a leaf of the chain, not a further delegator. */ +const LEAF_NOT_DELEGABLE = 0; + +export interface DelegateDmSendToDeviceOptions { + /** The principal doing the sub-delegating -- must be the same identity the parent grant's own bearer names, or mintCapabilityToken's own parent-bearer-matches-issuer narrowing check refuses the mint outright (`parent_bearer_mismatch`). */ + userIdentity: IdentityPort; + /** Directory override for tests, forwarded to user-identity.ts's own issued-grant storage -- must resolve to the same user-identity.json userIdentity's own key material lives in. */ + userIdentityOptions?: UserIdentityOptions; + clock: Clock; + tokenId: Uint8Array; + /** The grant this principal itself was admitted with (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0) -- every one of tokens.cddl's own narrowing obligations is checked against it at mint time. */ + parent: CapabilityToken; + /** The device being delegated to -- one of this principal's own devices. */ + deviceId: DeviceId; + /** The remote user principal that originally admitted this identity -- the scope this delegated token must keep naming, unchanged from parent's own scope (tokens.cddl's own narrowing requires an identical "user" scope path down the whole chain). */ + remoteUserPrincipalDeviceId: DeviceId; + expires: number; + /** How many further hops the delegated token itself permits below it -- defaults to LEAF_NOT_DELEGABLE; only worth raising for a hierarchy deeper than "principal delegates directly to a device" genuinely needs. */ + delegationsRemaining?: number; +} + +/** + * Mints deviceId's own dm:send delegation from a received grant: bearer = deviceId, parent = the grant this principal itself was admitted with, scope unchanged (still the REMOTE admitting principal's own "user" scope, never this principal's own device-id) -- mintCapabilityToken's own narrowing refuses this outright if parent's own delegationsRemaining was 0 (nothing left to sub-delegate) or if userIdentity does not match parent's own bearer. On success, records the token-id under THIS principal's own issued-grant store, keyed by deviceId's hex, so a later revocation can find it -- reuses user-identity.ts's saveIssuedDmGrant/loadIssuedDmGrant/deleteIssuedDmGrant exactly as admitAgentForDm's own root-level self-grants do, since both are simply "grants this identity has issued," keyed by bearer, regardless of whether the grant is root-level or itself a delegation. + */ +export async function delegateDmSendToDevice( + options: Readonly, +): Promise { + const verdict = await mintCapabilityToken({ + identity: options.userIdentity, + clock: options.clock, + tokenId: options.tokenId, + bearer: options.deviceId, + capability: DM_SEND_CAPABILITY, + scope: { + kind: DM_SEND_SCOPE_KIND, + path: deviceIdToHex(options.remoteUserPrincipalDeviceId), + }, + expires: options.expires, + delegationsRemaining: options.delegationsRemaining ?? LEAF_NOT_DELEGABLE, + parent: options.parent, + }); + if (!verdict.ok) return verdict; + + saveIssuedDmGrant( + options.userIdentityOptions, + deviceIdToHex(options.deviceId), + options.tokenId, + ); + return verdict; +} diff --git a/src/core/gateway-trust.ts b/src/core/gateway-trust.ts index c5fc826a..fd57e631 100644 --- a/src/core/gateway-trust.ts +++ b/src/core/gateway-trust.ts @@ -4,24 +4,34 @@ * Persisted per bridge slot when constructed with one (agent-comms#186), mirroring identity-store.ts's own per-slot room-token/issued-grant persistence: the trusted set is loaded from that slot's own sibling JSON file (identity-store.ts's loadGatewayTrust) on construction, and written back in full (saveGatewayTrust) after every add/remove, so trust survives a gateway restart instead of needing to be re-established every run. The in-memory Set below remains the live source of truth for isTrusted/hasAny at all times; persistence is purely load-on-construct and save-on-mutate. Constructed with no slot, this class keeps the original v1 FederationManager.trustedFingerprints precedent (retired with federation.ts, commit 4232b08): in-memory only, never touching disk. Every pre-#186 construction site (most tests, and any caller with no bridge identity slot to hand) falls into this no-slot case unchanged. * * Keyed by individual device-id, not by "one entry per remote machine": wire-mesh-core's relay-hub protocol (relay-hub.ts, gossip-frame, relay-data-frame) carries no field identifying which remote gateway connection a given directory entry or relayed request actually originated from -- only the entry/request's own device-id, which may be an ordinary local peer forwarded on a remote machine's behalf rather than that machine's own coordinator. Gating per individual device-id is therefore the finest-grained, and only wire-protocol-honest, trust boundary actually implementable without a wire-mesh-core protocol change (deliberately out of scope here, matching agent-comms#156's own "gating the hub itself is out of scope" framing) -- confirmed as the intended granularity by hub-session.ts's own pre-existing isStateMutatingMessage doc comment, which already named this exact gap as "agent-comms#156's own future deliverable" of "per-peer" admission control. An operator who wants every local peer on a remote machine reachable trusts each of that machine's device-ids individually, not just its coordinator's. + * + * Principal-keyed trust (agent-comms#187): alongside the bare-device allowlist above, an operator may also trust a user-principal device-id (user-identity.ts) directly -- the root a remote peer's own dm:send-style delegation chain can terminate at, verified the same way device-membership-verification.ts already verifies a device's own group:member chain (`verifyCapabilityToken`'s chain-walk and its `rootIssuer` output). This is purely a second, parallel allowlist: `isTrusted`/`add`/`remove`/`list` keep checking only the bare-device set, unchanged, for a peer with no principal at all -- `isTrustedFor` is the additive entrypoint a caller who has already chain-verified a token uses to decide trust from that verified bearer and chain-root pair, accepting either a directly trusted device or a bearer rooted at a trusted principal. Persisted alongside the bare-device set (agent-comms#186's own persistence, extended here per that issue's own "agent-comms#187 covers what gets stored" framing): loadGatewayTrust/saveGatewayTrust now carry both sets. */ import type { IdentitySlot } from "./identity-store.js"; import { loadGatewayTrust, saveGatewayTrust } from "./identity-store.js"; -/** The read-only slice of GatewayTrust every consumer of the trust boundary actually needs (WireMeshTransport, HubSession, hub-forwarding.ts) -- named so call sites that only ever read trust decisions, never mutate them, don't repeat the same `Pick` inline at every field/parameter that takes one. */ -export type GatewayTrustReader = Pick; +/** The read-only slice of GatewayTrust every consumer of the trust boundary actually needs (WireMeshTransport, HubSession, hub-forwarding.ts) -- named so call sites that only ever read trust decisions, never mutate them, don't repeat the same `Pick` inline at every field/parameter that takes one. */ +export type GatewayTrustReader = Pick< + GatewayTrust, + "isTrusted" | "hasAny" | "isTrustedPrincipal" | "isTrustedFor" +>; export class GatewayTrust { private readonly trusted = new Set(); + private readonly trustedPrincipals = new Set(); private readonly slot: Readonly | undefined; - /** Constructs the trust boundary, optionally bound to a bridge identity slot for persistence (agent-comms#186); see this class's own doc comment for what a slot does and doesn't change. Given a slot, immediately loads whatever device-ids were trusted before the last restart into the initial in-memory set. */ + /** Constructs the trust boundary, optionally bound to a bridge identity slot for persistence (agent-comms#186); see this class's own doc comment for what a slot does and doesn't change. Given a slot, immediately loads whatever devices and principals were trusted before the last restart into the initial in-memory sets. */ constructor(slot?: Readonly) { this.slot = slot; if (slot !== undefined) { - for (const deviceHex of loadGatewayTrust(slot)) { + const loaded = loadGatewayTrust(slot); + for (const deviceHex of loaded.devices) { this.trusted.add(deviceHex); } + for (const principalHex of loaded.principals) { + this.trustedPrincipals.add(principalHex); + } } } @@ -37,9 +47,11 @@ export class GatewayTrust { this.persist(); } - /** Writes the complete current trusted set back to this instance's own slot, if it was constructed with one. A no-op for the in-memory-only (no slot) case. */ + /** Writes the complete current trusted device and principal sets back to this instance's own slot, if it was constructed with one. A no-op for the in-memory-only (no slot) case. */ private persist(): void { - if (this.slot !== undefined) saveGatewayTrust(this.slot, this.list()); + if (this.slot !== undefined) { + saveGatewayTrust(this.slot, this.list(), this.listPrincipals()); + } } /** Every currently trusted device-id, lowercase hex, in insertion order. */ @@ -52,10 +64,39 @@ export class GatewayTrust { return this.trusted.has(deviceHex.toLowerCase()); } + /** Marks a remote user-principal device-id (hex, case-insensitive; user-identity.ts) as trusted (agent-comms#187): a peer presenting a token whose delegation chain roots at this principal is trusted via `isTrustedFor` below, without that peer's own bare device-id ever needing individual trust. Idempotent, and entirely independent of the bare-device allowlist `add` manages. Persists the updated set when this instance was constructed with a slot. */ + addPrincipal(deviceHex: string): void { + this.trustedPrincipals.add(deviceHex.toLowerCase()); + this.persist(); + } + + /** Withdraws a previously trusted principal (hex, case-insensitive). A no-op if it was never trusted. Governs future chain checks only, mirroring `remove`'s own already-merged-traffic-is-unaffected posture. Persists the updated set when this instance was constructed with a slot. */ + removePrincipal(deviceHex: string): void { + this.trustedPrincipals.delete(deviceHex.toLowerCase()); + this.persist(); + } + + /** Every currently trusted principal device-id, lowercase hex, in insertion order. */ + listPrincipals(): string[] { + return [...this.trustedPrincipals]; + } + + /** Whether the given device-id (hex, case-insensitive) is currently trusted as a user principal. */ + isTrustedPrincipal(deviceHex: string): boolean { + return this.trustedPrincipals.has(deviceHex.toLowerCase()); + } + + /** + * Whether a peer is trusted, given a token's own bearer and chain-root device-ids (hex, case-insensitive) a caller has already chain-verified via `verifyCapabilityToken` (the same `rootIssuer` output device-membership-verification.ts's own chain check already relies on). Passes when the bearer itself is directly trusted (the existing bare-device path, unmodified for a peer with no principal at all), OR when the chain's root is a trusted principal -- so a device sub-delegated from an admitted principal (mirroring agent-comms#161's own "principal admits its own devices" pattern) is trusted without ever being individually allowlisted. This class performs no cryptographic verification itself; the caller supplies already-verified device-ids, keeping GatewayTrust the same plain allowlist it always was. + */ + isTrustedFor(bearerHex: string, rootIssuerHex: string): boolean { + return this.isTrusted(bearerHex) || this.isTrustedPrincipal(rootIssuerHex); + } + /** - * Whether at least one remote device is currently trusted -- the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device. Once true, an advertisement still reaches every hub-connected peer, trusted or not -- the per-device isTrusted() check above is what keeps this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters. + * Whether at least one remote device or principal is currently trusted -- the outbound gossip gate. wire-mesh-core's relay-hub broadcasts a gossiped advert to every connected hub peer with no per-recipient targeting (RelayHub.handleConnection's own "re-broadcasts each gossip frame to every other connected client"), so "advertise local agents only to allowlisted remote gateways" can only be approximated at the coarse granularity this side actually controls: don't advertise anything at all until the operator has opted in by trusting at least one remote device or principal. Once true, an advertisement still reaches every hub-connected peer, trusted or not -- the per-device isTrusted()/isTrustedFor() checks above are what keep this side from ACTING on anything an untrusted peer sends back, which is the boundary that actually matters. */ hasAny(): boolean { - return this.trusted.size > 0; + return this.trusted.size > 0 || this.trustedPrincipals.size > 0; } } diff --git a/src/core/identity-store.ts b/src/core/identity-store.ts index 8ba9e645..f0f48004 100644 --- a/src/core/identity-store.ts +++ b/src/core/identity-store.ts @@ -509,32 +509,60 @@ function gatewayTrustFilePath(slot: Readonly): string { return path.join(dir, `${base}.json`); } +/** loadGatewayTrust's own return shape: every remote device-id and every remote user-principal device-id (agent-comms#187) this slot's gateway currently trusts, each lowercase hex, in insertion order. */ +export interface LoadedGatewayTrust { + devices: string[]; + principals: string[]; +} + +function isStringArray(value: unknown): value is string[] { + return ( + Array.isArray(value) && value.every((entry) => typeof entry === "string") + ); +} + /** - * Every remote device-id this slot's gateway currently trusts, lowercase hex, in insertion order. Empty if the slot has never saved a trusted set, or its gateway trust file is missing or unparseable. + * The trusted devices and principals (agent-comms#187) a slot's gateway had persisted before this call, both empty if the slot has never saved a trusted set or its gateway trust file is missing or unparseable. Reads a pre-#187 file (a bare JSON array, agent-comms#186's own original format) as devices-only with no principals -- every gateway-trust file written before principal-keyed trust existed named only bare devices, so there is nothing to migrate, just an older, narrower shape to keep reading correctly. */ -export function loadGatewayTrust(slot: Readonly): string[] { +export function loadGatewayTrust( + slot: Readonly, +): LoadedGatewayTrust { let parsed: unknown; try { parsed = JSON.parse(fs.readFileSync(gatewayTrustFilePath(slot), "utf-8")); } catch { - return []; + return { devices: [], principals: [] }; } - if (!Array.isArray(parsed)) return []; - return parsed.filter((entry): entry is string => typeof entry === "string"); + if (isStringArray(parsed)) return { devices: parsed, principals: [] }; + if (typeof parsed !== "object" || parsed === null) { + return { devices: [], principals: [] }; + } + const devices = + "devices" in parsed && isStringArray(parsed.devices) ? parsed.devices : []; + const principals = + "principals" in parsed && isStringArray(parsed.principals) + ? parsed.principals + : []; + return { devices, principals }; } /** - * Persists a slot's complete trusted-gateway device-id set, surviving a restart the same way the identity it gates alongside does. Overwrites whatever was saved before in full: GatewayTrust always calls this with its own current list() after every add/remove, so there is no per-device partial update to preserve here the way saveRoomToken preserves other rooms' tokens. + * Persists a slot's complete trusted-gateway device-id and principal-id sets (agent-comms#187 extends agent-comms#186's own original device-only persistence, per that issue's own "agent-comms#187 covers what gets stored" framing), surviving a restart the same way the identity they gate alongside does. Overwrites whatever was saved before in full: GatewayTrust always calls this with its own current list()/listPrincipals() after every add/remove/addPrincipal/removePrincipal, so there is no per-entry partial update to preserve here the way saveRoomToken preserves other rooms' tokens. */ export function saveGatewayTrust( slot: Readonly, - trusted: readonly string[], + devices: readonly string[], + principals: readonly string[], ): void { const { dir } = slotPaths(slot); fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); + const stored: LoadedGatewayTrust = { + devices: [...devices], + principals: [...principals], + }; fs.writeFileSync( gatewayTrustFilePath(slot), - `${JSON.stringify(trusted, null, 2)}\n`, + `${JSON.stringify(stored, null, 2)}\n`, { encoding: "utf-8", mode: 0o600 }, ); } diff --git a/src/core/mesh-store.ts b/src/core/mesh-store.ts index 2103d64c..ab737d4d 100644 --- a/src/core/mesh-store.ts +++ b/src/core/mesh-store.ts @@ -565,9 +565,12 @@ export class MeshStore implements CommsStore { 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); + /** 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. delegationsRemaining defaults to 0 (non-delegable, the original behaviour); a positive value admits bearerId as a user principal capable of sub-delegating to its own devices (agent-comms#187) -- see RoomLifecycle.admitAgentForDm's own doc comment. */ + async admitAgentForDm( + bearerId: string, + delegationsRemaining = 0, + ): Promise { + return this.roomLifecycle.admitAgentForDm(bearerId, delegationsRemaining); } /** 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. */ diff --git a/src/core/room-lifecycle.ts b/src/core/room-lifecycle.ts index c61565ec..c7f39995 100644 --- a/src/core/room-lifecycle.ts +++ b/src/core/room-lifecycle.ts @@ -734,8 +734,13 @@ export class RoomLifecycle { /** * 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). + * + * delegationsRemaining defaults to 0 -- the original, non-delegable behaviour, unchanged for a bearer that is just a bare device with no principal of its own. Passing a positive value admits bearerId as a user PRINCIPAL rather than a single device (agent-comms#187): the principal itself then holds enough delegation depth to mint further dm:send tokens (bearer = one of its own devices, parent = this grant) via dm-send-delegation.ts's delegateDmSendToDevice, the dm:send counterpart to how #161's device-membership tokens already let a principal admit its own devices. verifyDmSendToken needs no change to accept the result: its chain-walk already resolves rootIssuer through arbitrarily many hops back to this call's own userIdentity, regardless of how many of those hops this grant itself permits. */ - async admitAgentForDm(bearerId: string): Promise { + async admitAgentForDm( + bearerId: string, + delegationsRemaining = 0, + ): Promise { const { userIdentity, userIdentityOptions, clock } = this.deps.requireIdentity(); const tokenId = randomId(); @@ -747,8 +752,7 @@ export class RoomLifecycle { 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, + delegationsRemaining, }); if (!verdict.ok) { throw new CommsError( diff --git a/src/test/dm-send-capability-admission.integration.test.ts b/src/test/dm-send-capability-admission.integration.test.ts index 0eae6f94..289a32a5 100644 --- a/src/test/dm-send-capability-admission.integration.test.ts +++ b/src/test/dm-send-capability-admission.integration.test.ts @@ -2,14 +2,30 @@ * 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 * as fs from "node:fs"; +import * as path from "node:path"; +import { tmpdir } from "node:os"; import { test, expect } from "vitest"; -import { deviceIdFromHex } from "wire-mesh-core/domain/device-id"; +import { + deviceIdFromHex, + 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 { MeshStore } from "../core/mesh-store.js"; +import { generateIdentity } from "../core/identity.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { randomId } from "../core/random-id.js"; +import { loadOrCreateUserIdentity } from "../core/user-identity.js"; +import { DM_SEND_CAPABILITY } from "../core/dm-token-verification.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; +/** Expiry window for a delegated dm:send token minted directly in these tests (agent-comms#187) -- comfortably longer than any single test run, matching device-membership.test.ts's own TOKEN_TTL_MS convention. */ +const DELEGATED_TOKEN_TTL_MS = 60_000; + let nextPort = 20_990; function freshPort(): number { nextPort += 1; @@ -113,3 +129,104 @@ test("revoking a bearer that was never admitted is a harmless no-op", async () = await a.shutdown(); } }); + +/** B's own user-principal identity, loaded from a directory this test controls directly (rather than the throwaway one wireTestTransport would otherwise generate internally and never disclose), so a test can compute the exact "user" scope path (B's own principal device-id, hex) a dm:send grant B mints is rooted at -- agent-comms#187's own principal-keyed admission tests need this to construct a further delegation whose scope actually narrows the grant it chains from. */ +function tempUserIdentityDir(): { dir: string } { + return { + dir: fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-b-user-identity-test-"), + ), + }; +} + +test("admitting a bearer with a positive delegationsRemaining lets that bearer itself mint a further dm:send delegation (agent-comms#187)", async () => { + const bUserIdentityOptions = tempUserIdentityDir(); + const bPrincipalHex = deviceIdToHex( + (await toIdentityPort(loadOrCreateUserIdentity(bUserIdentityOptions))) + .deviceId, + ); + const clock = createSystemClock(); + + const b = new MeshStore(freshPort()); + await wireTestTransport( + b, + undefined, + undefined, + undefined, + bUserIdentityOptions, + ); + await b.init(); + + try { + // The admitted bearer is a real principal identity here, not just a bare hex string -- only the entity actually holding bearerId's own key can mint a delegation of the grant, since mintCapabilityToken's own parent-narrowing requires the child's issuer to equal the parent's bearer. + const principal = await toIdentityPort(generateIdentity()); + const bearerHex = deviceIdToHex(principal.deviceId); + + const grant = await b.admitAgentForDm(bearerHex, 1); + + const device = await toIdentityPort(generateIdentity()); + const delegated = await mintCapabilityToken({ + identity: principal, + clock, + tokenId: randomId(), + bearer: device.deviceId, + capability: DM_SEND_CAPABILITY, + scope: { kind: "user", path: bPrincipalHex }, + expires: clock.now() + DELEGATED_TOKEN_TTL_MS, + delegationsRemaining: 0, + parent: grant, + }); + + expect( + delegated.ok, + `expected the delegation to succeed, got ${JSON.stringify(delegated)}`, + ).toBe(true); + } finally { + await b.shutdown(); + } +}); + +test("admitting a bearer with no delegationsRemaining given stays non-delegable, exactly like the existing bare-device path", async () => { + const bUserIdentityOptions = tempUserIdentityDir(); + const bPrincipalHex = deviceIdToHex( + (await toIdentityPort(loadOrCreateUserIdentity(bUserIdentityOptions))) + .deviceId, + ); + const clock = createSystemClock(); + + const b = new MeshStore(freshPort()); + await wireTestTransport( + b, + undefined, + undefined, + undefined, + bUserIdentityOptions, + ); + await b.init(); + + try { + const principal = await toIdentityPort(generateIdentity()); + const bearerHex = deviceIdToHex(principal.deviceId); + + const grant = await b.admitAgentForDm(bearerHex); + + const device = await toIdentityPort(generateIdentity()); + const delegated = await mintCapabilityToken({ + identity: principal, + clock, + tokenId: randomId(), + bearer: device.deviceId, + capability: DM_SEND_CAPABILITY, + scope: { kind: "user", path: bPrincipalHex }, + expires: clock.now() + DELEGATED_TOKEN_TTL_MS, + delegationsRemaining: 0, + parent: grant, + }); + + expect(delegated.ok).toBe(false); + if (!delegated.ok) + expect(delegated.reason).toBe("delegation_exceeds_parent"); + } finally { + await b.shutdown(); + } +}); diff --git a/src/test/dm-send-delegation.test.ts b/src/test/dm-send-delegation.test.ts new file mode 100644 index 00000000..9daa6bae --- /dev/null +++ b/src/test/dm-send-delegation.test.ts @@ -0,0 +1,205 @@ +/** + * Unit tests for core/dm-send-delegation: delegateDmSendToDevice, the primitive that lets a user principal already admitted into a remote user's DM-communication scope (room-lifecycle.ts's admitAgentForDm, minted with delegationsRemaining \> 0) sub-delegate that admission to one of its own devices (agent-comms#187) -- the dm:send counterpart to device-membership.test.ts's own admitDevice/removeDevice coverage. + */ +import * as fs from "node:fs"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it, 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 { createRevocationView } from "wire-mesh-core/domain/revocation-view"; +import { generateIdentity } from "../core/identity.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { randomId } from "../core/random-id.js"; +import { + loadIssuedDmGrant, + loadOrCreateUserIdentity, +} from "../core/user-identity.js"; +import { + DM_SEND_CAPABILITY, + DM_SEND_SCOPE_KIND, + verifyDmSendToken, +} from "../core/dm-token-verification.js"; +import { delegateDmSendToDevice } from "../core/dm-send-delegation.js"; + +const TOKEN_TTL_MS = 60_000; +const NON_DELEGABLE = 0; +const ONE_HOP_DELEGABLE = 1; + +function tempDir(): string { + return fs.mkdtempSync(path.join(tmpdir(), "agent-comms-dm-delegation-test-")); +} + +/** A real, persisted user-principal identity -- delegateDmSendToDevice writes through to the same user-identity.json this identity's own key material lives in, so the fixture must actually create that file first, the same way device-membership.test.ts's own makeUser() does. */ +async function makeUser(): Promise<{ + identity: Awaited>; + userIdentityOptions: { dir: string }; +}> { + const dir = tempDir(); + const identity = await toIdentityPort(loadOrCreateUserIdentity({ dir })); + return { identity, userIdentityOptions: { dir } }; +} + +/** Alice's own root-level dm:send grant naming bearer as the admitted party -- the same shape room-lifecycle.ts's admitAgentForDm mints, built directly here so this file's own tests don't need a full MeshStore. Takes an explicit expires (rather than deriving one from clock.now() itself) so a caller can mint a child token sharing the identical expiry -- a real wall clock advances between two calls, and a child's own expires must never exceed its parent's. */ +async function mintRootGrant( + admitter: Awaited>, + bearer: Awaited>["deviceId"], + clock: Readonly>, + delegationsRemaining: number, + expires: number, +) { + const verdict = await mintCapabilityToken({ + identity: admitter, + clock, + tokenId: randomId(), + bearer, + capability: DM_SEND_CAPABILITY, + scope: { kind: DM_SEND_SCOPE_KIND, path: deviceIdToHex(admitter.deviceId) }, + expires, + delegationsRemaining, + }); + if (!verdict.ok) throw new Error("expected root grant to mint"); + return verdict.token; +} + +describe("delegateDmSendToDevice", () => { + it("mints a dm:send token bearing the device, chaining back to the remote admitting principal", async () => { + const alice = await toIdentityPort(generateIdentity()); + const { identity: bobPrincipal, userIdentityOptions } = await makeUser(); + const bobDevice = await toIdentityPort(generateIdentity()); + const clock = createSystemClock(); + const expires = clock.now() + TOKEN_TTL_MS; + + const grant = await mintRootGrant( + alice, + bobPrincipal.deviceId, + clock, + ONE_HOP_DELEGABLE, + expires, + ); + + const verdict = await delegateDmSendToDevice({ + userIdentity: bobPrincipal, + userIdentityOptions, + clock, + tokenId: randomId(), + parent: grant, + deviceId: bobDevice.deviceId, + remoteUserPrincipalDeviceId: alice.deviceId, + expires, + }); + + expect( + verdict.ok, + `expected delegation to succeed, got ${JSON.stringify(verdict)}`, + ).toBe(true); + if (!verdict.ok) return; + + const checked = await verifyDmSendToken(verdict.token, { + identity: alice, + clock, + revocation: createRevocationView(), + expectedBearer: bobDevice.deviceId, + userPrincipalDeviceId: alice.deviceId, + }); + expect(checked.ok, JSON.stringify(checked)).toBe(true); + }); + + it("records the delegated grant's token-id under the delegating principal's own store, keyed by device hex", async () => { + const alice = await toIdentityPort(generateIdentity()); + const { identity: bobPrincipal, userIdentityOptions } = await makeUser(); + const bobDevice = await toIdentityPort(generateIdentity()); + const clock = createSystemClock(); + const tokenId = randomId(); + const expires = clock.now() + TOKEN_TTL_MS; + + const grant = await mintRootGrant( + alice, + bobPrincipal.deviceId, + clock, + ONE_HOP_DELEGABLE, + expires, + ); + + const verdict = await delegateDmSendToDevice({ + userIdentity: bobPrincipal, + userIdentityOptions, + clock, + tokenId, + parent: grant, + deviceId: bobDevice.deviceId, + remoteUserPrincipalDeviceId: alice.deviceId, + expires, + }); + expect(verdict.ok).toBe(true); + + const deviceHex = deviceIdToHex(bobDevice.deviceId); + expect(loadIssuedDmGrant(userIdentityOptions, deviceHex)).toEqual(tokenId); + }); + + it("refuses when the parent grant carries no further delegation depth, and records nothing", async () => { + const alice = await toIdentityPort(generateIdentity()); + const { identity: bobPrincipal, userIdentityOptions } = await makeUser(); + const bobDevice = await toIdentityPort(generateIdentity()); + const clock = createSystemClock(); + const expires = clock.now() + TOKEN_TTL_MS; + + // The original, non-delegable admission -- exactly what admitAgentForDm mints by default. + const grant = await mintRootGrant( + alice, + bobPrincipal.deviceId, + clock, + NON_DELEGABLE, + expires, + ); + + const verdict = await delegateDmSendToDevice({ + userIdentity: bobPrincipal, + userIdentityOptions, + clock, + tokenId: randomId(), + parent: grant, + deviceId: bobDevice.deviceId, + remoteUserPrincipalDeviceId: alice.deviceId, + expires, + }); + + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe("delegation_exceeds_parent"); + const deviceHex = deviceIdToHex(bobDevice.deviceId); + expect(loadIssuedDmGrant(userIdentityOptions, deviceHex)).toBeUndefined(); + }); + + it("refuses when the caller's identity is not the parent grant's own bearer", async () => { + const alice = await toIdentityPort(generateIdentity()); + const { identity: bobPrincipal, userIdentityOptions } = await makeUser(); + const someoneElse = await toIdentityPort(generateIdentity()); + const bobDevice = await toIdentityPort(generateIdentity()); + const clock = createSystemClock(); + const expires = clock.now() + TOKEN_TTL_MS; + + const grant = await mintRootGrant( + alice, + bobPrincipal.deviceId, + clock, + ONE_HOP_DELEGABLE, + expires, + ); + + const verdict = await delegateDmSendToDevice({ + // someoneElse never received this grant -- only bobPrincipal (the parent's own bearer) may mint a delegation of it. + userIdentity: someoneElse, + userIdentityOptions, + clock, + tokenId: randomId(), + parent: grant, + deviceId: bobDevice.deviceId, + remoteUserPrincipalDeviceId: alice.deviceId, + expires, + }); + + expect(verdict.ok).toBe(false); + if (!verdict.ok) expect(verdict.reason).toBe("parent_bearer_mismatch"); + }); +}); diff --git a/src/test/dm-send-principal-delegation.integration.test.ts b/src/test/dm-send-principal-delegation.integration.test.ts new file mode 100644 index 00000000..b3fffb46 --- /dev/null +++ b/src/test/dm-send-principal-delegation.integration.test.ts @@ -0,0 +1,123 @@ +/** + * End-to-end integration test for agent-comms#187: a user principal admitted into another user's DM-communication scope with room delegation depth (admitAgentForDm) mints, via delegateDmSendToDevice, a further dm:send token naming one of its own devices as bearer -- and that device's own requestDmAccess, presenting only the delegated token, auto-admits exactly the way a directly-admitted bare device already does in dm-send-capability-admission.integration.test.ts. Proves the full "trust this whole person, not just one of their devices" flow the issue names, end to end over a real wire connection. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import { tmpdir } from "node:os"; +import { test, expect } from "vitest"; +import { + deviceIdFromHex, + deviceIdToHex, +} from "wire-mesh-core/domain/device-id"; +import { createSystemClock } from "wire-mesh-core/adapters/system-clock"; +import { MeshStore } from "../core/mesh-store.js"; +import { loadOrCreateUserIdentity } from "../core/user-identity.js"; +import { toIdentityPort } from "../core/wire-mesh-identity.js"; +import { randomId } from "../core/random-id.js"; +import { delegateDmSendToDevice } from "../core/dm-send-delegation.js"; +import { waitFor, wireTestTransport } from "./test-transport.js"; + +/** Delegated token expiry -- comfortably longer than a single test run, matching this file's sibling integration tests' own convention. */ +const DELEGATED_TOKEN_TTL_MS = 60_000; +/** The admitted principal keeps exactly one further hop of delegation depth, enough to reach one of its own devices -- the minimal case the issue itself describes ("sub-delegate to its own devices"). */ +const ONE_HOP_DELEGABLE = 1; + +let nextPort = 20_970; +function freshPort(): number { + nextPort += 1; + return nextPort; +} + +function tempUserIdentityDir(): { dir: string } { + return { + dir: fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-principal-delegation-test-"), + ), + }; +} + +test("a principal's own device, holding only a delegated dm:send token, auto-admits into the admitting user's DM scope", async () => { + const port = freshPort(); + + // Alice's own user-principal identity is controlled directly (not the throwaway one wireTestTransport would otherwise generate and never disclose), so this test can compute the exact "user" scope path a delegated token must keep naming. + const aliceUserIdentityOptions = tempUserIdentityDir(); + const alicePrincipal = await toIdentityPort( + loadOrCreateUserIdentity(aliceUserIdentityOptions), + ); + + const a = new MeshStore(port); + await wireTestTransport( + a, + undefined, + undefined, + undefined, + aliceUserIdentityOptions, + ); + await a.init(); + await a.registerAgent({ + name: "alice", + harness: "test", + cwd: "/test/alice", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + // Bob's own user-principal identity -- distinct from any bridge-slot device identity, and distinct from any of Bob's own devices below. + const bobUserIdentityOptions = tempUserIdentityDir(); + const bobPrincipal = await toIdentityPort( + loadOrCreateUserIdentity(bobUserIdentityOptions), + ); + + // One of Bob's own devices -- an ordinary bridge slot, wired and connected to Alice exactly like any other peer. + const d = new MeshStore(port); + await wireTestTransport(d); + await d.init(); + await d.registerAgent({ + name: "bobs-device", + harness: "test", + cwd: "/test/bobs-device", + pid: process.pid, + visibility: "visible", + tags: [], + }); + + await waitFor( + () => a.serialise().agents[d.peerId] !== undefined, + "alice sees bob's device", + ); + + try { + // Alice trusts Bob's own principal -- not this specific device -- with enough delegation depth for Bob to admit his own devices. + const grant = await a.admitAgentForDm( + deviceIdToHex(bobPrincipal.deviceId), + ONE_HOP_DELEGABLE, + ); + + // Bob's principal delegates that admission to this one device, out of band from Alice entirely -- Alice never learns this device's own device-id in advance. + const clock = createSystemClock(); + const delegated = await delegateDmSendToDevice({ + userIdentity: bobPrincipal, + userIdentityOptions: bobUserIdentityOptions, + clock, + tokenId: randomId(), + parent: grant, + deviceId: deviceIdFromHex(d.peerId), + remoteUserPrincipalDeviceId: alicePrincipal.deviceId, + expires: clock.now() + DELEGATED_TOKEN_TTL_MS, + }); + expect( + delegated.ok, + `expected the delegation to succeed, got ${JSON.stringify(delegated)}`, + ).toBe(true); + if (!delegated.ok) return; + + await d.requestDmAccess(a.peerId, delegated.token); + + expect(a.listPendingRoomJoins()).toEqual([]); + expect(d.listPendingRoomJoins()).toEqual([]); + } finally { + await d.shutdown(); + await a.shutdown(); + } +}); diff --git a/src/test/gateway-trust.test.ts b/src/test/gateway-trust.test.ts index 8b8b7d6d..5f27a0a8 100644 --- a/src/test/gateway-trust.test.ts +++ b/src/test/gateway-trust.test.ts @@ -142,4 +142,114 @@ describe("GatewayTrust persistence (agent-comms#186)", () => { expect(restartedB.list()).toEqual([]); }); + + // Principal persistence (agent-comms#187) -- extends #186's own device-only persistence to the second, parallel principal allowlist, per #186's own issue text naming #187 as the one that decides what gets stored. + it("persists a trusted principal, surviving a restart alongside the bare-device set", () => { + const slot = tempSlot("pi"); + const first = new GatewayTrust(slot); + first.add("aabbcc"); + first.addPrincipal("112233"); + + const restarted = new GatewayTrust(slot); + + expect(restarted.list()).toEqual(["aabbcc"]); + expect(restarted.listPrincipals()).toEqual(["112233"]); + }); + + it("persists a principal removal independently of the bare-device set", () => { + const slot = tempSlot("pi"); + const first = new GatewayTrust(slot); + first.addPrincipal("112233"); + first.addPrincipal("445566"); + first.removePrincipal("112233"); + + const restarted = new GatewayTrust(slot); + + expect(restarted.listPrincipals()).toEqual(["445566"]); + }); +}); + +describe("GatewayTrust -- principal-keyed trust (agent-comms#187)", () => { + it("trusts no principal by default", () => { + const trust = new GatewayTrust(); + expect(trust.isTrustedPrincipal("aabbcc")).toBe(false); + expect(trust.listPrincipals()).toEqual([]); + }); + + it("trusts a principal once added, independently of the bare-device set", () => { + const trust = new GatewayTrust(); + trust.addPrincipal("AABBCC"); + expect(trust.isTrustedPrincipal("aabbcc")).toBe(true); + expect(trust.listPrincipals()).toEqual(["aabbcc"]); + expect(trust.isTrusted("aabbcc")).toBe(false); + }); + + it("normalises hex case for principals the same way it does for bare devices", () => { + const trust = new GatewayTrust(); + trust.addPrincipal("AaBbCc"); + expect(trust.isTrustedPrincipal("aabbcc")).toBe(true); + expect(trust.isTrustedPrincipal("AABBCC")).toBe(true); + }); + + it("is idempotent: adding the same principal twice keeps it listed once", () => { + const trust = new GatewayTrust(); + trust.addPrincipal("aabbcc"); + trust.addPrincipal("AABBCC"); + expect(trust.listPrincipals()).toEqual(["aabbcc"]); + }); + + it("stops trusting a principal once removed", () => { + const trust = new GatewayTrust(); + trust.addPrincipal("aabbcc"); + trust.removePrincipal("AABBCC"); + expect(trust.isTrustedPrincipal("aabbcc")).toBe(false); + expect(trust.listPrincipals()).toEqual([]); + }); + + it("removing a principal that was never trusted is a safe no-op", () => { + const trust = new GatewayTrust(); + expect(() => { + trust.removePrincipal("aabbcc"); + }).not.toThrow(); + expect(trust.listPrincipals()).toEqual([]); + }); + + it("hasAny becomes true once a principal is trusted, even with no bare device ever trusted", () => { + const trust = new GatewayTrust(); + expect(trust.hasAny()).toBe(false); + trust.addPrincipal("aabbcc"); + expect(trust.hasAny()).toBe(true); + }); + + it("hasAny falls back to false once both the device and principal sets are empty again", () => { + const trust = new GatewayTrust(); + trust.addPrincipal("aabbcc"); + trust.removePrincipal("aabbcc"); + expect(trust.hasAny()).toBe(false); + }); + + describe("isTrustedFor -- deciding trust from a verified token's own bearer and chain root", () => { + it("passes a bearer that is itself directly trusted, regardless of its chain root", () => { + const trust = new GatewayTrust(); + trust.add("aabbcc"); + expect(trust.isTrustedFor("aabbcc", "ffffff")).toBe(true); + }); + + it("passes a bearer whose chain roots at a trusted principal, even though the bearer itself was never individually trusted", () => { + const trust = new GatewayTrust(); + trust.addPrincipal("ffffff"); + expect(trust.isTrustedFor("aabbcc", "ffffff")).toBe(true); + }); + + it("refuses a bearer that is neither directly trusted nor rooted at a trusted principal", () => { + const trust = new GatewayTrust(); + expect(trust.isTrustedFor("aabbcc", "ffffff")).toBe(false); + }); + + it("is case-insensitive on both the bearer and the chain-root hex", () => { + const trust = new GatewayTrust(); + trust.addPrincipal("FFFFFF"); + expect(trust.isTrustedFor("AABBCC", "ffffff")).toBe(true); + }); + }); }); diff --git a/src/test/identity-store.test.ts b/src/test/identity-store.test.ts index 8bf02455..350f5c7f 100644 --- a/src/test/identity-store.test.ts +++ b/src/test/identity-store.test.ts @@ -321,40 +321,68 @@ test("deleteGroupToken is a no-op when nothing was saved for that group path", ( test("loadGatewayTrust is empty for a slot that has never saved a trusted set", () => { const { slot } = tempSlot("pi"); - expect(loadGatewayTrust(slot)).toEqual([]); + expect(loadGatewayTrust(slot)).toEqual({ devices: [], principals: [] }); }); test("loadGatewayTrust does not require an identity to have been created first, unlike loadRoomTokens/loadGroupTokens", () => { const { slot } = tempSlot("pi"); expect(() => { - saveGatewayTrust(slot, ["aabbcc"]); + saveGatewayTrust(slot, ["aabbcc"], []); }).not.toThrow(); - expect(loadGatewayTrust(slot)).toEqual(["aabbcc"]); + expect(loadGatewayTrust(slot)).toEqual({ + devices: ["aabbcc"], + principals: [], + }); }); -test("saveGatewayTrust persists the trusted set, loadGatewayTrust reloads the same list", () => { +test("saveGatewayTrust persists the trusted device and principal sets, loadGatewayTrust reloads the same lists", () => { const { slot } = tempSlot("pi"); - saveGatewayTrust(slot, ["aabbcc", "ddeeff"]); - expect(loadGatewayTrust(slot)).toEqual(["aabbcc", "ddeeff"]); + saveGatewayTrust(slot, ["aabbcc", "ddeeff"], ["112233"]); + expect(loadGatewayTrust(slot)).toEqual({ + devices: ["aabbcc", "ddeeff"], + principals: ["112233"], + }); }); -test("saveGatewayTrust overwrites the previously saved set rather than merging with it", () => { +test("saveGatewayTrust overwrites the previously saved sets rather than merging with them", () => { const { slot } = tempSlot("pi"); - saveGatewayTrust(slot, ["aabbcc", "ddeeff"]); - saveGatewayTrust(slot, ["112233"]); - expect(loadGatewayTrust(slot)).toEqual(["112233"]); + saveGatewayTrust(slot, ["aabbcc", "ddeeff"], ["112233"]); + saveGatewayTrust(slot, ["112233"], ["445566"]); + expect(loadGatewayTrust(slot)).toEqual({ + devices: ["112233"], + principals: ["445566"], + }); }); -test("saveGatewayTrust persists an empty set, clearing whatever was saved before", () => { +test("saveGatewayTrust persists empty sets, clearing whatever was saved before", () => { const { slot } = tempSlot("pi"); - saveGatewayTrust(slot, ["aabbcc"]); - saveGatewayTrust(slot, []); - expect(loadGatewayTrust(slot)).toEqual([]); + saveGatewayTrust(slot, ["aabbcc"], ["112233"]); + saveGatewayTrust(slot, [], []); + expect(loadGatewayTrust(slot)).toEqual({ devices: [], principals: [] }); +}); + +test("loadGatewayTrust reads a pre-agent-comms#187 bare-array file as devices-only, with no principals", () => { + const { slot, dir } = tempSlot("pi"); + // Create the real gateway-trust file at its actual path first (its exact slugified name is an implementation detail), then overwrite its content with the bare-array shape every file written before principal-keyed trust existed actually used. + saveGatewayTrust(slot, [], []); + const file = fs + .readdirSync(dir) + .find((f) => f.startsWith("gateway-trust-") && f.endsWith(".json")); + if (file === undefined) throw new Error("expected a gateway-trust file"); + fs.writeFileSync( + path.join(dir, file), + `${JSON.stringify(["aabbcc", "ddeeff"])}\n`, + ); + + expect(loadGatewayTrust(slot)).toEqual({ + devices: ["aabbcc", "ddeeff"], + principals: [], + }); }); test("the gateway trust file is written with owner-only permissions", () => { const { slot, dir } = tempSlot("pi"); - saveGatewayTrust(slot, ["aabbcc"]); + saveGatewayTrust(slot, ["aabbcc"], []); const file = fs .readdirSync(dir) .find((f) => f.startsWith("gateway-trust-") && f.endsWith(".json")); @@ -366,7 +394,7 @@ test("the gateway trust file is written with owner-only permissions", () => { test("the gateway trust file is a sibling of the identity file, distinct per (harness, cwd)", () => { const { slot, dir } = tempSlot("pi"); - saveGatewayTrust(slot, ["aabbcc"]); + saveGatewayTrust(slot, ["aabbcc"], []); const file = fs .readdirSync(dir) .find((f) => f.startsWith("gateway-trust-") && f.endsWith(".json")); @@ -378,11 +406,11 @@ test("the gateway trust file is a sibling of the identity file, distinct per (ha test("loadGatewayTrust returns empty for a slot whose gateway trust file is corrupt", () => { const { slot, dir } = tempSlot("pi"); - saveGatewayTrust(slot, ["aabbcc"]); + saveGatewayTrust(slot, ["aabbcc"], []); const file = fs .readdirSync(dir) .find((f) => f.startsWith("gateway-trust-") && f.endsWith(".json")); if (file === undefined) throw new Error("expected a gateway-trust file"); fs.writeFileSync(path.join(dir, file), "not valid json{{{"); - expect(loadGatewayTrust(slot)).toEqual([]); + expect(loadGatewayTrust(slot)).toEqual({ devices: [], principals: [] }); }); diff --git a/src/test/test-transport.ts b/src/test/test-transport.ts index 639e63b4..00342380 100644 --- a/src/test/test-transport.ts +++ b/src/test/test-transport.ts @@ -12,6 +12,7 @@ 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 type { UserIdentityOptions } from "../core/user-identity.js"; import { nanoid } from "../core/nanoid.js"; import type { MeshStore } from "../core/mesh-store.js"; @@ -24,6 +25,7 @@ export async function wireTestTransport( slot?: Readonly, pendingConnectionTimeoutMs?: number, presenceReadvertiseIntervalMs?: number, + userIdentityOptions?: Readonly, ): Promise { const resolvedSlot: IdentitySlot = slot ?? { harness: "test", @@ -31,11 +33,14 @@ 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); + // 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. A caller that needs to know this store's own principal device-id ahead of time (agent-comms#187's own principal-keyed admission tests) passes an explicit userIdentityOptions naming a directory it already loaded itself, rather than this store minting one it can never be told about afterwards. + const resolvedUserIdentityOptions: Readonly = + userIdentityOptions ?? { + dir: fs.mkdtempSync( + path.join(tmpdir(), "agent-comms-test-user-identity-"), + ), + }; + const userIdentity = loadOrCreateUserIdentity(resolvedUserIdentityOptions); // 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. @@ -61,7 +66,7 @@ export async function wireTestTransport( revocation: createRevocationView(), dataStorage, userIdentity: await toIdentityPort(userIdentity), - userIdentityOptions, + userIdentityOptions: resolvedUserIdentityOptions, }); // 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) => {