diff --git a/spec/management.cddl b/spec/management.cddl index da42733..2cba423 100644 --- a/spec/management.cddl +++ b/spec/management.cddl @@ -44,6 +44,52 @@ $manage-command-params /= capability-request capability-request = { verb: "capability.request", capability: tstr, ? valid-until: uint, * tstr => any } capability-grant-ok = { result: "ok", "granted-token": capability-token, * tstr => any } +; The generic unsolicited push-grant counterpart to capability-request +; (wire-mesh#117): lifts core/room's own room.invite shape (the owner mints +; a token unprompted and delivers it in the request itself, since there is +; no approval round-trip to carry a grant back) out of that one domain the +; same way capability-request already lifted room.join's own pull half. No +; scope field in params, for the identical reason capability-request already +; omits one: manage-request-frame's own top-level scope already carries it. +; No invitee field either -- the recipient is already the request's own +; destination, so naming it a second time inside params could only ever +; disagree with who the request was actually sent to. The outer +; manage-command.verb MUST be the target capability itself, matching +; capability-request's own convention (a receiver's per-capability handler +; is how it knows which grant this push is even for). +; +; Verification is entirely the receiving side's own job; there is no +; protocol-level approval round trip -- a receiver responds manage-error +; only for a validation failure (malformed, the token itself doesn't +; verify), never a human "no" (an application MAY still surface the grant +; to a human before treating it as active, but that is application policy, +; not a wire obligation, mirroring room.invite's own existing design). Four +; verifier obligations: +; +; 1. granted-token.bearer MUST equal the verifying party's own device-id -- +; never a relay-asserted hint. An unsolicited push names its own +; recipient by who it was actually sent to, not by a claim inside the +; payload, so a token bearing anyone else's device-id was misdirected +; (or malicious) and must be refused, exactly as agent-comms' own +; existing room.invite handling already checks the pushed token against +; its own local identity rather than the sender's. +; 2. granted-token.capability MUST equal the outer manage-command.verb, the +; same "outer verb is the capability" convention capability-request +; already established. +; 3. granted-token MUST independently pass every ordinary +; verifyCapabilityToken obligation (signature, expiry, valid-until, +; not-before, revocation, full ancestor-chain walk) -- a recipient +; verifies an unsolicited grant exactly as it would any other token, not +; merely because it arrived wrapped in this verb. +; 4. granted-token.scope MUST equal, or be an acceptable root for, the +; enclosing manage-request-frame's own top-level scope -- the same +; equal-or-root relation tokens.cddl's own delegation narrowing already +; defines, applied here to check the pushed token against the scope the +; request claims to act on rather than against a parent in a delegation +; chain. +$manage-command-params /= capability-grant +capability-grant = { verb: "capability.grant", "granted-token": capability-token, * tstr => any } + ; Revocation — new, not present in Cascade's frozen set. Gossiped revocation ; entries let a peer check a token against a shared revocation view without ; a synchronous lookup against the issuer for every use of the token. diff --git a/spec/protocol.cddl b/spec/protocol.cddl index b58b7a6..325096a 100644 --- a/spec/protocol.cddl +++ b/spec/protocol.cddl @@ -401,6 +401,52 @@ $manage-command-params /= capability-request capability-request = { verb: "capability.request", capability: tstr, ? valid-until: uint, * tstr => any } capability-grant-ok = { result: "ok", "granted-token": capability-token, * tstr => any } +; The generic unsolicited push-grant counterpart to capability-request +; (wire-mesh#117): lifts core/room's own room.invite shape (the owner mints +; a token unprompted and delivers it in the request itself, since there is +; no approval round-trip to carry a grant back) out of that one domain the +; same way capability-request already lifted room.join's own pull half. No +; scope field in params, for the identical reason capability-request already +; omits one: manage-request-frame's own top-level scope already carries it. +; No invitee field either -- the recipient is already the request's own +; destination, so naming it a second time inside params could only ever +; disagree with who the request was actually sent to. The outer +; manage-command.verb MUST be the target capability itself, matching +; capability-request's own convention (a receiver's per-capability handler +; is how it knows which grant this push is even for). +; +; Verification is entirely the receiving side's own job; there is no +; protocol-level approval round trip -- a receiver responds manage-error +; only for a validation failure (malformed, the token itself doesn't +; verify), never a human "no" (an application MAY still surface the grant +; to a human before treating it as active, but that is application policy, +; not a wire obligation, mirroring room.invite's own existing design). Four +; verifier obligations: +; +; 1. granted-token.bearer MUST equal the verifying party's own device-id -- +; never a relay-asserted hint. An unsolicited push names its own +; recipient by who it was actually sent to, not by a claim inside the +; payload, so a token bearing anyone else's device-id was misdirected +; (or malicious) and must be refused, exactly as agent-comms' own +; existing room.invite handling already checks the pushed token against +; its own local identity rather than the sender's. +; 2. granted-token.capability MUST equal the outer manage-command.verb, the +; same "outer verb is the capability" convention capability-request +; already established. +; 3. granted-token MUST independently pass every ordinary +; verifyCapabilityToken obligation (signature, expiry, valid-until, +; not-before, revocation, full ancestor-chain walk) -- a recipient +; verifies an unsolicited grant exactly as it would any other token, not +; merely because it arrived wrapped in this verb. +; 4. granted-token.scope MUST equal, or be an acceptable root for, the +; enclosing manage-request-frame's own top-level scope -- the same +; equal-or-root relation tokens.cddl's own delegation narrowing already +; defines, applied here to check the pushed token against the scope the +; request claims to act on rather than against a parent in a delegation +; chain. +$manage-command-params /= capability-grant +capability-grant = { verb: "capability.grant", "granted-token": capability-token, * tstr => any } + ; Revocation — new, not present in Cascade's frozen set. Gossiped revocation ; entries let a peer check a token against a shared revocation view without ; a synchronous lookup against the issuer for every use of the token. diff --git a/ts/packages/core/package.json b/ts/packages/core/package.json index 4a6bf76..a898970 100644 --- a/ts/packages/core/package.json +++ b/ts/packages/core/package.json @@ -85,6 +85,10 @@ "import": "./dist/adapters/tls-transport.mjs", "require": "./dist/adapters/tls-transport.cjs" }, + "./domain/capability-grant": { + "import": "./dist/domain/capability-grant.mjs", + "require": "./dist/domain/capability-grant.cjs" + }, "./domain/capability-request": { "import": "./dist/domain/capability-request.mjs", "require": "./dist/domain/capability-request.cjs" diff --git a/ts/packages/core/src/domain/capability-grant.ts b/ts/packages/core/src/domain/capability-grant.ts new file mode 100644 index 0000000..11a6942 --- /dev/null +++ b/ts/packages/core/src/domain/capability-grant.ts @@ -0,0 +1,139 @@ +/** + * The generic, capability-agnostic capability-grant primitive (wire-mesh#117, spec/management.cddl) -- the unsolicited push counterpart to capability-request.ts's own ask/response primitive. Where capability-request lifts core/room's room.join shape (a pull: the requester asks, the owner mints and returns a grant on the same response) out of that one domain, capability-grant lifts room.invite's own shape (a push: the owner mints unprompted and delivers the grant in the request itself, since there is no approval round-trip to carry it back) the same way. core/room's own room-client.ts is the reference consumer: it builds sendRoomInvite as a thin wrapper over sendCapabilityGrant below, and wires createRoomRouter's dispatch onto createCapabilityGrantHandler for the receiving side. + * + * Two responsibilities, split the same way capability-request.ts already splits them: sendCapabilityGrant is the pushing side (a thin wrapper over MeshSession.sendManageRequest), and createCapabilityGrantHandler is the receiving side (validates the pushed token against all four of management.cddl's own capability-grant obligations, then hands the verified grant to the domain). Unlike capability-request's handler, there is no decide()/timeout mechanism here: management.cddl deliberately specifies no protocol-level approval round trip for this primitive (a receiver's manage-response reports validation success or failure only, never a human decision), so onGrant is a plain notification callback, not an event carrying its own responder. + */ + +import { + capabilityGrantSchema, + type CapabilityScope, + type CapabilityToken, + type DeviceId, + type ManageCommand, +} from "../generated/protocol.js"; +import type { + IncomingManageRequest, + ManageOutcome, + MeshSession, +} from "./mesh-session.js"; +import type { Clock } from "../ports/clock.js"; +import type { IdentityPort } from "../ports/identity.js"; +import { + scopeNarrows, + verifyCapabilityToken, + type RevocationCheck, +} from "./tokens.js"; + +/** + * Builds a capability-grant command per management.cddl. The outer `manage-command.verb` is the capability string itself, the identical convention capability-request.ts's own buildCapabilityRequestCommand already establishes -- a receiver's per-capability handler is how it knows which grant this push is even for. `params.verb` is the fixed "capability.grant" marker. No scope or invitee field: manage-request-frame's own top-level scope already carries the former, and the request's own destination already carries the latter -- naming either a second time inside params could only ever disagree with the fact it duplicates. + */ +export function buildCapabilityGrantCommand( + capability: string, + grantedToken: CapabilityToken, +): ManageCommand { + return { + verb: capability, + params: { + verb: "capability.grant", + "granted-token": grantedToken, + }, + }; +} + +/** + * Pushes an already-minted grantedToken to whichever peer this request is addressed to -- the caller mints grantedToken itself beforehand (there is no minting step here, unlike capability-request's own accept path, since this primitive carries a token the sender already decided to hand over unprompted). Resolves with the raw manage-response outcome: `{result:"ok"}` on successful validation, an ordinary manage-error otherwise (per management.cddl's own capability-grant obligations, this is a validation-failure code only, never a human "no" -- an application wanting a human-decision gate applies it on the RECEIVING side's own onGrant callback instead, exactly as core/room's own room.invite→room_invite delivery event already does today in agent-comms). + * + * scope and targetDevice forward directly to MeshSession.sendManageRequest's own identically-named parameters (the request's own top-level scope obligation 4 checks the token against, and relay routing respectively). + */ +export async function sendCapabilityGrant( + session: Readonly, + capability: string, + grantedToken: CapabilityToken, + scope: Readonly, + targetDevice?: DeviceId, +): Promise { + return session.sendManageRequest( + buildCapabilityGrantCommand(capability, grantedToken), + scope, + targetDevice, + ); +} + +/** One incoming, fully-verified capability-grant, surfaced for the domain to react to (store the token, notify a human, etc.) -- there is no decide() here because management.cddl specifies no protocol-level approval round trip for this primitive; by the time onGrant fires, the wire response has already been sent. */ +export interface CapabilityGrantEvent { + /** The capability granted -- equals both the outer manage-command.verb and the verified token's own capability claim (obligation 2 already enforced this before onGrant fires). */ + capability: string; + /** The pushed token, already confirmed to independently pass every ordinary verifyCapabilityToken obligation, name this receiver as its own bearer, and scope-match the enclosing request (obligations 1, 2, 3, and 4 respectively). Ready to use exactly as capability-request.ts's own CapabilityGrantOk["granted-token"] is on the pull side. */ + grantedToken: CapabilityToken; + /** The scope this grant's own manage-request-frame carried -- e.g. core/room's `{kind:"room", path: roomPath}` -- passed through unchanged so the domain can inspect it (a room handler reads `.path` back out) without this module needing to know its shape. */ + scope: Readonly; + /** The peer device-id authenticated on the connection this grant arrived on -- the granter, surfaced so the domain can attribute the push (e.g. core/room's own room_invite event names the inviter). Never itself checked against the token's bearer (obligation 1 checks the RECIPIENT's own identity instead -- an unsolicited push names its own recipient by where it was sent, not by who sent it). */ + granterDevice: DeviceId; +} + +export interface CreateCapabilityGrantHandlerOptions { + /** The capability this handler accepts pushed grants for. Checked against the incoming command's own outer verb (a mismatch is refused as malformed, mirroring capability-request.ts's identical capability check) and against the verified token's own capability claim (obligation 2); a caller wanting to accept grants for several distinct capabilities over one session constructs one handler per capability, the same way core/room constructs one handler for `room:member` and nothing else. */ + capability: string; + /** This receiver's own identity -- both the verification primitives every pushed token is checked with, and (via `.deviceId`) the value obligation 1 requires the token's own bearer to equal. There is no separate bearerDevice option the way CreateCapabilityRequestHandlerOptions has one: capability-request's handler mints a NEW token for whichever peer asked, so it needs that peer's device-id as an input; capability-grant's handler verifies an ALREADY-minted token against this side's own identity, which identity.deviceId already provides. */ + identity: IdentityPort; + clock: Clock; + revocation: RevocationCheck; + /** The peer device-id authenticated on this session's own connection -- the granter, surfaced on every CapabilityGrantEvent so the domain can attribute the push. Never used for verification itself (see CapabilityGrantEvent.granterDevice's own doc comment on why obligation 1 checks the recipient's identity instead). */ + granterDevice: DeviceId; + /** Called once per incoming, fully-verified capability-grant. Fires after the wire response has already been sent (see createCapabilityGrantHandler's own doc comment) -- this is a notification, not a decision point. */ + onGrant: (event: Readonly) => void; +} + +/** + * Builds a reusable handler for one capability's incoming capability-grants. The returned function checks, in order: the outer verb names this handler's own capability (otherwise `{result:"error", code:"malformed"}`, matching capability-request.ts's identical check); the params payload parses against capability-grant's own CDDL shape (otherwise "malformed"); the embedded token independently passes every ordinary verifyCapabilityToken obligation with `expectedBearer` set to this receiver's OWN identity -- obligations 1 and 3 together, since an unsolicited push must name its actual recipient as bearer and must otherwise be exactly as valid as any other token (a verification failure responds with verifyCapabilityToken's own specific TokenVerdictReason as the error code, e.g. "expired"/"revoked"/"bad_signature", rather than a single generic code, mirroring how capability-request.ts already distinguishes "expired" from "malformed"); the token's own capability claim equals this handler's capability (obligation 2, otherwise "capability_mismatch"); and the token's own scope equals or roots the enclosing request's own top-level scope (obligation 4, via tokens.ts's own scopeNarrows -- otherwise "scope_mismatch"). Only once every obligation passes does it respond `{result:"ok"}` and invoke onGrant -- there is no decide()/timeout mechanism the way createCapabilityRequestHandler has one, since management.cddl specifies no protocol-level approval round trip for this primitive at all. + */ +export function createCapabilityGrantHandler( + options: Readonly, +): (incoming: Readonly) => Promise { + return async function handleCapabilityGrant( + incoming: Readonly, + ): Promise { + if (incoming.command.verb !== options.capability) { + await incoming.respond({ result: "error", code: "malformed" }); + return; + } + const parsed = capabilityGrantSchema.safeParse(incoming.command.params); + if (!parsed.success) { + await incoming.respond({ result: "error", code: "malformed" }); + return; + } + const grantedToken = parsed.data["granted-token"]; + + const verdict = await verifyCapabilityToken(grantedToken, { + identity: options.identity, + clock: options.clock, + revocation: options.revocation, + expectedBearer: options.identity.deviceId, + }); + if (!verdict.ok) { + await incoming.respond({ result: "error", code: verdict.reason }); + return; + } + + if (verdict.claims.capability !== options.capability) { + await incoming.respond({ + result: "error", + code: "capability_mismatch", + }); + return; + } + + if (!scopeNarrows(verdict.claims.scope, incoming.scope)) { + await incoming.respond({ result: "error", code: "scope_mismatch" }); + return; + } + + await incoming.respond({ result: "ok" }); + options.onGrant({ + capability: options.capability, + grantedToken, + scope: incoming.scope, + granterDevice: options.granterDevice, + }); + }; +} diff --git a/ts/packages/core/src/domain/tokens.ts b/ts/packages/core/src/domain/tokens.ts index 81265bb..280e74a 100644 --- a/ts/packages/core/src/domain/tokens.ts +++ b/ts/packages/core/src/domain/tokens.ts @@ -75,9 +75,9 @@ function pathNarrows(childPath: string, parentPath: string): boolean { } /** - * True when childScope narrows parentScope per tokens.cddl ("each hop can only narrow authority, never widen it"): the kind must be identical (a different kind is a different kind of authority, not a narrower one), and a parent with a path requires the child to carry an equal-or-descendant path -- an absent child path means the kind's whole-scope root, which is wider than any path-narrowed parent. A parent with no path (whole-scope root) lets any child path under the same kind through. + * True when childScope narrows parentScope per tokens.cddl ("each hop can only narrow authority, never widen it"): the kind must be identical (a different kind is a different kind of authority, not a narrower one), and a parent with a path requires the child to carry an equal-or-descendant path -- an absent child path means the kind's whole-scope root, which is wider than any path-narrowed parent. A parent with no path (whole-scope root) lets any child path under the same kind through. Exported for capability-grant.ts's own obligation 4 (an unsolicited push's embedded token must equal-or-root the enclosing request's own scope), which is exactly this same narrowing relation applied outside a delegation chain. */ -function scopeNarrows( +export function scopeNarrows( parent: TokenClaims["scope"], child: TokenClaims["scope"], ): boolean { diff --git a/ts/packages/core/src/generated/protocol.ts b/ts/packages/core/src/generated/protocol.ts index 6590a73..2dd890c 100644 --- a/ts/packages/core/src/generated/protocol.ts +++ b/ts/packages/core/src/generated/protocol.ts @@ -30,7 +30,7 @@ export const handleClaimsSchema = z.lazy(() => z.object({ export const handleRecordSchema = z.lazy(() => z.lazy(() => coseSign1Schema)); export const manageCommandParamsSchema = z.lazy(() => z.union([z.union([z.lazy(() => ptySpawnSchema), z.lazy(() => ptyWriteSchema), z.lazy(() => ptyResizeSchema), z.lazy(() => ptyKillSchema), z.lazy(() => procSpawnSchema), z.lazy(() => procSignalSchema), z.lazy(() => procKillSchema), z.lazy(() => execListSchema)]), z.object({ -}).catchall(z.unknown()), z.lazy(() => capabilityRequestSchema), z.union([z.lazy(() => roomSendSchema), z.lazy(() => roomReadSchema), z.lazy(() => roomLeaveSchema), z.lazy(() => roomMembersSchema)]), z.union([z.lazy(() => roomJoinSchema), z.lazy(() => roomInviteSchema)]), z.union([z.lazy(() => webrtcOfferSchema), z.lazy(() => webrtcAnswerSchema), z.lazy(() => webrtcIceCandidateSchema)])])); +}).catchall(z.unknown()), z.lazy(() => capabilityRequestSchema), z.lazy(() => capabilityGrantSchema), z.union([z.lazy(() => roomSendSchema), z.lazy(() => roomReadSchema), z.lazy(() => roomLeaveSchema), z.lazy(() => roomMembersSchema)]), z.union([z.lazy(() => roomJoinSchema), z.lazy(() => roomInviteSchema)]), z.union([z.lazy(() => webrtcOfferSchema), z.lazy(() => webrtcAnswerSchema), z.lazy(() => webrtcIceCandidateSchema)])])); export const ptySpawnSchema = z.lazy(() => z.object({ "verb": z.literal("pty.spawn"), "shell": z.string().optional(), @@ -142,6 +142,10 @@ export const capabilityGrantOkSchema = z.lazy(() => z.object({ "result": z.literal("ok"), "granted-token": z.lazy(() => capabilityTokenSchema), }).catchall(z.unknown())); +export const capabilityGrantSchema = z.lazy(() => z.object({ + "verb": z.literal("capability.grant"), + "granted-token": z.lazy(() => capabilityTokenSchema), +}).catchall(z.unknown())); export const revocationClaimsSchema = z.lazy(() => z.object({ "token-id": z.instanceof(Uint8Array), "issuer": z.lazy(() => deviceIdSchema), @@ -377,6 +381,7 @@ export type ManageError = z.infer; export type ManageResponseFrame = z.infer; export type CapabilityRequest = z.infer; export type CapabilityGrantOk = z.infer; +export type CapabilityGrant = z.infer; export type RevocationClaims = z.infer; export type RevocationEntry = z.infer; export type RevocationAnnounceFrame = z.infer; diff --git a/ts/packages/core/test/capability-grant.test.ts b/ts/packages/core/test/capability-grant.test.ts new file mode 100644 index 0000000..b32f2e4 --- /dev/null +++ b/ts/packages/core/test/capability-grant.test.ts @@ -0,0 +1,545 @@ +import { webcrypto } from "node:crypto"; +import { cdeEncodeOptions, encode } from "cbor2"; +import { describe, expect, it, vi } from "vitest"; +import { createNodeIdentity } from "../src/adapters/node-identity.js"; +import { deviceIdToHex } from "../src/domain/device-id.js"; +import type { IdentityPort } from "../src/ports/identity.js"; +import type { Clock } from "../src/ports/clock.js"; +import type { + CapabilityScope, + CapabilityToken, + DeviceId, + ManageCommand, + TokenClaims, +} from "../src/generated/protocol.js"; +import type { + IncomingManageRequest, + ManageOutcome, + MeshSession, +} from "../src/domain/mesh-session.js"; +import type { RevocationCheck } from "../src/domain/tokens.js"; +import { mintCapabilityToken } from "../src/domain/tokens.js"; +import { + buildCapabilityGrantCommand, + createCapabilityGrantHandler, + sendCapabilityGrant, + type CapabilityGrantEvent, +} from "../src/domain/capability-grant.js"; + +const ES256 = -7; +const HOUR_MS = 3_600_000; +const NOW_MS = 1_893_456_000_000; +const TEST_CAPABILITY = "room:member"; +const TEST_SCOPE: CapabilityScope = { kind: "room", path: "some-room" }; + +async function generateEs256Identity(): Promise { + const keyPair = await webcrypto.subtle.generateKey( + { name: "ECDSA", namedCurve: "P-256" }, + true, + ["sign", "verify"], + ); + const publicKeyBytes = new Uint8Array( + await webcrypto.subtle.exportKey("raw", keyPair.publicKey), + ); + return createNodeIdentity(keyPair.privateKey, publicKeyBytes, ES256); +} + +function fixedClock(atMs: number): Clock { + return { now: () => atMs }; +} + +const neverRevoked: RevocationCheck = { + isRevoked: async () => Promise.resolve(false), +}; + +let issuedTokenIds = 0; +function nextTokenId(): Uint8Array { + issuedTokenIds += 1; + return Uint8Array.from([issuedTokenIds]); +} + +interface TokenSeed { + tokenId: Uint8Array; + bearer: DeviceId; + capability: string; + scope: CapabilityScope; + expires: number; +} + +function buf(bytes: Uint8Array | ArrayLike): Uint8Array { + return Uint8Array.from(bytes); +} + +function encodeBuf(value: unknown): Uint8Array { + return buf(encode(value, cdeEncodeOptions)); +} + +/** Signs a token directly against raw claims (bypassing mintCapabilityToken's own narrowing/expiry checks) so a test can construct a deliberately invalid token -- e.g. one bearing the wrong device-id, or one whose payload the signer never actually produced honestly. Mirrors room-client.test.ts's own identical helper. */ +async function signToken( + identity: IdentityPort, + seed: Readonly, +): Promise { + const claims: TokenClaims = { + "token-id": seed.tokenId, + issuer: identity.deviceId, + "issuer-key": identity.identityKey, + bearer: seed.bearer, + capability: seed.capability, + scope: seed.scope, + expires: seed.expires, + }; + const payload = encodeBuf(claims); + const protectedHeader = encodeBuf({}); + const toBeSigned = encodeBuf([ + "Signature1", + protectedHeader, + new Uint8Array(0), + payload, + ]); + const signature = await identity.sign(toBeSigned); + return [protectedHeader, {}, payload, signature]; +} + +function fakeSession(): MeshSession { + return { + sendManageRequest: vi.fn(), + } as unknown as MeshSession; +} + +function fakeIncoming( + command: ManageCommand, + scope: Readonly = TEST_SCOPE, +): { incoming: IncomingManageRequest; respond: ReturnType } { + const respond = vi.fn(async (): Promise => Promise.resolve()); + const incoming: IncomingManageRequest = { + requestId: 0, + command, + scope, + respond, + }; + return { incoming, respond }; +} + +describe("buildCapabilityGrantCommand", () => { + it("carries the capability string as the outer verb and the granted token in params", () => { + const token: CapabilityToken = [ + new Uint8Array(0), + {}, + null, + new Uint8Array(0), + ]; + expect(buildCapabilityGrantCommand(TEST_CAPABILITY, token)).toEqual({ + verb: TEST_CAPABILITY, + params: { verb: "capability.grant", "granted-token": token }, + } satisfies ManageCommand); + }); +}); + +describe("sendCapabilityGrant", () => { + it("sends the grant scoped to the given scope, with no targetDevice by default", async () => { + const session = fakeSession(); + const token: CapabilityToken = [ + new Uint8Array(0), + {}, + null, + new Uint8Array(0), + ]; + vi.mocked(session.sendManageRequest).mockResolvedValue({ result: "ok" }); + + const outcome = await sendCapabilityGrant( + session, + TEST_CAPABILITY, + token, + TEST_SCOPE, + ); + + expect(outcome).toEqual({ result: "ok" }); + expect(session.sendManageRequest).toHaveBeenCalledTimes(1); + const [command, scope, targetDevice] = vi.mocked(session.sendManageRequest) + .mock.calls[0] as [ManageCommand, CapabilityScope, DeviceId | undefined]; + expect(command).toEqual( + buildCapabilityGrantCommand(TEST_CAPABILITY, token), + ); + expect(scope).toEqual(TEST_SCOPE); + expect(targetDevice).toBeUndefined(); + }); + + it("forwards targetDevice when given", async () => { + const session = fakeSession(); + const token: CapabilityToken = [ + new Uint8Array(0), + {}, + null, + new Uint8Array(0), + ]; + const target = (await generateEs256Identity()).deviceId; + vi.mocked(session.sendManageRequest).mockResolvedValue({ result: "ok" }); + + await sendCapabilityGrant( + session, + TEST_CAPABILITY, + token, + TEST_SCOPE, + target, + ); + + const [, , targetDevice] = vi.mocked(session.sendManageRequest).mock + .calls[0] as [unknown, unknown, DeviceId | undefined]; + expect(targetDevice).toBe(target); + }); +}); + +describe("createCapabilityGrantHandler", () => { + async function makeHandler(overrides?: { + onGrant?: (event: Readonly) => void; + capability?: string; + }): Promise<{ + handle: (incoming: Readonly) => Promise; + granter: IdentityPort; + recipient: IdentityPort; + onGrant: ReturnType; + }> { + const granter = await generateEs256Identity(); + const recipient = await generateEs256Identity(); + const onGrant = + overrides?.onGrant !== undefined + ? vi.fn(overrides.onGrant) + : vi.fn<(event: Readonly) => void>(); + const handle = createCapabilityGrantHandler({ + capability: overrides?.capability ?? TEST_CAPABILITY, + identity: recipient, + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + granterDevice: granter.deviceId, + onGrant, + }); + return { handle, granter, recipient, onGrant }; + } + + async function validGrantToken( + granter: IdentityPort, + recipient: IdentityPort, + overrides?: Partial<{ + capability: string; + scope: CapabilityScope; + bearer: DeviceId; + expires: number; + }>, + ): Promise { + const verdict = await mintCapabilityToken({ + identity: granter, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: overrides?.bearer ?? recipient.deviceId, + capability: overrides?.capability ?? TEST_CAPABILITY, + scope: overrides?.scope ?? TEST_SCOPE, + expires: overrides?.expires ?? NOW_MS + HOUR_MS, + }); + if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; + } + + it("refuses a grant whose outer verb does not match this handler's own capability", async () => { + const { handle, onGrant, granter, recipient } = await makeHandler({ + capability: "exec:pty", + }); + const token = await validGrantToken(granter, recipient); + const { incoming, respond } = fakeIncoming( + buildCapabilityGrantCommand(TEST_CAPABILITY, token), + ); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "malformed", + }); + expect(onGrant).not.toHaveBeenCalled(); + }); + + it("refuses a malformed params payload (no granted-token field)", async () => { + const { handle, onGrant } = await makeHandler(); + const { incoming, respond } = fakeIncoming({ + verb: TEST_CAPABILITY, + params: { verb: "capability.grant" }, + }); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "malformed", + }); + expect(onGrant).not.toHaveBeenCalled(); + }); + + it("obligation 1: refuses a token whose bearer is not this handler's own identity", async () => { + const { handle, onGrant, granter, recipient } = await makeHandler(); + const someoneElse = await generateEs256Identity(); + const token = await validGrantToken(granter, recipient, { + bearer: someoneElse.deviceId, + }); + const { incoming, respond } = fakeIncoming( + buildCapabilityGrantCommand(TEST_CAPABILITY, token), + ); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "bearer_mismatch", + }); + expect(onGrant).not.toHaveBeenCalled(); + }); + + it("obligation 2: refuses a token whose own capability claim does not match the outer verb", async () => { + const { handle, onGrant, granter, recipient } = await makeHandler(); + const token = await validGrantToken(granter, recipient, { + capability: "exec:pty", + }); + // Outer verb still names the handler's own capability -- exec:pty is the token's own inner claim, mismatching it. + const command = buildCapabilityGrantCommand(TEST_CAPABILITY, token); + const { incoming, respond } = fakeIncoming(command); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "capability_mismatch", + }); + expect(onGrant).not.toHaveBeenCalled(); + }); + + it("obligation 3: refuses a token that fails ordinary verification (bad signature)", async () => { + const { handle, onGrant, granter, recipient } = await makeHandler(); + const token = await signToken(granter, { + tokenId: nextTokenId(), + bearer: recipient.deviceId, + capability: TEST_CAPABILITY, + scope: TEST_SCOPE, + expires: NOW_MS + HOUR_MS, + }); + // Corrupt the signature so it no longer verifies against granter's own key. + const tampered: CapabilityToken = [ + token[0], + token[1], + token[2], + new Uint8Array(token[3].length).fill(0), + ]; + const { incoming, respond } = fakeIncoming( + buildCapabilityGrantCommand(TEST_CAPABILITY, tampered), + ); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "bad_signature", + }); + expect(onGrant).not.toHaveBeenCalled(); + }); + + it("obligation 3: refuses an already-expired token", async () => { + const { handle, onGrant, granter, recipient } = await makeHandler(); + const token = await signToken(granter, { + tokenId: nextTokenId(), + bearer: recipient.deviceId, + capability: TEST_CAPABILITY, + scope: TEST_SCOPE, + expires: NOW_MS - 1, + }); + const { incoming, respond } = fakeIncoming( + buildCapabilityGrantCommand(TEST_CAPABILITY, token), + ); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "expired", + }); + expect(onGrant).not.toHaveBeenCalled(); + }); + + it("obligation 3: refuses a token revoked by its own issuer", async () => { + const granter = await generateEs256Identity(); + const recipient = await generateEs256Identity(); + const revokedTokenId = nextTokenId(); + const revocation: RevocationCheck = { + isRevoked: async (tokenId, issuer) => + Promise.resolve( + deviceIdToHex(issuer) === deviceIdToHex(granter.deviceId) && + tokenId.length === revokedTokenId.length && + tokenId.every((byte, index) => byte === revokedTokenId[index]), + ), + }; + const onGrant = vi.fn<(event: Readonly) => void>(); + const handle = createCapabilityGrantHandler({ + capability: TEST_CAPABILITY, + identity: recipient, + clock: fixedClock(NOW_MS), + revocation, + granterDevice: granter.deviceId, + onGrant, + }); + const token = await signToken(granter, { + tokenId: revokedTokenId, + bearer: recipient.deviceId, + capability: TEST_CAPABILITY, + scope: TEST_SCOPE, + expires: NOW_MS + HOUR_MS, + }); + const { incoming, respond } = fakeIncoming( + buildCapabilityGrantCommand(TEST_CAPABILITY, token), + ); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "revoked", + }); + expect(onGrant).not.toHaveBeenCalled(); + }); + + it("obligation 4: refuses a token whose scope does not equal-or-root the enclosing request's own scope", async () => { + const { handle, onGrant, granter, recipient } = await makeHandler(); + const token = await validGrantToken(granter, recipient, { + scope: { kind: "room", path: "some-other-room" }, + }); + const { incoming, respond } = fakeIncoming( + buildCapabilityGrantCommand(TEST_CAPABILITY, token), + TEST_SCOPE, + ); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "scope_mismatch", + }); + expect(onGrant).not.toHaveBeenCalled(); + }); + + it("obligation 4: accepts a token scoped to a root the request's own scope narrows", async () => { + const { handle, onGrant, granter, recipient } = await makeHandler(); + // The token's own scope carries no path (a whole-kind root); the request's own scope narrows it by naming a specific path -- an acceptable root per obligation 4. + const token = await validGrantToken(granter, recipient, { + scope: { kind: "room" }, + }); + const { incoming, respond } = fakeIncoming( + buildCapabilityGrantCommand(TEST_CAPABILITY, token), + TEST_SCOPE, + ); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ result: "ok" }); + expect(onGrant).toHaveBeenCalledTimes(1); + }); + + it("accepts a fully valid grant, responds ok, and invokes onGrant with the verified fields", async () => { + let seen: CapabilityGrantEvent | undefined; + const { handle, granter, recipient } = await makeHandler({ + onGrant: (event) => { + seen = event; + }, + }); + const token = await validGrantToken(granter, recipient); + const { incoming, respond } = fakeIncoming( + buildCapabilityGrantCommand(TEST_CAPABILITY, token), + TEST_SCOPE, + ); + + await handle(incoming); + + expect(respond).toHaveBeenCalledWith({ result: "ok" }); + if (seen === undefined) throw new Error("onGrant never fired"); + expect(seen.capability).toBe(TEST_CAPABILITY); + expect(seen.grantedToken).toEqual(token); + expect(seen.scope).toEqual(TEST_SCOPE); + expect(deviceIdToHex(seen.granterDevice)).toBe( + deviceIdToHex(granter.deviceId), + ); + }); + + it("responds ok before invoking onGrant -- validation success, not application handling, is what the wire response reports", async () => { + const calls: string[] = []; + const granter = await generateEs256Identity(); + const recipient = await generateEs256Identity(); + const token = await validGrantToken(granter, recipient); + const respond = vi.fn(async (): Promise => { + calls.push("respond"); + return Promise.resolve(); + }); + const handle = createCapabilityGrantHandler({ + capability: TEST_CAPABILITY, + identity: recipient, + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + granterDevice: granter.deviceId, + onGrant: () => { + calls.push("onGrant"); + }, + }); + const incoming: IncomingManageRequest = { + requestId: 0, + command: buildCapabilityGrantCommand(TEST_CAPABILITY, token), + scope: TEST_SCOPE, + respond, + }; + + await handle(incoming); + + expect(calls).toEqual(["respond", "onGrant"]); + }); +}); + +describe("round trip: sendCapabilityGrant against createCapabilityGrantHandler", () => { + it("delivers a real, independently-verifiable token end to end", async () => { + const granter = await generateEs256Identity(); + const recipient = await generateEs256Identity(); + const verdict = await mintCapabilityToken({ + identity: granter, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: recipient.deviceId, + capability: TEST_CAPABILITY, + scope: TEST_SCOPE, + expires: NOW_MS + HOUR_MS, + }); + if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + + let seen: CapabilityGrantEvent | undefined; + const handle = createCapabilityGrantHandler({ + capability: TEST_CAPABILITY, + identity: recipient, + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + granterDevice: granter.deviceId, + onGrant: (event) => { + seen = event; + }, + }); + + const session = fakeSession(); + vi.mocked(session.sendManageRequest).mockImplementation( + async (command): Promise => { + const { incoming, respond } = fakeIncoming(command, TEST_SCOPE); + await handle(incoming); + return respond.mock.calls[0]?.[0] as ManageOutcome; + }, + ); + + const outcome = await sendCapabilityGrant( + session, + TEST_CAPABILITY, + verdict.token, + TEST_SCOPE, + ); + + expect(outcome).toEqual({ result: "ok" }); + if (seen === undefined) throw new Error("onGrant never fired"); + expect(seen.grantedToken).toEqual(verdict.token); + }); +}); diff --git a/ts/packages/core/tsdown.config.ts b/ts/packages/core/tsdown.config.ts index bb66c40..07da771 100644 --- a/ts/packages/core/tsdown.config.ts +++ b/ts/packages/core/tsdown.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "src/ports/storage.ts", "src/ports/identity.ts", "src/ports/clock.ts", + "src/domain/capability-grant.ts", "src/domain/capability-request.ts", "src/domain/device-id.ts", "src/domain/handshake.ts", diff --git a/ts/packages/web-console/src/room-client.ts b/ts/packages/web-console/src/room-client.ts index 79ef207..2a7b0a9 100644 --- a/ts/packages/web-console/src/room-client.ts +++ b/ts/packages/web-console/src/room-client.ts @@ -12,7 +12,12 @@ import type { ManageOutcome, MeshSession, } from "wire-mesh-core/domain/mesh-session"; -import type { VerifyCapabilityTokenOptions } from "wire-mesh-core/domain/tokens"; +import { + mintCapabilityToken, + type VerifyCapabilityTokenOptions, +} from "wire-mesh-core/domain/tokens"; +import type { IdentityPort } from "wire-mesh-core/ports/identity"; +import type { Clock } from "wire-mesh-core/ports/clock"; import { ROOM_MEMBER_CAPABILITY, verifyRoomToken, @@ -23,6 +28,11 @@ import { type CapabilityGrantDecision, type CapabilityGrantRequestEvent, } from "wire-mesh-core/domain/capability-request"; +import { + createCapabilityGrantHandler, + sendCapabilityGrant, + type CapabilityGrantEvent, +} from "wire-mesh-core/domain/capability-grant"; const MESSAGE_ID_BYTE_LENGTH = 16; @@ -32,6 +42,14 @@ function randomMessageId(): Uint8Array { return bytes; } +const TOKEN_ID_BYTE_LENGTH = 16; + +function randomTokenId(): Uint8Array { + const bytes = new Uint8Array(TOKEN_ID_BYTE_LENGTH); + crypto.getRandomValues(bytes); + return bytes; +} + /** How long an incoming room.join may sit awaiting a human's accept/reject before this side auto-responds with a real manage-error timeout rather than leaving the joiner's own requestToJoin hanging indefinitely -- the same generous, human-approval-window reasoning agent-comms' own PENDING_CONNECTION_TIMEOUT_MINUTES uses for its equivalent connect_request decision. Overridable per RoomRouterOptions.joinRequestTimeoutMs for a caller with its own policy (e.g. a test needing a short window). */ const DEFAULT_JOIN_REQUEST_TIMEOUT_MINUTES = 5; const SECONDS_PER_MINUTE = 60; @@ -103,6 +121,53 @@ export async function requestToJoin( }; } +/** + * Mints a fresh room:member grant for invitee, ready to hand to sendRoomInvite -- the room-specific specialization of the generic mintCapabilityToken, the same way requestToJoin/handleRoomJoin already specialize capability-request.ts's own generic primitives to core/room. There is no equivalent minting call already living in this file to mirror (the accept path's own minting moved into capability-request.ts's createCapabilityRequestHandler when #78 generalized room.join, so room-client.ts itself no longer calls mintCapabilityToken anywhere else): capability-grant is a push of an ALREADY-minted token, so unlike the pull side, minting has to happen here, on the inviter's own side, before there is anything to send at all. + */ +export async function mintRoomInviteGrant( + identity: Readonly, + clock: Readonly, + roomPath: string, + invitee: DeviceId, + expires: number, + delegationsRemaining?: number, +): Promise { + const verdict = await mintCapabilityToken({ + identity, + clock, + tokenId: randomTokenId(), + bearer: invitee, + capability: ROOM_MEMBER_CAPABILITY, + scope: { kind: "room", path: roomPath }, + expires, + ...(delegationsRemaining !== undefined ? { delegationsRemaining } : {}), + }); + if (!verdict.ok) { + throw new Error( + `failed to mint an invite grant for ${roomPath} (${verdict.reason})`, + ); + } + return verdict.token; +} + +/** + * Sends room.invite, now a thin wrapper over capability-grant.ts's own generic sendCapabilityGrant (core/room's room:member grant is the reference specialization of that primitive, the push counterpart to requestToJoin's own pull-side wrapper over requestCapability) -- core/room's own room.invite is deliberately ungated the same way room.join is: the owner already IS the room's own authority to invite, with no capability check on this request itself, security living entirely in grantedToken's own verification on the receiving end (capability-grant.ts's four obligations). Resolves with the raw manage-response outcome: `{result:"ok"}` once the invitee's own side has validated the pushed token, an ordinary manage-error otherwise -- never a human "no" (an invitee wanting to decline surfaces that as its own room.leave, per agent-comms' existing room.invite/room_invite/decline design, not a wire-level rejection of the push itself). + */ +export async function sendRoomInvite( + session: Readonly, + roomPath: string, + grantedToken: CapabilityToken, + targetDevice?: DeviceId, +): Promise { + return sendCapabilityGrant( + session, + ROOM_MEMBER_CAPABILITY, + grantedToken, + { kind: "room", path: roomPath }, + targetDevice, + ); +} + export interface IncomingRoomMessage { roomPath: string; text: string; @@ -126,6 +191,14 @@ export interface RoomJoinRequestEvent { decide: (decision: Readonly) => Promise; } +export interface RoomInviteEvent { + roomPath: string; + /** The peer device-id that pushed this invite -- the room's owner, or whoever else was entrusted to invite on its behalf. */ + granterDevice: DeviceId; + /** The freshly verified room:member token this invite carried -- already confirmed to independently pass every ordinary token obligation, name this side as its own bearer, and scope-match roomPath (capability-grant.ts's own four obligations). Ready to use exactly as requestToJoin's own RoomJoinResult.token is; there is no decide() here, unlike RoomJoinRequestEvent, since core/room's own room.invite carries no approval round-trip to answer. */ + token: CapabilityToken; +} + export interface RoomRouterOptions extends VerifyCapabilityTokenOptions { /** The device-id authenticated as this session's own peer -- MeshSession exposes no way for this module to learn it independently (see webrtc-negotiation.ts's own authorizeIncomingOffer for the same limitation on a pathless scope); the caller already knows this by the time room-client machinery attaches to a session (it just negotiated or accepted the very connection the session runs over). */ peerDevice: DeviceId; @@ -140,10 +213,12 @@ export interface RoomRouterHandlers { onMessage?: (message: Readonly) => void; /** Called for an incoming, deliberately ungated room.join, for a human to accept or reject via the given event's own decide(). */ onJoinRequest?: (event: Readonly) => void; + /** Called for an incoming, verified room.invite -- there is no decision to make (unlike onJoinRequest): by the time this fires, capability-grant.ts's own handler has already responded ok on the wire, so this is purely a notification for the domain to act on (persist the token, surface a UI notice, etc.). Omit for a router that only ever handles room.send/room.join -- an incoming room.invite is then refused with `unsupported_verb`, the same precondition handleRoomJoin already applies to onJoinRequest. */ + onRoomInvite?: (event: Readonly) => void; } /** - * The one consumer of session.incomingManageRequests for every core/room verb this console speaks, dispatching room.send to onMessage (after verifying the presented token against all six of core/room's obligations -- an unauthorized or malformed request is refused, an ordinary manage-error, and never reaches the caller) and room.join to onJoinRequest (deliberately ungated, per core/room's own design: access control lives entirely in the human decision behind decide(), not a token check on the request itself). A single shared consumer, not two independent ones, because two concurrent `for await` loops over the same incomingManageRequests would race for its items -- exactly the "shared request router... future work for whenever a second consumer actually exists" gap webrtc-negotiation.ts's own consumeIncoming already flags, now arrived. Runs for the lifetime of the session; a verb this router doesn't recognise is left unanswered rather than misrouted, matching every other domain's own convention in this package. + * The one consumer of session.incomingManageRequests for every core/room verb this console speaks, dispatching room.send to onMessage (after verifying the presented token against all six of core/room's obligations -- an unauthorized or malformed request is refused, an ordinary manage-error, and never reaches the caller), room.join to onJoinRequest (deliberately ungated, per core/room's own design: access control lives entirely in the human decision behind decide(), not a token check on the request itself), and room.invite to onRoomInvite (also ungated on the request itself, per the identical reasoning -- security instead lives entirely in the pushed token's own verification against capability-grant.ts's four obligations, with no decision to make once that passes). A single shared consumer, not several independent ones, because concurrent `for await` loops over the same incomingManageRequests would race for its items -- exactly the "shared request router... future work for whenever a second consumer actually exists" gap webrtc-negotiation.ts's own consumeIncoming already flags, now arrived. Runs for the lifetime of the session; a verb this router doesn't recognise is left unanswered rather than misrouted, matching every other domain's own convention in this package. */ export function createRoomRouter( session: Readonly, @@ -253,6 +328,42 @@ export function createRoomRouter( await handleCapabilityGrantRequest(incoming); } + const handleCapabilityGrant = createCapabilityGrantHandler({ + capability: ROOM_MEMBER_CAPABILITY, + identity: options.identity, + clock: options.clock, + revocation: options.revocation, + granterDevice: options.peerDevice, + onGrant(event: Readonly): void { + const roomPath = event.scope.path; + const onRoomInvite = handlers.onRoomInvite; + // Both unreachable in practice -- handleRoomInvite below already refuses (missing_scope_path/unsupported_verb, matching this router's own pre-rewiring codes exactly) before ever calling this handler when either precondition fails. Kept because CapabilityGrantEvent's own scope/handlers types don't encode either precondition structurally, so TS cannot narrow across this callback boundary on its own -- the same reasoning adaptRoomJoinDecision's own onRequest callback above already documents for the identical shape of guard. + if (roomPath === undefined || onRoomInvite === undefined) { + return; + } + onRoomInvite({ + roomPath, + granterDevice: event.granterDevice, + token: event.grantedToken, + }); + }, + }); + + async function handleRoomInvite( + incoming: Readonly, + ): Promise { + const roomPath = incoming.scope.path; + if (roomPath === undefined) { + await incoming.respond({ result: "error", code: "missing_scope_path" }); + return; + } + if (handlers.onRoomInvite === undefined) { + await incoming.respond({ result: "error", code: "unsupported_verb" }); + return; + } + await handleCapabilityGrant(incoming); + } + void (async () => { for await (const incoming of session.incomingManageRequests) { if (incoming.command.verb !== ROOM_MEMBER_CAPABILITY) { @@ -266,6 +377,8 @@ export function createRoomRouter( await handleRoomSend(incoming, params); } else if (params.verb === "capability.request") { await handleRoomJoin(incoming); + } else if (params.verb === "capability.grant") { + await handleRoomInvite(incoming); } } })(); diff --git a/ts/packages/web-console/test/room-client.test.ts b/ts/packages/web-console/test/room-client.test.ts index b2c1132..a108d1d 100644 --- a/ts/packages/web-console/test/room-client.test.ts +++ b/ts/packages/web-console/test/room-client.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from "vitest"; -import { cdeEncodeOptions, encode } from "cbor2"; +import { cdeDecodeOptions, cdeEncodeOptions, decode, encode } from "cbor2"; import type { CapabilityScope, CapabilityToken, @@ -7,7 +7,10 @@ import type { ManageCommand, TokenClaims, } from "wire-mesh-core/generated/protocol"; -import { roomJoinOkSchema } from "wire-mesh-core/generated/protocol"; +import { + roomJoinOkSchema, + tokenClaimsSchema, +} from "wire-mesh-core/generated/protocol"; import type { IdentityPort } from "wire-mesh-core/ports/identity"; import type { Clock } from "wire-mesh-core/ports/clock"; import type { RevocationCheck } from "wire-mesh-core/domain/tokens"; @@ -23,9 +26,12 @@ import { createWebCryptoIdentity } from "../src/adapters/web-crypto-identity.js" import { buildRoomSendCommand, createRoomRouter, + mintRoomInviteGrant, requestToJoin, + sendRoomInvite, sendRoomMessage, type IncomingRoomMessage, + type RoomInviteEvent, type RoomJoinRequestEvent, } from "../src/room-client.js"; @@ -208,6 +214,55 @@ describe("requestToJoin", () => { }); }); +describe("mintRoomInviteGrant", () => { + it("mints a token bearing invitee, scoped to roomPath under room:member", async () => { + const owner = await createWebCryptoIdentity(); + const invitee = await createWebCryptoIdentity(); + + const token = await mintRoomInviteGrant( + owner, + fixedClock(NOW_MS), + ROOM_PATH, + invitee.deviceId, + NOW_MS + HOUR_MS, + ); + + const payload = token[2]; + if (payload === null) throw new Error("expected a payload"); + const claims = tokenClaimsSchema.parse(decode(payload, cdeDecodeOptions)); + expect(deviceIdToHex(claims.bearer)).toBe(deviceIdToHex(invitee.deviceId)); + expect(claims.capability).toBe(ROOM_MEMBER_CAPABILITY); + expect(claims.scope).toEqual({ kind: "room", path: ROOM_PATH }); + expect(claims.expires).toBe(NOW_MS + HOUR_MS); + }); +}); + +describe("sendRoomInvite", () => { + it("pushes the given token scoped to roomPath under room:member", async () => { + const { session } = fakeSession(); + const token: CapabilityToken = [ + new Uint8Array(0), + {}, + null, + new Uint8Array(0), + ]; + vi.mocked(session.sendManageRequest).mockResolvedValue({ result: "ok" }); + + const outcome = await sendRoomInvite(session, ROOM_PATH, token); + + expect(outcome).toEqual({ result: "ok" }); + expect(session.sendManageRequest).toHaveBeenCalledTimes(1); + const [command, scope, targetDevice] = vi.mocked(session.sendManageRequest) + .mock.calls[0] as [ManageCommand, CapabilityScope, DeviceId | undefined]; + expect(command).toEqual({ + verb: ROOM_MEMBER_CAPABILITY, + params: { verb: "capability.grant", "granted-token": token }, + } satisfies ManageCommand); + expect(scope).toEqual({ kind: "room", path: ROOM_PATH }); + expect(targetDevice).toBeUndefined(); + }); +}); + function fakeIncomingRoomSend( token: CapabilityToken | undefined, params: Record = {}, @@ -407,4 +462,152 @@ describe("createRoomRouter", () => { }); }); }); + + it("delivers a validly verified room.invite to onRoomInvite, and responds ok", async () => { + const owner = await createWebCryptoIdentity(); + const invitee = await createWebCryptoIdentity(); + const roomPath = ownerNamedRoomPath( + deviceIdToHex(owner.deviceId), + "general", + ); + const grantedToken = await mintRoomInviteGrant( + owner, + fixedClock(NOW_MS), + roomPath, + invitee.deviceId, + NOW_MS + HOUR_MS, + ); + const { session, push } = fakeSession(); + const onRoomInvite = vi.fn<(event: Readonly) => void>(); + createRoomRouter( + session, + { + identity: invitee, + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + peerDevice: owner.deviceId, + }, + { onRoomInvite }, + ); + + const respond = vi.fn(async (): Promise => Promise.resolve()); + const incoming: IncomingManageRequest = { + requestId: 3, + command: { + verb: ROOM_MEMBER_CAPABILITY, + params: { verb: "capability.grant", "granted-token": grantedToken }, + }, + scope: { kind: "room", path: roomPath }, + respond, + }; + push(incoming); + + await vi.waitFor(() => { + expect(respond).toHaveBeenCalledWith({ result: "ok" }); + }); + expect(onRoomInvite).toHaveBeenCalledTimes(1); + expect(onRoomInvite).toHaveBeenCalledWith({ + roomPath, + granterDevice: owner.deviceId, + token: grantedToken, + }); + }); + + it("refuses a room.invite carrying a token bearing someone else, and never calls onRoomInvite", async () => { + const owner = await createWebCryptoIdentity(); + const invitee = await createWebCryptoIdentity(); + const someoneElse = await createWebCryptoIdentity(); + const roomPath = ownerNamedRoomPath( + deviceIdToHex(owner.deviceId), + "general", + ); + const misdirectedToken = await mintRoomInviteGrant( + owner, + fixedClock(NOW_MS), + roomPath, + someoneElse.deviceId, + NOW_MS + HOUR_MS, + ); + const { session, push } = fakeSession(); + const onRoomInvite = vi.fn<(event: Readonly) => void>(); + createRoomRouter( + session, + { + identity: invitee, + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + peerDevice: owner.deviceId, + }, + { onRoomInvite }, + ); + + const respond = vi.fn(async (): Promise => Promise.resolve()); + const incoming: IncomingManageRequest = { + requestId: 4, + command: { + verb: ROOM_MEMBER_CAPABILITY, + params: { + verb: "capability.grant", + "granted-token": misdirectedToken, + }, + }, + scope: { kind: "room", path: roomPath }, + respond, + }; + push(incoming); + + await vi.waitFor(() => { + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "bearer_mismatch", + }); + }); + expect(onRoomInvite).not.toHaveBeenCalled(); + }); + + it("refuses a room.invite when this router has no onRoomInvite handler registered", async () => { + const owner = await createWebCryptoIdentity(); + const invitee = await createWebCryptoIdentity(); + const roomPath = ownerNamedRoomPath( + deviceIdToHex(owner.deviceId), + "general", + ); + const grantedToken = await mintRoomInviteGrant( + owner, + fixedClock(NOW_MS), + roomPath, + invitee.deviceId, + NOW_MS + HOUR_MS, + ); + const { session, push } = fakeSession(); + createRoomRouter( + session, + { + identity: invitee, + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + peerDevice: owner.deviceId, + }, + {}, + ); + + const respond = vi.fn(async (): Promise => Promise.resolve()); + const incoming: IncomingManageRequest = { + requestId: 5, + command: { + verb: ROOM_MEMBER_CAPABILITY, + params: { verb: "capability.grant", "granted-token": grantedToken }, + }, + scope: { kind: "room", path: roomPath }, + respond, + }; + push(incoming); + + await vi.waitFor(() => { + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "unsupported_verb", + }); + }); + }); });