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
76 changes: 76 additions & 0 deletions src/core/dm-send-delegation.ts
Original file line number Diff line number Diff line change
@@ -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<ArrayBuffer>;
/** 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<DelegateDmSendToDeviceOptions>,
): Promise<MintVerdict> {
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;
}
57 changes: 49 additions & 8 deletions src/core/gateway-trust.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<GatewayTrust, "isTrusted" | "hasAny">` inline at every field/parameter that takes one. */
export type GatewayTrustReader = Pick<GatewayTrust, "isTrusted" | "hasAny">;
/** 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<GatewayTrust, "isTrusted" | "hasAny" | "isTrustedPrincipal" | "isTrustedFor">` 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<string>();
private readonly trustedPrincipals = new Set<string>();
private readonly slot: Readonly<IdentitySlot> | 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<IdentitySlot>) {
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);
}
}
}

Expand All @@ -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. */
Expand All @@ -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;
}
}
44 changes: 36 additions & 8 deletions src/core/identity-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -509,32 +509,60 @@ function gatewayTrustFilePath(slot: Readonly<IdentitySlot>): 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<IdentitySlot>): string[] {
export function loadGatewayTrust(
slot: Readonly<IdentitySlot>,
): 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<IdentitySlot>,
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 },
);
}
Loading
Loading