Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions rust/crates/wire-mesh-core/src/domain/tokens.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
1 change: 1 addition & 0 deletions spec/management.cddl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
2 changes: 2 additions & 0 deletions spec/protocol.cddl
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions spec/tokens.cddl
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
27 changes: 27 additions & 0 deletions ts/packages/core/test/token-delegation-narrowing.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
Loading