From f25f3f39289adfed1963d6ee607014cfecc44767 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 18:52:48 +0100 Subject: [PATCH 1/3] feat(spec): define the request permission as manage:request A grantable, condition-bearing token presented in the manage-request's own token field; the gate is optional per verb and scope on the receiving side so a stranger's first contact still works, and the spec says plainly it protects only what is reachable through an earlier relationship. The conformance suite gains the token and a gated capability.request frame. --- conformance/frames.v1.json | 31 +++++++++++++++++++++ conformance/generate.ts | 18 +++++++++++- conformance/token-vectors.ts | 32 ++++++++++++++++++++++ conformance/tokens.v1.json | 16 +++++++++++ spec/management.cddl | 2 +- spec/protocol.cddl | 3 +- spec/registry/core-capabilities.md | 1 + spec/tokens.cddl | 1 + ts/packages/core/src/generated/protocol.ts | 1 + 9 files changed, 102 insertions(+), 3 deletions(-) diff --git a/conformance/frames.v1.json b/conformance/frames.v1.json index ba92fd89..32c8e53b 100644 --- a/conformance/frames.v1.json +++ b/conformance/frames.v1.json @@ -580,6 +580,37 @@ }, "wire_hex": "a564747970656e6d616e6167652d726571756573746573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c65746f6b656e845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059016ba86573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450030303030303030303030303030303036a6361706162696c6974796b726f6f6d3a6d656d6265726a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67636f6d6d616e64a264766572626b726f6f6d3a6d656d62657266706172616d73a164766572626c726f6f6d2e6d656d626572736a726571756573742d69640a" }, + { + "name": "manage_request_v1_capability_request_gated", + "message": { + "type": "manage-request", + "request-id": 21, + "command": { + "verb": "room:member", + "params": { + "verb": "capability.request", + "capability": "room:member" + } + }, + "scope": { + "kind": "room", + "path": "1111111111111111111111111111111111111111111111111111111111111111/general" + }, + "token": [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" + }, + {}, + { + "hex": "a86573636f7065a1646b696e6464726f6f6d6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450080808080808080808080808080808086a6361706162696c6974796e6d616e6167653a726571756573746a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7372657175657374732d6361706162696c6974796b726f6f6d3a6d656d626572" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ] + }, + "wire_hex": "a564747970656e6d616e6167652d726571756573746573636f7065a2646b696e6464726f6f6d64706174687848313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131313131312f67656e6572616c65746f6b656e845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a0590128a86573636f7065a1646b696e6464726f6f6d6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450080808080808080808080808080808086a6361706162696c6974796e6d616e6167653a726571756573746a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7372657175657374732d6361706162696c6974796b726f6f6d3a6d656d6265725840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67636f6d6d616e64a264766572626b726f6f6d3a6d656d62657266706172616d73a26476657262726361706162696c6974792e726571756573746a6361706162696c6974796b726f6f6d3a6d656d6265726a726571756573742d696415" + }, { "name": "manage_request_v1_room_join_dm", "message": { diff --git a/conformance/generate.ts b/conformance/generate.ts index ef4b004e..4da46113 100644 --- a/conformance/generate.ts +++ b/conformance/generate.ts @@ -10,7 +10,11 @@ import { type Vector, } from "@exadev/wire-mesh-conformance"; import { advertVectors } from "./adverts.ts"; -import { grantTokenVectors, roomMemberTokens } from "./token-vectors.ts"; +import { + grantTokenVectors, + manageRequestTokenVector, + roomMemberTokens, +} from "./token-vectors.ts"; import { writeVectorFiles } from "./vector-files.ts"; function wireHex(message: JsonWire): string { @@ -242,6 +246,7 @@ const tokenVectors: Vector[] = [ rootTokenVector, delegatedTokenVector, ...grantVectors, + manageRequestTokenVector(grantVectorContext), ...roomMemberVectors, roomNoticeVector, roomNoticeForwardVector, @@ -520,6 +525,17 @@ const frameVectors: Vector[] = [ token: roomMember.rootToken, }), // room.join and room.invite are deliberately ungated (no token field) -- access control is a human's explicit approval in the receiving UI, not a pre-shared token, the first verbs in this spec to work that way. This join vector uses a DM room path (the sorted device-id pair), the shape a first, tokenless contact actually needs. + // wire-mesh#324: the same capability.request ask, presented by a requester holding a manage:request permission -- manage-request-frame's own token field carries it, exactly what a receiver that has opted into gating checks. + vector("manage_request_v1_capability_request_gated", { + type: "manage-request", + "request-id": 21, + command: { + verb: "room:member", + params: { verb: "capability.request", capability: "room:member" }, + }, + scope: { kind: "room", path: `${deviceAHex}/general` }, + token: manageRequestTokenVector(grantVectorContext).message, + }), vector("manage_request_v1_room_join_dm", { type: "manage-request", "request-id": 11, diff --git a/conformance/token-vectors.ts b/conformance/token-vectors.ts index 8fc8fa9e..eb0e9875 100644 --- a/conformance/token-vectors.ts +++ b/conformance/token-vectors.ts @@ -190,3 +190,35 @@ export function roomMemberTokens(ctx: Readonly): { delegatedToken: delegated.message, }; } + +/// wire-mesh#324's request-permission root: deviceA grants deviceB the right to ASK for +/// room:member over any room (a path-less room scope is the kind's whole root, so the +/// token's scope covers any room path a request might carry). Presented in +/// manage-request-frame's own token field; the gated frame vector below embeds exactly +/// these wire bytes. +export function manageRequestTokenVector( + ctx: Readonly, +): Vector { + const vector = (name: string, message: JsonWire): Vector => ({ + name, + message, + wire_hex: ctx.wireHex(message), + }); + return vector("capability_token_v1_manage_request_root", [ + hex(ctx.protectedHeaderHex(ctx.deviceA)), + {}, + hex( + ctx.wireHex({ + "token-id": hex("08".repeat(ctx.tokenIdByteLength)), + issuer: ctx.deviceA, + "issuer-key": { alg: -7, "public-key": ctx.publicKeyEs256A }, + bearer: ctx.deviceB, + capability: "manage:request", + scope: { kind: "room" }, + expires: 1893456000000, + "requests-capability": "room:member", + }), + ), + ctx.signatureFiller, + ]); +} diff --git a/conformance/tokens.v1.json b/conformance/tokens.v1.json index 60865127..388c0fa8 100644 --- a/conformance/tokens.v1.json +++ b/conformance/tokens.v1.json @@ -82,6 +82,22 @@ ], "wire_hex": "845828a2613126613458202222222222222222222222222222222222222222222222222222222222222222a0590445aa6573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258203333333333333333333333333333333333333333333333333333333333333333666973737565725820222222222222222222222222222222222222222222222222222222222222222267657870697265731b000001b18314880068746f6b656e2d696450070707070707070707070707070707076a6361706162696c6974796c6d616e6167653a6772616e746a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd6d617574686f72697365642d62795902ef845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059027ea96573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450050505050505050505050505050505056a6361706162696c6974796c6d616e6167653a6772616e746a636f6e646974696f6e7359014681a2646b696e64636e6f74676f706572616e64a3646b696e6463616e64646c656674a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d756772616e7465642d6361706162696c6974792d6973677061796c6f616468657865633a707479657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f5657269676874a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d6a6772616e7465652d6973677061796c6f6164784032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f56a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff716772616e74732d6361706162696c69747968657865633a7074797564656c65676174696f6e732d72656d61696e696e67005840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" }, + { + "name": "capability_token_v1_manage_request_root", + "message": [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" + }, + {}, + { + "hex": "a86573636f7065a1646b696e6464726f6f6d6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450080808080808080808080808080808086a6361706162696c6974796e6d616e6167653a726571756573746a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7372657175657374732d6361706162696c6974796b726f6f6d3a6d656d626572" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + "wire_hex": "845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a0590128a86573636f7065a1646b696e6464726f6f6d6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450080808080808080808080808080808086a6361706162696c6974796e6d616e6167653a726571756573746a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7372657175657374732d6361706162696c6974796b726f6f6d3a6d656d6265725840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, { "name": "capability_token_v1_room_member_root_grant", "message": [ diff --git a/spec/management.cddl b/spec/management.cddl index 25d10f70..9e8fd6a3 100644 --- a/spec/management.cddl +++ b/spec/management.cddl @@ -40,7 +40,7 @@ manage-response-frame = { type: "manage-response", request-id: uint, outcome: ma ; same message/token-lifetime concept the plan's own section E already ; names as its own open item, rather than inventing a second TTL field. ; The outer manage-command.verb for a capability-request MUST be the capability string itself -- the same value as this map's own `capability` field below -- never a generic literal: routing by capability (the same pattern room.send/room.join already share under one shared room:member outer verb) is how a receiver's own per-capability handler knows which grant this ask is even for. -; Whether requesting a capability needs a permission of its own (wire-mesh#324, open): a request already carries an optional `token` field, and a receiver can apply its own policy to it, accepting a request that presents a token it already honours (a `group:member` token for its own group, say) and rate-limiting or blocking the rest. The open design treats the right to request as a grantable permission under the same owner-rooted model as #323, so a receiver can also delegate who may ask. Whichever way it lands, a stranger's first contact holds no token, so a receiver that wants to hear from strangers must stay reachable without one; the spec must keep saying so plainly. +; The right to request a capability is itself a permission (wire-mesh#324): `manage:request`, a grantable, condition-bearing token presented in manage-request-frame's own `token` field. The gate is OPTIONAL per verb and scope, on the receiving side: open by default, so a stranger's first contact (a first DM is a request with no token behind it) still reaches the receiver's own decision, and required exactly where a receiver opts in. This gate protects only what is reachable through an earlier relationship: a requester can only present a request-permission someone granted it, so it constrains known parties and pre-authorises a class of requesters without a human prompt; it does not close the stranger case, and a receiver that wants to hear from strangers leaves the gate open for them. Re-granting a request-permission goes through manage:grant like any other capability (`grants-capability: "manage:request"`); its own conditions (subject-mode or ordinary) bind exactly as tokens.cddl defines. $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 } diff --git a/spec/protocol.cddl b/spec/protocol.cddl index 1246a95f..439f2623 100644 --- a/spec/protocol.cddl +++ b/spec/protocol.cddl @@ -511,7 +511,7 @@ manage-response-frame = { type: "manage-response", request-id: uint, outcome: ma ; same message/token-lifetime concept the plan's own section E already ; names as its own open item, rather than inventing a second TTL field. ; The outer manage-command.verb for a capability-request MUST be the capability string itself -- the same value as this map's own `capability` field below -- never a generic literal: routing by capability (the same pattern room.send/room.join already share under one shared room:member outer verb) is how a receiver's own per-capability handler knows which grant this ask is even for. -; Whether requesting a capability needs a permission of its own (wire-mesh#324, open): a request already carries an optional `token` field, and a receiver can apply its own policy to it, accepting a request that presents a token it already honours (a `group:member` token for its own group, say) and rate-limiting or blocking the rest. The open design treats the right to request as a grantable permission under the same owner-rooted model as #323, so a receiver can also delegate who may ask. Whichever way it lands, a stranger's first contact holds no token, so a receiver that wants to hear from strangers must stay reachable without one; the spec must keep saying so plainly. +; The right to request a capability is itself a permission (wire-mesh#324): `manage:request`, a grantable, condition-bearing token presented in manage-request-frame's own `token` field. The gate is OPTIONAL per verb and scope, on the receiving side: open by default, so a stranger's first contact (a first DM is a request with no token behind it) still reaches the receiver's own decision, and required exactly where a receiver opts in. This gate protects only what is reachable through an earlier relationship: a requester can only present a request-permission someone granted it, so it constrains known parties and pre-authorises a class of requesters without a human prompt; it does not close the stranger case, and a receiver that wants to hear from strangers leaves the gate open for them. Re-granting a request-permission goes through manage:grant like any other capability (`grants-capability: "manage:request"`); its own conditions (subject-mode or ordinary) bind exactly as tokens.cddl defines. $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 } @@ -1484,6 +1484,7 @@ token-claims = { ? "valid-until": uint, ; Unix ms; bounds the lifetime of the authorised CONTENT/ACTION, distinct from `expires` (authorisation to present the token itself). A verifier MUST additionally reject when now > valid-until, independent of expires/revocation. Absent means unbounded, matching delegations-remaining's own absent-means-unbounded precedent -- no migration needed for existing tokens. ? conditions: bstr, ; bstr .cbor of a trilean predicate-node array (see https://github.com/ExaDev/trilean), opaque at this CDDL layer the same way `parent` nests another capability-token opaquely -- trilean's own Zod schemas are the shape's source of truth, not a parallel CDDL redefinition of an evolving node-kind vocabulary. A verifier obligation, not a CDDL constraint: every entry MUST evaluate to a definite `true` via trilean's evaluatePredicate, with a registered resolver for the mandatory `bearer-is`/`expires-at`/`scope-narrows`/`capability-is`/`depth-remaining` delegate systems; an indeterminate or false result for ANY entry refuses the whole token, the same "can't resolve it, refuse it" fail-closed convention already applied to an unrecognised discriminator elsewhere in this spec. This field is strictly additive: those five narrowing checks are evaluated structurally from a delegated token's own claims and its parent's, regardless of this field's contents, and no entry here can disable, relax, or override them -- `conditions` only ever adds further, issuer-chosen restrictions on top. Absent means no further restrictions beyond narrowing, exactly today's behaviour -- no migration needed for existing tokens. ? "grants-capability": capability-verb, ; on a manage:grant token: the single verb it authorises its bearer to mint. Absent means any capability verb within the token's own scope (the absent-means-unbounded convention). Combined with subject-mode conditions this is the spelling of "an admin who may hand out room:member but hold none themselves": grant manage:grant scoped to the room, grants-capability room:member, and a grantee-is condition barring the bearer itself. + ? "requests-capability": capability-verb, ; on a manage:request token (wire-mesh#324): the single verb it authorises its bearer to request. Absent means any capability verb within the token's own scope. Presented in manage-request-frame's own token field; a receiver that has opted into gating checks the verb here against the capability the request asks for, the scope against the request's scope, and the bearer against the authenticated requester. * tstr => any, ; forward-compatible extension claims } diff --git a/spec/registry/core-capabilities.md b/spec/registry/core-capabilities.md index a53877d8..54db26d6 100644 --- a/spec/registry/core-capabilities.md +++ b/spec/registry/core-capabilities.md @@ -14,6 +14,7 @@ Append-only. A bare `subsystem:action` capability verb is spec-owned and matched | `relay:use` | `node` | `core/management` | active | | `manage:revoke` | any — narrows to the target token's own scope kind | `core/management` | active | | `manage:grant` | any — must narrow the minted token's own scope; `grants-capability` names the verb it may mint (absent: any) | `core/management` | active | +| `manage:request` | any — must cover the requested capability's own scope; `requests-capability` names the verb it may ask for (absent: any) | `core/management` | active | | `bulk:write` | any — left open to the calling domain (e.g. `folder` for a `core/exec` stdout capture) | `core/bulk` | active | | `bulk:read` | any — left open to the calling domain | `core/bulk` | active | | `group:member` | `group` | `core/management` | active | diff --git a/spec/tokens.cddl b/spec/tokens.cddl index 4d5517b0..d2992a2d 100644 --- a/spec/tokens.cddl +++ b/spec/tokens.cddl @@ -117,6 +117,7 @@ token-claims = { ? "valid-until": uint, ; Unix ms; bounds the lifetime of the authorised CONTENT/ACTION, distinct from `expires` (authorisation to present the token itself). A verifier MUST additionally reject when now > valid-until, independent of expires/revocation. Absent means unbounded, matching delegations-remaining's own absent-means-unbounded precedent -- no migration needed for existing tokens. ? conditions: bstr, ; bstr .cbor of a trilean predicate-node array (see https://github.com/ExaDev/trilean), opaque at this CDDL layer the same way `parent` nests another capability-token opaquely -- trilean's own Zod schemas are the shape's source of truth, not a parallel CDDL redefinition of an evolving node-kind vocabulary. A verifier obligation, not a CDDL constraint: every entry MUST evaluate to a definite `true` via trilean's evaluatePredicate, with a registered resolver for the mandatory `bearer-is`/`expires-at`/`scope-narrows`/`capability-is`/`depth-remaining` delegate systems; an indeterminate or false result for ANY entry refuses the whole token, the same "can't resolve it, refuse it" fail-closed convention already applied to an unrecognised discriminator elsewhere in this spec. This field is strictly additive: those five narrowing checks are evaluated structurally from a delegated token's own claims and its parent's, regardless of this field's contents, and no entry here can disable, relax, or override them -- `conditions` only ever adds further, issuer-chosen restrictions on top. Absent means no further restrictions beyond narrowing, exactly today's behaviour -- no migration needed for existing tokens. ? "grants-capability": capability-verb, ; on a manage:grant token: the single verb it authorises its bearer to mint. Absent means any capability verb within the token's own scope (the absent-means-unbounded convention). Combined with subject-mode conditions this is the spelling of "an admin who may hand out room:member but hold none themselves": grant manage:grant scoped to the room, grants-capability room:member, and a grantee-is condition barring the bearer itself. + ? "requests-capability": capability-verb, ; on a manage:request token (wire-mesh#324): the single verb it authorises its bearer to request. Absent means any capability verb within the token's own scope. Presented in manage-request-frame's own token field; a receiver that has opted into gating checks the verb here against the capability the request asks for, the scope against the request's scope, and the bearer against the authenticated requester. * tstr => any, ; forward-compatible extension claims } diff --git a/ts/packages/core/src/generated/protocol.ts b/ts/packages/core/src/generated/protocol.ts index 4f4dc072..16d1abe6 100644 --- a/ts/packages/core/src/generated/protocol.ts +++ b/ts/packages/core/src/generated/protocol.ts @@ -397,6 +397,7 @@ export const tokenClaimsSchema = z.lazy(() => z.object({ "valid-until": z.number().int().nonnegative().optional(), "conditions": z.instanceof(Uint8Array).optional(), "grants-capability": z.lazy(() => capabilityVerbSchema).optional(), + "requests-capability": z.lazy(() => capabilityVerbSchema).optional(), }).catchall(z.unknown())); export const pingFrameSchema = z.lazy(() => z.object({ "type": z.literal("ping"), From 463f9a7f0b5ba5a685aaa99de1ac2f16a54dbd64 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 18:52:49 +0100 Subject: [PATCH 2/3] feat(rust): type requests-capability and verify manage:request tokens --- rust/crates/wire-mesh-core/src/domain/room.rs | 2 ++ .../src/domain/room_token_verification.rs | 2 ++ .../wire-mesh-core/src/domain/tokens.rs | 29 +++++++++++++++++++ rust/crates/wire-mesh-wire/src/tokens.rs | 14 +++++++++ 4 files changed, 47 insertions(+) diff --git a/rust/crates/wire-mesh-core/src/domain/room.rs b/rust/crates/wire-mesh-core/src/domain/room.rs index ec26d1fe..e0e3931b 100644 --- a/rust/crates/wire-mesh-core/src/domain/room.rs +++ b/rust/crates/wire-mesh-core/src/domain/room.rs @@ -245,6 +245,7 @@ mod tests { delegations_remaining: None, authorised_by: None, grants_capability: None, + requests_capability: None, extra: CanonicalMap::new(), }; issuer @@ -792,6 +793,7 @@ mod tests { delegations_remaining: None, authorised_by: None, grants_capability: None, + requests_capability: None, extra: CanonicalMap::new(), }; let token = owner diff --git a/rust/crates/wire-mesh-core/src/domain/room_token_verification.rs b/rust/crates/wire-mesh-core/src/domain/room_token_verification.rs index cb8877dc..23a323a9 100644 --- a/rust/crates/wire-mesh-core/src/domain/room_token_verification.rs +++ b/rust/crates/wire-mesh-core/src/domain/room_token_verification.rs @@ -182,6 +182,7 @@ mod tests { delegations_remaining: None, authorised_by: None, grants_capability: None, + requests_capability: None, extra: CanonicalMap::new(), }; issuer @@ -345,6 +346,7 @@ mod tests { delegations_remaining: None, authorised_by: None, grants_capability: None, + requests_capability: None, extra: CanonicalMap::new(), }; let token = owner diff --git a/rust/crates/wire-mesh-core/src/domain/tokens.rs b/rust/crates/wire-mesh-core/src/domain/tokens.rs index 19f93263..fe1cfe0f 100644 --- a/rust/crates/wire-mesh-core/src/domain/tokens.rs +++ b/rust/crates/wire-mesh-core/src/domain/tokens.rs @@ -648,6 +648,7 @@ mod tests { delegations_remaining: None, authorised_by: None, grants_capability: None, + requests_capability: None, extra: CanonicalMap::new(), } } @@ -675,6 +676,7 @@ mod tests { delegations_remaining: None, authorised_by: None, grants_capability: None, + requests_capability: None, extra: CanonicalMap::new(), } } @@ -2060,4 +2062,31 @@ mod tests { other => panic!("expected Valid, got {other:?}"), } } + + #[tokio::test] + async fn a_manage_request_token_verifies_as_an_ordinary_capability_token() { + // wire-mesh#324: a request-permission is just a capability token carrying the + // manage:request verb and optionally naming the verb it may ask for; the generic + // verifier treats it like any other, and the receiving-side gate (where it + // exists) is the one that reads requests-capability. + let issuer = NodeIdentity::generate_ed25519(); + let bearer = NodeIdentity::generate_ed25519(); + let mut claims = claims_for(&issuer, *bearer.device_id()); + claims.capability = CapabilityVerb("manage:request".to_owned()); + claims.requests_capability = Some(CapabilityVerb("room:member".to_owned())); + claims.scope = CapabilityScope { + kind: "room".to_owned(), + path: None, + }; + let token = mint(&issuer, &claims).await; + match verify(&issuer, &RevocationView::new(), &token).await { + TokenVerdict::Valid { claims, .. } => { + assert_eq!( + claims.requests_capability.map(|v| v.0.clone()), + Some("room:member".to_owned()) + ); + } + other => panic!("expected Valid, got {other:?}"), + } + } } diff --git a/rust/crates/wire-mesh-wire/src/tokens.rs b/rust/crates/wire-mesh-wire/src/tokens.rs index fd3b8c90..acb5044b 100644 --- a/rust/crates/wire-mesh-wire/src/tokens.rs +++ b/rust/crates/wire-mesh-wire/src/tokens.rs @@ -505,6 +505,10 @@ pub struct TokenClaims { /// On a manage:grant token: the single verb it authorises minting. /// Absent means any verb within the token's own scope. pub grants_capability: Option, + /// On a manage:request token (wire-mesh#324): the single verb it + /// authorises requesting. Absent means any verb within the token's own + /// scope. + pub requests_capability: Option, pub extra: CanonicalMap, } @@ -566,6 +570,9 @@ impl Encode<()> for TokenClaims { if let Some(grants) = &self.grants_capability { builder.push("grants-capability", &grants.0.as_str()); } + if let Some(requests) = &self.requests_capability { + builder.push("requests-capability", &requests.0.as_str()); + } for (key, value) in self.extra.iter() { let mut value_buf = Vec::new(); let mut value_enc = Encoder::new(&mut value_buf); @@ -600,6 +607,7 @@ pub(crate) fn token_claims_from(d: &mut Decoder<'_>) -> Result = None; let mut authorised_by: Option> = None; let mut grants_capability: Option = None; + let mut requests_capability: Option = None; let mut extra = CanonicalMap::new(); while let Some(key) = map.next_key(d)? { match key { @@ -623,6 +631,9 @@ pub(crate) fn token_claims_from(d: &mut Decoder<'_>) -> Result { strict::set_once(&mut grants_capability, strict::text_value(d)?)? } + "requests-capability" => { + strict::set_once(&mut requests_capability, strict::text_value(d)?)? + } other => { let value = CborValue::decode_strict(d)?; extra.insert(other.to_owned(), value)?; @@ -644,6 +655,7 @@ pub(crate) fn token_claims_from(d: &mut Decoder<'_>) -> Result Date: Thu, 1 Oct 2026 18:52:49 +0100 Subject: [PATCH 3/3] feat(core): gate a capability request on a presented manage:request token The handler opts in per capability; the gate checks the token's bearer, verb, requests-capability and scope before the domain ever sees the ask, and requestCapability can present one. Off by default. --- .../core/src/domain/capability-request.ts | 78 +++++++++- ts/packages/core/src/domain/tokens.ts | 5 + .../core/test/capability-request.unit.test.ts | 143 ++++++++++++++++++ 3 files changed, 224 insertions(+), 2 deletions(-) diff --git a/ts/packages/core/src/domain/capability-request.ts b/ts/packages/core/src/domain/capability-request.ts index 8b1b9d5b..a0ab4f59 100644 --- a/ts/packages/core/src/domain/capability-request.ts +++ b/ts/packages/core/src/domain/capability-request.ts @@ -16,7 +16,17 @@ import { import type { IncomingManageRequest, MeshSession } from "./mesh-session.js"; import type { Clock } from "../ports/clock.js"; import type { IdentityPort } from "../ports/identity.js"; -import { mintCapabilityToken } from "./tokens.js"; +import { + mintCapabilityToken, + verifyCapabilityToken, + type RevocationCheck, + type TokenVerdictReason, +} from "./tokens.js"; +import { scopeNarrows } from "./token-scope.js"; +import type { + ConditionsContext, + TokenDelegateHandler, +} from "./token-predicates.js"; const TOKEN_ID_BYTE_LENGTH = 16; @@ -67,12 +77,14 @@ export async function requestCapability( targetDevice?: DeviceId, timeoutMs?: number, validUntil?: number, + /** A manage:request token to present with the ask, for a receiver that gates requesting (wire-mesh#324). Absent keeps the request ungated, which is what a stranger's first contact must remain able to send. */ + requestToken?: CapabilityToken, ): Promise { const outcome = await session.sendManageRequest( buildCapabilityRequestCommand(capability, validUntil), scope, targetDevice, - undefined, + requestToken, timeoutMs, ); if (outcome.result !== "ok") { @@ -114,11 +126,22 @@ export interface CapabilityGrantRequestEvent { decide: (decision: Readonly) => Promise; } +/** The capability verb of a request-permission (wire-mesh#324): the right to ask for a capability, grantable and delegable like any other. */ +export const MANAGE_REQUEST_CAPABILITY = "manage:request"; + export interface CreateCapabilityRequestHandlerOptions { /** The capability this handler grants. Checked against both the incoming request's own `params.capability` field (a mismatch is refused as malformed rather than trusted -- nothing about manage-command.verb structurally guarantees params.capability agrees with it) and used as the scope this handler is willing to act on at all; a caller wanting to grant 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; identity: IdentityPort; clock: Clock; + /** The revocation view the request-permission gate (requireRequestToken) verifies a presented manage:request token against -- the same plumbing every other token verification here already takes. Required only when the gate is on. */ + revocation?: RevocationCheck; + /** Opt the receiver into the request-permission gate (wire-mesh#324): when on, a request must present a valid manage:request token whose bearer is the authenticated requester, whose requests-capability (when present) matches this handler's capability, and whose scope covers the request's own scope. Off by default so a stranger's first contact still reaches the domain's own decision. */ + requireRequestToken?: boolean; + /** Domain-specific delegate systems a presented request-permission's own conditions entries may name, forwarded verbatim to verifyCapabilityToken. */ + extraPredicateResolvers?: Readonly< + Record> + >; /** The peer device-id authenticated on this session's own connection -- the requester, and so the future bearer of any token this handler mints. One handler is constructed per session for the same reason one RoomRouter is (RoomRouterOptions.peerDevice): MeshSession exposes no way for domain code to learn the authenticated peer independently. */ bearerDevice: DeviceId; /** Receiver-side auto-reject window (wire-mesh#81): how long an incoming request may sit awaiting the domain's decision before this side responds with a real manage-error timeout rather than leaving the requester's own sendManageRequest hanging indefinitely. Ported from agent-comms' PendingConnection pattern (`wire-mesh-transport.ts`'s `pendingConnectionTimeoutMs` / `expirePendingConnection`): an unref'd timer so a stray one can never by itself keep the process alive, cleared the instant decide() is actually called so it can never fire after the request has already been answered. IncomingManageRequest exposes no requester-disconnect signal at this layer (unlike agent-comms' own transport, which owns the raw connection), so only the timeout half of that pattern is implemented here -- there is nothing to detect the requester giving up early honestly, so this module does not pretend to. */ @@ -127,6 +150,47 @@ export interface CreateCapabilityRequestHandlerOptions { onRequest: (event: Readonly) => void; } +/** The request-permission gate (wire-mesh#324): the incoming request must present a manage:request token naming this requester as bearer, matching this capability when the token names one, and covering the request's own scope. A wrong token is an ordinary manage-error, never an exception -- the same convention every other refusal here already follows. */ +async function checkRequestPermission( + incoming: Readonly, + options: Readonly, +): Promise<{ ok: true } | { ok: false; code: string }> { + if (incoming.token === undefined) { + return { ok: false, code: "token_required" }; + } + if (options.revocation === undefined) { + throw new Error( + "requireRequestToken needs a revocation view on the handler options", + ); + } + const verdict = await verifyCapabilityToken(incoming.token, { + identity: options.identity, + clock: options.clock, + revocation: options.revocation, + expectedBearer: options.bearerDevice, + ...(options.extraPredicateResolvers !== undefined + ? { extraPredicateResolvers: options.extraPredicateResolvers } + : {}), + }); + if (!verdict.ok) { + return { ok: false, code: verdict.reason satisfies TokenVerdictReason }; + } + if (verdict.claims.capability !== MANAGE_REQUEST_CAPABILITY) { + return { ok: false, code: "capability_mismatch" }; + } + const requestsCapability = verdict.claims["requests-capability"]; + if ( + requestsCapability !== undefined && + requestsCapability !== options.capability + ) { + return { ok: false, code: "capability_mismatch" }; + } + if (!scopeNarrows(verdict.claims.scope, incoming.scope)) { + return { ok: false, code: "scope_mismatch" }; + } + return { ok: true }; +} + /** * Builds a reusable handler for one capability's incoming capability-requests. The returned function is the generic counterpart to room-client.ts's own (now capability-specific) handleRoomJoin: given one IncomingManageRequest, it refuses a request for the wrong capability or a malformed payload as `{result:"error", code:"malformed"}`, refuses an already-expired request (per its own `valid-until`, wire-mesh#82) as `{result:"error", code:"expired"}` without ever invoking onRequest, otherwise arms the receiver-side timeout described on CreateCapabilityRequestHandlerOptions.timeoutMs and calls onRequest so the domain can accept (minting a fresh token via mintCapabilityToken and responding `{result:"ok", "granted-token": token, ...extensions}`) or reject (an ordinary manage-error) via the event's own decide(). */ @@ -142,6 +206,16 @@ export function createCapabilityRequestHandler( return; } const params = parsed.data; + if (options.requireRequestToken === true) { + const verdict = await checkRequestPermission(incoming, options); + if (!verdict.ok) { + await incoming.respond({ + result: "error", + code: verdict.code, + }); + return; + } + } const validUntil = params["valid-until"]; if (validUntil !== undefined && validUntil <= options.clock.now()) { await incoming.respond({ result: "error", code: "expired" }); diff --git a/ts/packages/core/src/domain/tokens.ts b/ts/packages/core/src/domain/tokens.ts index 45f1719a..78447d16 100644 --- a/ts/packages/core/src/domain/tokens.ts +++ b/ts/packages/core/src/domain/tokens.ts @@ -642,6 +642,8 @@ export interface MintCapabilityTokenOptions { authorisedBy?: CapabilityToken; /** On a manage:grant token: the single verb it authorises minting. Absent means any verb within scope. */ grantsCapability?: TokenClaims["capability"]; + /** On a manage:request token (wire-mesh#324): the single verb it authorises requesting. Absent means any verb within scope. */ + requestsCapability?: TokenClaims["capability"]; } /** @@ -739,6 +741,9 @@ export async function mintCapabilityToken( ...(options.grantsCapability !== undefined ? { "grants-capability": options.grantsCapability } : {}), + ...(options.requestsCapability !== undefined + ? { "requests-capability": options.requestsCapability } + : {}), }; const payload = encodeBuf(claims); diff --git a/ts/packages/core/test/capability-request.unit.test.ts b/ts/packages/core/test/capability-request.unit.test.ts index 660a1610..824be9f6 100644 --- a/ts/packages/core/test/capability-request.unit.test.ts +++ b/ts/packages/core/test/capability-request.unit.test.ts @@ -16,11 +16,15 @@ import type { MeshSession, } from "../src/domain/mesh-session.js"; import { + MANAGE_REQUEST_CAPABILITY, buildCapabilityRequestCommand, createCapabilityRequestHandler, requestCapability, type CapabilityGrantRequestEvent, } from "../src/domain/capability-request.js"; +import { mintCapabilityToken } from "../src/domain/tokens.js"; +import { signToken } from "./tokens-fixtures.js"; +import { neverRevoked } from "./tokens-fixtures.js"; const ES256 = -7; const HOUR_MS = 3_600_000; @@ -55,12 +59,14 @@ function fakeSession(): MeshSession { function fakeIncoming( command: ManageCommand, scope: Readonly = TEST_SCOPE, + token?: CapabilityToken, ): { incoming: IncomingManageRequest; respond: ReturnType } { const respond = vi.fn(async (): Promise => Promise.resolve()); const incoming: IncomingManageRequest = { requestId: 0, command, scope, + ...(token !== undefined ? { token } : {}), respond, }; return { incoming, respond }; @@ -173,6 +179,7 @@ describe("createCapabilityRequestHandler", () => { onRequest?: (event: Readonly) => void; timeoutMs?: number; capability?: string; + requireRequestToken?: boolean; }): Promise<{ handle: (incoming: Readonly) => Promise; identity: IdentityPort; @@ -191,6 +198,12 @@ describe("createCapabilityRequestHandler", () => { clock: fixedClock(NOW_MS), bearerDevice: bearer.deviceId, timeoutMs: overrides?.timeoutMs ?? HOUR_MS, + ...(overrides?.requireRequestToken === true + ? { + requireRequestToken: true, + revocation: neverRevoked, + } + : {}), onRequest, }); return { handle, identity, bearer, onRequest }; @@ -399,6 +412,136 @@ describe("createCapabilityRequestHandler", () => { ); expect(seen.scope).toEqual(TEST_SCOPE); }); + it("a gated handler refuses a request carrying no token, without invoking onRequest", async () => { + const { handle, onRequest } = await makeHandler({ + requireRequestToken: true, + }); + const { incoming, respond } = fakeIncoming( + buildCapabilityRequestCommand(TEST_CAPABILITY), + ); + await handle(incoming); + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "token_required", + }); + expect(onRequest).not.toHaveBeenCalled(); + }); + it("a gated handler passes a request presenting a valid manage:request token through to the domain", async () => { + const { handle, identity, bearer, onRequest } = await makeHandler({ + requireRequestToken: true, + }); + const token = await mintedRequestToken(identity, bearer.deviceId); + const { incoming, respond } = fakeIncoming( + buildCapabilityRequestCommand(TEST_CAPABILITY), + TEST_SCOPE, + token, + ); + await handle(incoming); + expect(onRequest).toHaveBeenCalledTimes(1); + expect(respond).not.toHaveBeenCalled(); + }); + it("a gated handler refuses a token naming a different capability, and one whose scope does not cover the request", async () => { + const { handle, identity, bearer } = await makeHandler({ + requireRequestToken: true, + }); + const wrongVerb = await mintedRequestToken(identity, bearer.deviceId, { + requestsCapability: "exec:pty", + }); + const wrongScope = await mintedRequestToken(identity, bearer.deviceId, { + scope: { kind: "room", path: "another-room" }, + }); + const first = fakeIncoming( + buildCapabilityRequestCommand(TEST_CAPABILITY), + TEST_SCOPE, + wrongVerb, + ); + await handle(first.incoming); + expect(first.respond).toHaveBeenCalledWith({ + result: "error", + code: "capability_mismatch", + }); + const second = fakeIncoming( + buildCapabilityRequestCommand(TEST_CAPABILITY), + TEST_SCOPE, + wrongScope, + ); + await handle(second.incoming); + expect(second.respond).toHaveBeenCalledWith({ + result: "error", + code: "scope_mismatch", + }); + }); + it("a gated handler refuses an expired request-permission with the verifier's own reason", async () => { + const { handle, identity, bearer } = await makeHandler({ + requireRequestToken: true, + }); + // Signed directly rather than minted: mint refuses an already-expired token by design, and this test is about the verifier's refusal, not the minter's. + const token = await signToken(identity, { + tokenId: Uint8Array.from([1]), + bearer: bearer.deviceId, + capability: MANAGE_REQUEST_CAPABILITY, + scope: { kind: "room" }, + expires: NOW_MS - 1, + }); + const { incoming, respond } = fakeIncoming( + buildCapabilityRequestCommand(TEST_CAPABILITY), + TEST_SCOPE, + token, + ); + await handle(incoming); + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "expired", + }); + }); + it("a request that passes the gate is still the domain's to refuse, with today's denied code", async () => { + let seen: CapabilityGrantRequestEvent | undefined; + const { handle, identity, bearer } = await makeHandler({ + requireRequestToken: true, + onRequest: (event) => { + seen = event; + }, + }); + const token = await mintedRequestToken(identity, bearer.deviceId); + const { incoming, respond } = fakeIncoming( + buildCapabilityRequestCommand(TEST_CAPABILITY), + TEST_SCOPE, + token, + ); + await handle(incoming); + if (seen === undefined) throw new Error("expected one request event"); + await seen.decide({ kind: "reject", reason: "not today" }); + expect(respond).toHaveBeenCalledWith({ + result: "error", + code: "denied", + message: "not today", + }); + }); + /** A manage:request token minted by the granting identity for the requester, defaulting to exactly what the gate accepts. */ + async function mintedRequestToken( + identity: IdentityPort, + bearer: DeviceId, + overrides: Partial<{ + requestsCapability: string; + scope: CapabilityScope; + expires: number; + }> = {}, + ): Promise { + const verdict = await mintCapabilityToken({ + identity, + clock: fixedClock(NOW_MS), + tokenId: Uint8Array.from([1]), + bearer, + capability: MANAGE_REQUEST_CAPABILITY, + scope: overrides.scope ?? { kind: "room" }, + expires: overrides.expires ?? NOW_MS + HOUR_MS, + ...(overrides.requestsCapability !== undefined + ? { requestsCapability: overrides.requestsCapability } + : {}), + }); + if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; + } }); describe("round trip: requestCapability against createCapabilityRequestHandler", () => {