From ea0c859f40cfc9756203db9140f98b10ff9b812d Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 14:38:21 +0100 Subject: [PATCH 1/2] test: pin that a delegator may name itself as its delegation's bearer The right to delegate a capability therefore always includes using it, which is the property that rules out a delegate-only permission. --- .../wire-mesh-core/src/domain/tokens.rs | 17 ++++++++++++ .../token-delegation-narrowing.unit.test.ts | 27 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/rust/crates/wire-mesh-core/src/domain/tokens.rs b/rust/crates/wire-mesh-core/src/domain/tokens.rs index cd4f2120..a45e8ee3 100644 --- a/rust/crates/wire-mesh-core/src/domain/tokens.rs +++ b/rust/crates/wire-mesh-core/src/domain/tokens.rs @@ -1049,6 +1049,23 @@ mod tests { } } + #[tokio::test] + async fn a_delegator_may_name_itself_as_the_bearer_of_its_own_delegation() { + // The right to delegate a capability always includes using it: nothing + // stops a delegator minting a child that names itself, so a + // delegate-only permission would not separate the two. + let parent = NodeIdentity::generate_ed25519(); + let (_, self_delegated, claims) = mint_delegated(&parent, |child| { + child.bearer = child.issuer; + }) + .await; + assert_eq!(claims.bearer, claims.issuer); + assert!(matches!( + verify(&parent, &RevocationView::new(), &self_delegated).await, + TokenVerdict::Valid { .. } + )); + } + #[tokio::test] async fn delegation_cannot_widen_scope() { let parent = NodeIdentity::generate_ed25519(); diff --git a/ts/packages/core/test/token-delegation-narrowing.unit.test.ts b/ts/packages/core/test/token-delegation-narrowing.unit.test.ts index abbf7bdf..655b5b72 100644 --- a/ts/packages/core/test/token-delegation-narrowing.unit.test.ts +++ b/ts/packages/core/test/token-delegation-narrowing.unit.test.ts @@ -661,4 +661,31 @@ describe("verifyCapabilityToken - delegation narrowing", () => { expect(verdict.ok).toBe(true); }); + + it("accepts a delegation whose bearer is the delegator itself, so the right to delegate a capability always includes using it", async () => { + const root = await signToken(issuer, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: now + 2 * HOUR_MS, + delegationsRemaining: 1, + }); + const selfDelegated = await signDelegated(bearerIdentity, { + tokenId: nextTokenId(), + bearer: bearerDeviceId, + scope: workScope, + expires: now + HOUR_MS, + parent: root, + delegationsRemaining: 0, + }); + + const verdict = await verifyCapabilityToken(selfDelegated, { + identity: issuer, + clock: fixedClock(now), + revocation: neverRevoked, + expectedBearer: bearerDeviceId, + }); + + expect(verdict.ok).toBe(true); + }); }); From 0a2fda611436726c7fc8b8049d24e91def96d05e Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 14:38:22 +0100 Subject: [PATCH 2/2] docs(spec): record why delegating and requesting are not capabilities of their own A delegator can name itself as bearer, so a delegate-only permission would not separate delegating from using. A receiver already decides on the optional token a request carries, so a request capability would wrap tokens without closing the stranger case. --- spec/management.cddl | 1 + spec/protocol.cddl | 2 ++ spec/tokens.cddl | 1 + 3 files changed, 4 insertions(+) diff --git a/spec/management.cddl b/spec/management.cddl index 6ba0cae9..a9b0bf13 100644 --- a/spec/management.cddl +++ b/spec/management.cddl @@ -40,6 +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. +; Why there is no "may request" capability (wire-mesh#324): a request already carries an optional `token` field, and what a receiver does with it is the receiver's own policy, not a wire rule. A receiver that wants to pre-authorise a class of requesters, or to refuse the rest without putting a prompt in front of a human, accepts a request that presents a token it already honours (a `group:member` token for its own group, say) and applies its own rate limits and block lists to the rest. That reuses identity and tokens as they are, so a dedicated request capability would be a wrapper around them. It would also not close the stranger case: a requester can only hold such a token if someone granted it one, and a first contact has none, so a receiver that wants to hear from strangers must be reachable without one either way. $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 1f064514..63e367b9 100644 --- a/spec/protocol.cddl +++ b/spec/protocol.cddl @@ -511,6 +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. +; Why there is no "may request" capability (wire-mesh#324): a request already carries an optional `token` field, and what a receiver does with it is the receiver's own policy, not a wire rule. A receiver that wants to pre-authorise a class of requesters, or to refuse the rest without putting a prompt in front of a human, accepts a request that presents a token it already honours (a `group:member` token for its own group, say) and applies its own rate limits and block lists to the rest. That reuses identity and tokens as they are, so a dedicated request capability would be a wrapper around them. It would also not close the stranger case: a requester can only hold such a token if someone granted it one, and a first contact has none, so a receiver that wants to hear from strangers must be reachable without one either way. $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 } @@ -1445,6 +1446,7 @@ capability-token = cose-sign1 ; `0` on every grant it issues directly, so every member's token was issued ; by the owner and is therefore directly revocable with zero collateral, ; rather than only revocable by tearing down an entire invited subtree. +; Why there is no "may delegate but may not use" permission (wire-mesh#323): a delegation's bearer is unconstrained, so whoever may mint a child of a token may mint one naming themselves as bearer. The right to delegate a capability therefore always includes the right to use it, and a separate delegate-only capability would not separate the two unless a token could also restrict who its children name as bearer, an inheritance of restrictions that `conditions` deliberately does not have. Authority over other tokens that does not include using them is `manage:revoke`'s case, and it works because revoking confers nothing the revoker could grant itself. A holder that must not use a capability is given no token for it; the owner of the resource mints root-level grants directly. token-claims = { token-id: bstr, issuer: device-id, diff --git a/spec/tokens.cddl b/spec/tokens.cddl index 6ac81220..b1ed65e2 100644 --- a/spec/tokens.cddl +++ b/spec/tokens.cddl @@ -81,6 +81,7 @@ capability-token = cose-sign1 ; `0` on every grant it issues directly, so every member's token was issued ; by the owner and is therefore directly revocable with zero collateral, ; rather than only revocable by tearing down an entire invited subtree. +; Why there is no "may delegate but may not use" permission (wire-mesh#323): a delegation's bearer is unconstrained, so whoever may mint a child of a token may mint one naming themselves as bearer. The right to delegate a capability therefore always includes the right to use it, and a separate delegate-only capability would not separate the two unless a token could also restrict who its children name as bearer, an inheritance of restrictions that `conditions` deliberately does not have. Authority over other tokens that does not include using them is `manage:revoke`'s case, and it works because revoking confers nothing the revoker could grant itself. A holder that must not use a capability is given no token for it; the owner of the resource mints root-level grants directly. token-claims = { token-id: bstr, issuer: device-id,