From a285c54f648598db764a6909fd5e09c5e7bfbea4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 17:42:02 +0100 Subject: [PATCH 1/8] feat(spec): define the grant permission and subject-mode conditions A manage:grant capability authorises its bearer to mint other capability tokens; the mint cites its authoriser through a new authorised-by claim whose obligations include evaluating the authoriser's conditions against the minted child's claims, so an issuer can bar the holder from granting to itself. The chain-root rule is stated for every scope kind, and the registry gains manage:grant. --- spec/protocol.cddl | 50 +++++++++++++++++++++- spec/registry/core-capabilities.md | 3 ++ spec/room.cddl | 4 +- spec/tokens.cddl | 46 +++++++++++++++++++- ts/packages/core/src/generated/protocol.ts | 2 + 5 files changed, 101 insertions(+), 4 deletions(-) diff --git a/spec/protocol.cddl b/spec/protocol.cddl index 67663860..1246a95f 100644 --- a/spec/protocol.cddl +++ b/spec/protocol.cddl @@ -716,7 +716,9 @@ topology-get = { verb: "topology.get" } ; Two shapes, two different trust roots, for a real security reason: ; ; - `/` (a named, persistent room): the acceptable -; root is the owner named in the path. A member honours another member's +; root is the owner named in the path. These two shapes are the room +; instances of the general chain-root rule tokens.cddl states, which also +; governs tokens minted under a grant-capability's authorised-by link. A member honours another member's ; token because that token's own chain terminates at the owner, and the ; verifying member already accepted that owner's authority the moment it ; accepted its own membership token. @@ -1446,7 +1448,27 @@ 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. -; Today's behaviour, pending the grant-permission design (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, as the model stands, includes the right to use it. `conditions` on a token are evaluated against that token's own claims and deliberately do not inherit, so nothing today constrains who a minted child may name as bearer. Whether a grant-capability (grantability as a permission of its own, rooted at the resource's owner) whose trilean conditions bind the grants it authorises, letting an issuer bar its holder from granting to itself among other constraints, should close that is the open design in #323. +; The right to delegate is itself a capability (wire-mesh#323): `manage:grant`. +; A grant-capability token authorises its bearer to mint other capability +; tokens; the minted token cites the grant-capability that authorised it via +; `authorised-by`, whose verifier obligations (below) include evaluating the +; authoriser's `conditions` against the MINTED child's own claims. That +; child-binding evaluation is what makes "may grant but may not grant to +; itself" expressible: the pre-#323 argument ("a delegator can always name +; itself as bearer, so delegating includes using") held only while conditions +; could not see the child, and subject-mode evaluation closes exactly that +; gap. `delegations-remaining` remains as the hard depth cap, counting hops +; across BOTH link kinds: a child's value MUST be strictly less than the value +; on the token above it, whichever link joins them. +; +; Delegation chains terminate at the resource's owner. For a scope kind whose +; path encodes an owner (room's owner-named paths, room.cddl) the acceptable +; root is that owner. For a scope kind with no owner in the path (node, +; folder, group) the acceptable root is the verifying device itself, mirroring +; the DM rule: the verifier's own acceptance of the root is what makes a +; grant over a verifier-local resource meaningful. A domain needing a broader +; root for its own kind defines its own rule, the way core/room did; nothing +; here requires every kind to have one before the rule applies where it can. token-claims = { token-id: bstr, issuer: device-id, @@ -1457,12 +1479,36 @@ token-claims = { expires: uint, ; Unix ms ? not-before: uint, ? parent: bstr, ; bstr .cbor capability-token — a fully self-contained, nested COSE_Sign1 of the parent token; the recursive delegation chain. A verifier walking this chain MUST also check every ancestor's own token-id against the revocation view (management.cddl's revocation-entry) -- revoking one ancestor revokes everything delegated beneath it, not just a leaf's own token-id. + ? "authorised-by": bstr, ; bstr .cbor capability-token -- the grant-capability that authorised this token's MINT (wire-mesh#323), a different link from `parent`: parent means "this token narrows that one" (same verb, narrower authority), authorised-by means "that token permitted this one to exist". Verifier obligations: decode and independently verify the authorising token as an ordinary capability-token -- signature, self-certification, expiry/not-before/valid-until, and the revocation sweep of its WHOLE chain, so revoking a grant-capability revokes every grant minted under it -- then all of: the authoriser's bearer MUST equal this token's issuer (the holder minted it, the authorised-by analogue of parent's bearer rule); the authoriser's capability MUST be manage:grant; the authoriser's grants-capability, when present, MUST equal this token's capability; this token's scope MUST narrow the authoriser's; this token's expires MUST NOT exceed the authoriser's; delegations-remaining consumes one hop (when the authoriser carries it, this token's own value MUST be present and strictly less). The authoriser's conditions are evaluated against THIS token's claims (subject mode), the one place conditions bind a token other than their carrier. A token MUST NOT carry both parent and authorised-by; a token whose capability is manage:grant MUST NOT carry parent at all -- conditions bind only across the authorised-by link, so a parent-mint of a grant-capability would sidestep the issuer's bars. ? "delegations-remaining": uint, ? "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. * tstr => any, ; forward-compatible extension claims } +; Subject-mode condition evaluation and the grant-capability's registered +; delegate systems (wire-mesh#323). Ordinarily a token's conditions are +; evaluated against that token's own claims (the rule on the conditions field +; above). When a verifier walks an authorised-by link, the AUTHORISER's +; conditions are instead evaluated exactly once, against the MINTED child's +; claims; the recursive verification of the authoriser never re-evaluates them +; in ordinary mode, or no child could ever verify past a child-named entry. +; Two delegate systems are registered whenever a subject is present, and are +; indeterminate (fail-closed) whenever one is not: +; grantee-is payload: 64-char lowercase hex device-id; +; true iff the subject's bearer is that device +; granted-capability-is payload: capability-verb; +; true iff the subject's capability is that verb +; With trilean's not/and (strict Kleene: not of an indeterminate stays +; indeterminate, and any indeterminate refuses) these express the two named +; bars, each one entry in the grant-capability's conditions array: +; no self-perpetuation: not(and(granted-capability-is "manage:grant", +; grantee-is )) +; delegate-without-use: the identical node with the underlying verb +; Recipient restrictions generally ("only to these principals") live here +; too, in the issuer's conditions, rather than in new structural fields. + ; Signing convention (a verifier obligation, not expressible as a CDDL ; constraint): `signature` is computed per RFC 9052 §4.4's Sig_structure, ; the array ["Signature1", protected, external_aad, payload], where diff --git a/spec/registry/core-capabilities.md b/spec/registry/core-capabilities.md index c8e01030..a53877d8 100644 --- a/spec/registry/core-capabilities.md +++ b/spec/registry/core-capabilities.md @@ -13,10 +13,13 @@ Append-only. A bare `subsystem:action` capability verb is spec-owned and matched | `room:invite` | `room` | `core/room` | active | | `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 | | `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 | | `path:trace` | `node` — carried but never checked; path.trace verifies no scope or token at all | `core/management` | active | | `topology:get` | `node` | `core/management` | active | +`manage:` is the convention for authority over tokens themselves (`manage:revoke`, `manage:grant`): the verb names the authority, and the token's `-capability` claim (`grants-capability`) names the verb that authority is over. + Third parties do not add entries here — see `namespaced-capability` in `tokens.cddl` for the registrant-owned namespace anyone else uses instead. `path:trace` and `topology:get` are both deliberately ungated, the same as `room:join`/`room:invite` above — see `management.cddl`'s own `path-trace`/`topology-get` comments for why. diff --git a/spec/room.cddl b/spec/room.cddl index d9b5f7b4..c67d88d2 100644 --- a/spec/room.cddl +++ b/spec/room.cddl @@ -31,7 +31,9 @@ ; Two shapes, two different trust roots, for a real security reason: ; ; - `/` (a named, persistent room): the acceptable -; root is the owner named in the path. A member honours another member's +; root is the owner named in the path. These two shapes are the room +; instances of the general chain-root rule tokens.cddl states, which also +; governs tokens minted under a grant-capability's authorised-by link. A member honours another member's ; token because that token's own chain terminates at the owner, and the ; verifying member already accepted that owner's authority the moment it ; accepted its own membership token. diff --git a/spec/tokens.cddl b/spec/tokens.cddl index dbd261a1..4d5517b0 100644 --- a/spec/tokens.cddl +++ b/spec/tokens.cddl @@ -81,7 +81,27 @@ 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. -; Today's behaviour, pending the grant-permission design (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, as the model stands, includes the right to use it. `conditions` on a token are evaluated against that token's own claims and deliberately do not inherit, so nothing today constrains who a minted child may name as bearer. Whether a grant-capability (grantability as a permission of its own, rooted at the resource's owner) whose trilean conditions bind the grants it authorises, letting an issuer bar its holder from granting to itself among other constraints, should close that is the open design in #323. +; The right to delegate is itself a capability (wire-mesh#323): `manage:grant`. +; A grant-capability token authorises its bearer to mint other capability +; tokens; the minted token cites the grant-capability that authorised it via +; `authorised-by`, whose verifier obligations (below) include evaluating the +; authoriser's `conditions` against the MINTED child's own claims. That +; child-binding evaluation is what makes "may grant but may not grant to +; itself" expressible: the pre-#323 argument ("a delegator can always name +; itself as bearer, so delegating includes using") held only while conditions +; could not see the child, and subject-mode evaluation closes exactly that +; gap. `delegations-remaining` remains as the hard depth cap, counting hops +; across BOTH link kinds: a child's value MUST be strictly less than the value +; on the token above it, whichever link joins them. +; +; Delegation chains terminate at the resource's owner. For a scope kind whose +; path encodes an owner (room's owner-named paths, room.cddl) the acceptable +; root is that owner. For a scope kind with no owner in the path (node, +; folder, group) the acceptable root is the verifying device itself, mirroring +; the DM rule: the verifier's own acceptance of the root is what makes a +; grant over a verifier-local resource meaningful. A domain needing a broader +; root for its own kind defines its own rule, the way core/room did; nothing +; here requires every kind to have one before the rule applies where it can. token-claims = { token-id: bstr, issuer: device-id, @@ -92,12 +112,36 @@ token-claims = { expires: uint, ; Unix ms ? not-before: uint, ? parent: bstr, ; bstr .cbor capability-token — a fully self-contained, nested COSE_Sign1 of the parent token; the recursive delegation chain. A verifier walking this chain MUST also check every ancestor's own token-id against the revocation view (management.cddl's revocation-entry) -- revoking one ancestor revokes everything delegated beneath it, not just a leaf's own token-id. + ? "authorised-by": bstr, ; bstr .cbor capability-token -- the grant-capability that authorised this token's MINT (wire-mesh#323), a different link from `parent`: parent means "this token narrows that one" (same verb, narrower authority), authorised-by means "that token permitted this one to exist". Verifier obligations: decode and independently verify the authorising token as an ordinary capability-token -- signature, self-certification, expiry/not-before/valid-until, and the revocation sweep of its WHOLE chain, so revoking a grant-capability revokes every grant minted under it -- then all of: the authoriser's bearer MUST equal this token's issuer (the holder minted it, the authorised-by analogue of parent's bearer rule); the authoriser's capability MUST be manage:grant; the authoriser's grants-capability, when present, MUST equal this token's capability; this token's scope MUST narrow the authoriser's; this token's expires MUST NOT exceed the authoriser's; delegations-remaining consumes one hop (when the authoriser carries it, this token's own value MUST be present and strictly less). The authoriser's conditions are evaluated against THIS token's claims (subject mode), the one place conditions bind a token other than their carrier. A token MUST NOT carry both parent and authorised-by; a token whose capability is manage:grant MUST NOT carry parent at all -- conditions bind only across the authorised-by link, so a parent-mint of a grant-capability would sidestep the issuer's bars. ? "delegations-remaining": uint, ? "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. * tstr => any, ; forward-compatible extension claims } +; Subject-mode condition evaluation and the grant-capability's registered +; delegate systems (wire-mesh#323). Ordinarily a token's conditions are +; evaluated against that token's own claims (the rule on the conditions field +; above). When a verifier walks an authorised-by link, the AUTHORISER's +; conditions are instead evaluated exactly once, against the MINTED child's +; claims; the recursive verification of the authoriser never re-evaluates them +; in ordinary mode, or no child could ever verify past a child-named entry. +; Two delegate systems are registered whenever a subject is present, and are +; indeterminate (fail-closed) whenever one is not: +; grantee-is payload: 64-char lowercase hex device-id; +; true iff the subject's bearer is that device +; granted-capability-is payload: capability-verb; +; true iff the subject's capability is that verb +; With trilean's not/and (strict Kleene: not of an indeterminate stays +; indeterminate, and any indeterminate refuses) these express the two named +; bars, each one entry in the grant-capability's conditions array: +; no self-perpetuation: not(and(granted-capability-is "manage:grant", +; grantee-is )) +; delegate-without-use: the identical node with the underlying verb +; Recipient restrictions generally ("only to these principals") live here +; too, in the issuer's conditions, rather than in new structural fields. + ; Signing convention (a verifier obligation, not expressible as a CDDL ; constraint): `signature` is computed per RFC 9052 §4.4's Sig_structure, ; the array ["Signature1", protected, external_aad, payload], where diff --git a/ts/packages/core/src/generated/protocol.ts b/ts/packages/core/src/generated/protocol.ts index b012bfd7..4f4dc072 100644 --- a/ts/packages/core/src/generated/protocol.ts +++ b/ts/packages/core/src/generated/protocol.ts @@ -392,9 +392,11 @@ export const tokenClaimsSchema = z.lazy(() => z.object({ "expires": z.number().int().nonnegative(), "not-before": z.number().int().nonnegative().optional(), "parent": z.instanceof(Uint8Array).optional(), + "authorised-by": z.instanceof(Uint8Array).optional(), "delegations-remaining": z.number().int().nonnegative().optional(), "valid-until": z.number().int().nonnegative().optional(), "conditions": z.instanceof(Uint8Array).optional(), + "grants-capability": z.lazy(() => capabilityVerbSchema).optional(), }).catchall(z.unknown())); export const pingFrameSchema = z.lazy(() => z.object({ "type": z.literal("ping"), From f379881cef6aee9d151edccc787cf0963bd645c3 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 17:42:03 +0100 Subject: [PATCH 2/8] feat(core): verify and mint tokens under a grant-capability The authorised-by link is checked in the token walk (bearer, verb, scope and expiry narrowing, one delegations-remaining hop, subject-mode conditions against the child) and at mint time, with canGrantVia as the pure query. Subject mode registers grantee-is and granted-capability-is, and the walk gains a depth bound and cycle guard. --- .../core/src/domain/token-predicates.ts | 75 +- ts/packages/core/src/domain/tokens.ts | 253 ++++++- .../token-grant-authorisation.unit.test.ts | 667 ++++++++++++++++++ ts/packages/core/test/tokens-fixtures.ts | 13 + 4 files changed, 999 insertions(+), 9 deletions(-) create mode 100644 ts/packages/core/test/token-grant-authorisation.unit.test.ts diff --git a/ts/packages/core/src/domain/token-predicates.ts b/ts/packages/core/src/domain/token-predicates.ts index 3ed5b054..7c04770a 100644 --- a/ts/packages/core/src/domain/token-predicates.ts +++ b/ts/packages/core/src/domain/token-predicates.ts @@ -9,6 +9,7 @@ import { import { z } from "zod"; import type { DeviceId, TokenClaims } from "../generated/protocol.js"; import type { Clock } from "../ports/clock.js"; +import { deviceIdToHex } from "./device-id.js"; import { bytesEqual, scopeNarrows } from "./token-scope.js"; /** The wire shape of `token-claims.conditions` once CBOR-decoded: trilean's own PredicateNodeSchema is the single source of truth for what a condition entry may contain, re-validated here rather than trusted from a CDDL-generated shadow schema (see tokens.cddl's own comment on why `conditions` is an opaque bstr, not a native CDDL type) -- a token from an untrusted peer must pass trilean's real schema before any of its conditions are evaluated. Explicitly annotated: trilean's PredicateNodeSchema is a deeply recursive z.lazy() type whose inferred shape is too large for tsdown's declaration-file generator to serialise (TS7056) without this. */ @@ -70,6 +71,58 @@ export interface TokenPredicateContext { export interface ConditionsContext { readonly clock: Clock; readonly claims: Readonly; + /** The minted child a grant-capability's conditions are being evaluated against (wire-mesh#323 subject mode): present only inside an authorised-by verification walk or a mint-time authorisation check, never when a token's conditions are evaluated against its own claims. */ + readonly subject?: Readonly; +} + +/** The child half of a subject-mode conditions evaluation: the claims of the token a grant-capability authorises, structurally what NarrowingCandidate already is plus the bearer the mint named. Satisfied by a full TokenClaims, so the verifier passes the child's decoded claims directly. */ +export interface GrantSubject { + capability: TokenClaims["capability"]; + scope: TokenClaims["scope"]; + bearer: DeviceId; + expires: number; + delegationsRemaining?: number; +} + +/** The two subject-mode delegate systems (wire-mesh#323), registered only when a ConditionsContext carries a subject, and indeterminate (fail-closed) whenever one does not. */ +export const GRANTEE_IS = "grantee-is"; +export const GRANTED_CAPABILITY_IS = "granted-capability-is"; + +export type SubjectSystem = typeof GRANTEE_IS | typeof GRANTED_CAPABILITY_IS; + +function isSubjectSystem(system: string): system is SubjectSystem { + return system === GRANTEE_IS || system === GRANTED_CAPABILITY_IS; +} + +const subjectHandlers: Record< + SubjectSystem, + TokenDelegateHandler +> = { + [GRANTEE_IS]: (payload, context) => { + if (context.subject === undefined) return { found: false }; + if (typeof payload !== "string") return { found: false }; + return booleanFound(deviceIdToHex(context.subject.bearer) === payload); + }, + [GRANTED_CAPABILITY_IS]: (payload, context) => { + if (context.subject === undefined) return { found: false }; + if (typeof payload !== "string") return { found: false }; + return booleanFound(context.subject.capability === payload); + }, +}; + +/** The named no-self-grant bar (wire-mesh#323): one conditions entry an issuer puts on a grant-capability so its bearer may not mint a `capability` token naming itself as bearer. `not(and(...))` under strict Kleene: a child granted to anyone else passes, a child granted to the bearer fails, and any unresolvable leg stays indeterminate and refuses. */ +export function noSelfGrantBar( + capability: TokenClaims["capability"], + bearer: DeviceId, +): PredicateNode { + return { + kind: "not", + operand: { + kind: "and", + left: delegateIsTrue(GRANTED_CAPABILITY_IS, capability), + right: delegateIsTrue(GRANTEE_IS, deviceIdToHex(bearer)), + }, + }; } /** One delegate system's own handler: given the delegate node's payload and the concrete context it was invoked with, resolves to a boolean-valued Resolution. Handlers never throw for a data-quality problem -- an op that cannot make sense of its own payload/context returns `{found: false}`, which the evaluator turns into a fail-closed `indeterminate`. */ @@ -123,12 +176,15 @@ const narrowingHandlers: Record< }, }; -/** `{kind:"compare", op:"eq", left:{kind:"delegate", system, payload:null}, right:{kind:"booleanLiteral", value:true}}` -- delegate is an ExpressionNode, not itself a PredicateNode (trilean's PredicateNodeSchema has no `delegate` member), so every predicate op is expressed this way: resolve the delegate to a boolean ComputedValue, then compare it against the literal `true`. */ -function delegateIsTrue(system: string): PredicateNode { +/** `{kind:"compare", op:"eq", left:{kind:"delegate", system, payload}, right:{kind:"booleanLiteral", value:true}}` -- delegate is an ExpressionNode, not itself a PredicateNode (trilean's PredicateNodeSchema has no `delegate` member), so every predicate op is expressed this way: resolve the delegate to a boolean ComputedValue, then compare it against the literal `true`. The narrowing checks pass no payload (every fact they need arrives via context); the subject-mode systems take their comparison value as the payload. */ +function delegateIsTrue( + system: string, + payload: JsonValue = null, +): PredicateNode { return { kind: "compare", op: "eq", - left: { kind: "delegate", system, payload: null }, + left: { kind: "delegate", system, payload }, right: { kind: "booleanLiteral", value: true }, }; } @@ -190,13 +246,24 @@ export async function evaluateConditions( extraHandlers: Readonly< Record> > = {}, + /** Subject mode (wire-mesh#323): the minted child the carrying grant-capability's conditions bind to. Absent evaluates against the carrying token's own claims, exactly as before; present additionally registers the two subject systems, whose handlers read the subject rather than `claims`. */ + subject?: Readonly, ): Promise { - const context: ConditionsContext = { clock, claims }; + const context: ConditionsContext = + subject === undefined ? { clock, claims } : { clock, claims, subject }; const resolvers: Readonly = { resolveValue: unusedResolveValue, resolveLookup: unusedResolveLookup, resolveCollection: unusedResolveCollection, resolveDelegate: async (system, payload) => { + // Subject-mode systems resolve before extraHandlers so a peer cannot shadow a mandatory system with a more permissive app-registered one of the same name; the narrowing systems stay deliberately unreachable here, exactly as documented on the conditions field itself. + if (subject !== undefined && isSubjectSystem(system)) { + try { + return await subjectHandlers[system](payload, context); + } catch { + return { found: false }; + } + } // Object.hasOwn guards against a peer-chosen system name (e.g. "__proto__", "constructor", "toString") resolving to an inherited Object.prototype member instead of undefined -- a bare extraHandlers[system] lookup on a plain object would treat that inherited value as a real handler, and "__proto__" specifically isn't even callable, so invoking it throws rather than producing a verdict. The try/catch below is defence in depth for the same fail-closed requirement, covering a genuinely registered handler that throws for its own reasons -- hostile or malformed input to a condition evaluator must always produce a verdict, never propagate an exception, matching verifyTokenChain's own documented convention. const handler = Object.hasOwn(extraHandlers, system) ? extraHandlers[system] diff --git a/ts/packages/core/src/domain/tokens.ts b/ts/packages/core/src/domain/tokens.ts index 1cd72772..45f1719a 100644 --- a/ts/packages/core/src/domain/tokens.ts +++ b/ts/packages/core/src/domain/tokens.ts @@ -18,12 +18,20 @@ import { evaluateConditions, evaluateNarrowing, type ConditionsContext, + type GrantSubject, type NarrowingCandidate, type NarrowingSystem, type TokenDelegateHandler, } from "./token-predicates.js"; +import { bytesToHex } from "./device-id.js"; import { bytesEqual, scopeNarrows } from "./token-scope.js"; +/** The capability verb of a grant-capability (wire-mesh#323): authority to mint other capability tokens, the same register manage:revoke already established for authority over tokens. */ +export const MANAGE_GRANT_CAPABILITY = "manage:grant"; + +/** Both link kinds walk at most this many hops before the verifier gives up, mirroring Rust's own MAX_CHAIN_DEPTH. Two colluding issuers can construct a mutually-bearing parent cycle that would otherwise recurse forever, and the authorised-by link makes the attack cheaper to mount, so TS gains the bound rather than relying on JS recursion limits. */ +const MAX_CHAIN_DEPTH = 64; + /** * The revocation view a verifier consults. Returns every recorded, already-signature-verified revocation-claims for tokenId, across every issuer that has ever submitted one -- unfiltered by the store itself. The actual verifier obligation (an entry counts against a token when its own issuer matches the token's own issuer, OR its optional `authorization` grants delegated revoke authority -- management.cddl) is checked by the caller (verifyTokenChain), not here, since that check needs the target token's own scope plus identity/clock to verify a nested authorization token, none of which a store constructed ahead of time has access to. Implementations ingest gossiped revocation-announce frames via verifyRevocationEntry (which enforces each entry's own signature and self-certification) and key the resulting claims by token-id alone -- a token-id can legitimately carry multiple recorded entries from different issuers. */ @@ -42,6 +50,12 @@ export type TokenVerdictReason = | "revoked" | "delegation_exceeds_parent" | "parent_invalid" + /** The authorised-by link's own obligations failed: the cited grant-capability did not decode or fully verify, its bearer is not this token's issuer, its capability is not manage:grant, its grants-capability does not match, the scope or expiry widened, the depth bound was exceeded, or its conditions (evaluated against THIS token's claims) did not hold. Collapsed to one reason the same way every narrowing failure already collapses to delegation_exceeds_parent. */ + | "authorisation_invalid" + /** The parent/authorised-by walk exceeded MAX_CHAIN_DEPTH. */ + | "chain_too_deep" + /** The walk revisited a payload already on the current path: a cycle two colluding issuers constructed. */ + | "chain_cycle" /** claims.conditions is present but its bstr fails CBOR decode, or decodes to something that is not a JSON array of valid trilean PredicateNodes -- fail-closed per CONVENTIONS.md's verifier-obligations glossary, the same treatment "malformed" already gives an undecodable payload. */ | "conditions_invalid" /** claims.conditions decoded and validated, but at least one entry did not evaluate to a definite `true` (indeterminate or false) -- the issuer's own additional restriction was not met. */ @@ -178,11 +192,23 @@ async function revocationEntryGrantsRevoke( async function verifyTokenChain( token: CapabilityToken, options: Omit, + /** Subject mode (wire-mesh#323): when this token is being verified as the AUTHORISER of another token, that other token's claims, against which this token's own conditions are evaluated instead of its own. Undefined for a standalone (leaf) verification, which is every existing call. */ + subjectClaims?: Readonly, + depth = 0, + seen: ReadonlySet = new Set(), ): Promise { const [protectedHeader, , payload, signature] = token; if (payload === null) { return { ok: false, reason: "malformed" }; } + if (depth >= MAX_CHAIN_DEPTH) { + return { ok: false, reason: "chain_too_deep" }; + } + const payloadHex = bytesToHex(payload); + if (seen.has(payloadHex)) { + return { ok: false, reason: "chain_cycle" }; + } + const deeperSeen: ReadonlySet = new Set(seen).add(payloadHex); let decodedClaims: unknown; try { @@ -250,16 +276,30 @@ async function verifyTokenChain( claims, options.clock, options.extraPredicateResolvers, + subjectClaims, ); if (!conditionsVerdict.ok) { return { ok: false, reason: "conditions_not_satisfied" }; } } - if (claims.parent !== undefined) { + const parentBytes = claims.parent; + const authorisationBytes = claims["authorised-by"]; + if ( + claims.capability === MANAGE_GRANT_CAPABILITY && + parentBytes !== undefined + ) { + // A manage:grant token is minted only through authorised-by, never a parent link, so an issuer's bars can never be sidestepped by choosing the other link kind. + return { ok: false, reason: "authorisation_invalid" }; + } + if (parentBytes !== undefined && authorisationBytes !== undefined) { + // The two link kinds are mutually exclusive by construction: parent means "this token narrows that one", authorised-by means "that token permitted this one to exist". Both present is a malformed token, not a policy judgement. + return { ok: false, reason: "malformed" }; + } + if (parentBytes !== undefined) { let decodedParent: unknown; try { - decodedParent = decode(claims.parent, cdeDecodeOptions); + decodedParent = decode(parentBytes, cdeDecodeOptions); } catch { return { ok: false, reason: "parent_invalid" }; } @@ -267,8 +307,21 @@ async function verifyTokenChain( if (!parentResult.success) { return { ok: false, reason: "parent_invalid" }; } - const parentVerdict = await verifyTokenChain(parentResult.data, options); + const parentVerdict = await verifyTokenChain( + parentResult.data, + options, + undefined, + depth + 1, + deeperSeen, + ); if (!parentVerdict.ok) { + // Walker-level conditions (too deep, cyclic) are properties of the whole chain, not of this one link, so they surface as themselves rather than hiding behind parent_invalid. + if ( + parentVerdict.reason === "chain_too_deep" || + parentVerdict.reason === "chain_cycle" + ) { + return { ok: false, reason: parentVerdict.reason }; + } return { ok: false, reason: "parent_invalid" }; } const candidate: NarrowingCandidate = { @@ -295,6 +348,58 @@ async function verifyTokenChain( depth: parentVerdict.depth + 1, }; } + if (authorisationBytes !== undefined) { + let decodedAuthoriser: unknown; + try { + decodedAuthoriser = decode(authorisationBytes, cdeDecodeOptions); + } catch { + return { ok: false, reason: "authorisation_invalid" }; + } + const authoriserResult = capabilityTokenSchema.safeParse(decodedAuthoriser); + if (!authoriserResult.success) { + return { ok: false, reason: "authorisation_invalid" }; + } + // Verified with THIS token's claims as the subject, so the authoriser's conditions bind the mint it authorised: the one place conditions evaluate against a token other than their carrier. The recursive call performs that subject-mode evaluation exactly once; the authoriser's conditions are never re-evaluated in ordinary mode anywhere in this walk. + const authoriserVerdict = await verifyTokenChain( + authoriserResult.data, + options, + claims, + depth + 1, + deeperSeen, + ); + if (!authoriserVerdict.ok) { + if ( + authoriserVerdict.reason === "chain_too_deep" || + authoriserVerdict.reason === "chain_cycle" + ) { + return { ok: false, reason: authoriserVerdict.reason }; + } + return { ok: false, reason: "authorisation_invalid" }; + } + const authoriser = authoriserVerdict.claims; + const grantsCapability = authoriser["grants-capability"]; + const linkOk = + bytesEqual(authoriser.bearer, claims.issuer) && + authoriser.capability === MANAGE_GRANT_CAPABILITY && + (grantsCapability === undefined || + grantsCapability === claims.capability) && + scopeNarrows(authoriser.scope, claims.scope) && + claims.expires <= authoriser.expires && + (authoriser["delegations-remaining"] === undefined || + (claims["delegations-remaining"] !== undefined && + claims["delegations-remaining"] < + authoriser["delegations-remaining"])); + if (!linkOk) { + return { ok: false, reason: "authorisation_invalid" }; + } + return { + ok: true, + claims, + rootIssuer: authoriserVerdict.rootIssuer, + rootIssuerKey: authoriserVerdict.rootIssuerKey, + depth: authoriserVerdict.depth + 1, + }; + } return { ok: true, @@ -367,7 +472,19 @@ export type MintRefusalReason = | "expires_exceeds_parent" | "scope_does_not_narrow" | "capability_mismatch" - | "delegation_exceeds_parent"; + | "delegation_exceeds_parent" + /** parent and authorised-by are mutually exclusive link kinds; a manage:grant capability must be minted through authorised-by only. */ + | "links_exclusive" + /** The cited grant-capability did not decode, or its own conditions bytes did not. */ + | "authorisation_malformed" + | "authorisation_parent_forbidden" + | "authorisation_bearer_mismatch" + | "authorisation_capability_mismatch" + | "authorisation_scope_does_not_narrow" + | "authorisation_exceeds_authoriser" + | "authorisation_delegation_exceeds" + /** The authoriser's conditions, evaluated against the candidate being minted, did not all hold (wire-mesh#323 subject mode). */ + | "authorisation_conditions_not_satisfied"; export type MintVerdict = | { ok: true; token: CapabilityToken } @@ -401,8 +518,70 @@ async function checkNarrowing( : NARROWING_SYSTEM_TO_MINT_REFUSAL[failedSystem]; } +/** The authorised-by link's own arithmetic (wire-mesh#323), the analogue checkNarrowing already is for the parent link: everything tokens.cddl's authorised-by obligations require, checked against a not-yet-minted candidate, shared between mintCapabilityToken and canGrantVia so the two can never drift. The authoriser's conditions are evaluated in subject mode against the candidate, the same evaluation the verifier performs against the minted child. Returns the specific refusal reason, or undefined when the authorisation holds. */ +async function checkAuthorisation( + authorisingClaims: Readonly, + minterDeviceId: DeviceId, + candidate: Readonly, +): Promise { + if (authorisingClaims.parent !== undefined) { + return "authorisation_parent_forbidden"; + } + if (!bytesEqual(authorisingClaims.bearer, minterDeviceId)) { + return "authorisation_bearer_mismatch"; + } + const grantsCapability = authorisingClaims["grants-capability"]; + if ( + authorisingClaims.capability !== MANAGE_GRANT_CAPABILITY || + (grantsCapability !== undefined && + grantsCapability !== candidate.capability) + ) { + return "authorisation_capability_mismatch"; + } + if (!scopeNarrows(authorisingClaims.scope, candidate.scope)) { + return "authorisation_scope_does_not_narrow"; + } + if (candidate.expires > authorisingClaims.expires) { + return "authorisation_exceeds_authoriser"; + } + const remaining = authorisingClaims["delegations-remaining"]; + if ( + remaining !== undefined && + (candidate.delegationsRemaining === undefined || + candidate.delegationsRemaining >= remaining) + ) { + return "authorisation_delegation_exceeds"; + } + if (authorisingClaims.conditions !== undefined) { + let decodedConditions: unknown; + try { + decodedConditions = decode( + authorisingClaims.conditions, + cdeDecodeOptions, + ); + } catch { + return "authorisation_malformed"; + } + const conditionsResult = conditionsListSchema.safeParse(decodedConditions); + if (!conditionsResult.success) { + return "authorisation_malformed"; + } + const verdict = await evaluateConditions( + conditionsResult.data, + authorisingClaims, + { now: () => 0 }, + {}, + candidate, + ); + if (!verdict.ok) { + return "authorisation_conditions_not_satisfied"; + } + } + return undefined; +} + /** - * A pure query: could deviceId, presenting heldToken as its own delegation authority, successfully mint a delegation matching candidate right now -- without attempting (and potentially failing) a real mint just to find out. Reuses mintCapabilityToken's own narrowing arithmetic via checkNarrowing, so the two can never silently drift into different ideas of what "narrows" means. + * A pure query: could deviceId, presenting heldToken as its own delegation authority," successfully mint a delegation matching candidate right now -- without attempting (and potentially failing) a real mint just to find out. Reuses mintCapabilityToken's own narrowing arithmetic via checkNarrowing, so the two can never silently drift into different ideas of what "narrows" means. * * Deliberately narrower than a full mint attempt in one respect: this checks only the narrowing rules tokens.cddl's own delegation obligations require (bearer match, expiry, scope, capability, delegations-remaining), the same scope mintCapabilityToken itself checks a *parent* against -- it does not verify heldToken's own signature or revocation status, exactly as mintCapabilityToken never re-verifies its own parent's signature either. A caller that also needs heldToken's cryptographic validity confirmed calls verifyCapabilityToken separately. * @@ -424,6 +603,25 @@ export async function canGrant( return (await checkNarrowing(heldClaims, deviceId, candidate)) === undefined; } +/** A pure query: could deviceId, presenting heldGrantCapability as its minting authority, mint a token matching candidate right now (wire-mesh#323) -- canGrant's authorised-by analogue, with the same deliberately-narrower-than-verify caveat canGrant's own doc comment states: the held token's signature and revocation status are not checked here; a caller needing those confirmed calls verifyCapabilityToken separately. The candidate carries a bearer because the authoriser's subject-mode conditions read it (the no-self-grant bar). */ +export async function canGrantVia( + heldGrantCapability: CapabilityToken, + deviceId: DeviceId, + candidate: Readonly, + now: number, +): Promise { + if (candidate.expires <= now) { + return false; + } + const heldClaims = decodeTokenClaims(heldGrantCapability); + if (heldClaims === undefined) { + return false; + } + return ( + (await checkAuthorisation(heldClaims, deviceId, candidate)) === undefined + ); +} + export interface MintCapabilityTokenOptions { /** The issuer -- signs the token, and supplies the self-certifying issuer/issuer-key claims. */ identity: IdentityPort; @@ -440,6 +638,10 @@ export interface MintCapabilityTokenOptions { parent?: CapabilityToken; /** Additional, issuer-chosen restrictions beyond the five mandatory narrowing checks (issue #85) -- CBOR-encoded into claims.conditions verbatim, evaluated by every verifier via evaluateConditions. Strictly additive: has no bearing on narrowing, which mint enforces separately above regardless of what's given here. Not re-validated against trilean's own schema before encoding -- the TS type already guarantees a well-formed PredicateNode[] at this call site, unlike the bytes a verifier decodes from an untrusted wire token, which always are. */ conditions?: PredicateNode[]; + /** The grant-capability authorising this mint (wire-mesh#323): the resulting token carries it as authorised-by, and every link obligation (bearer match, manage:grant verb, grants-capability match, scope and expiry narrowing, depth consumption, and the authoriser's subject-mode conditions against this very candidate) is enforced here, at issuance, rather than left for the far end as a bare authorisation_invalid. Mutually exclusive with parent. */ + authorisedBy?: CapabilityToken; + /** On a manage:grant token: the single verb it authorises minting. Absent means any verb within scope. */ + grantsCapability?: TokenClaims["capability"]; } /** @@ -454,6 +656,16 @@ export async function mintCapabilityToken( return { ok: false, reason: "already_expired" }; } + if (options.parent !== undefined && options.authorisedBy !== undefined) { + return { ok: false, reason: "links_exclusive" }; + } + if ( + options.capability === MANAGE_GRANT_CAPABILITY && + options.parent !== undefined + ) { + return { ok: false, reason: "authorisation_parent_forbidden" }; + } + let parentBytes: Uint8Array | undefined; if (options.parent !== undefined) { const parentClaims = decodeTokenClaims(options.parent); @@ -478,6 +690,31 @@ export async function mintCapabilityToken( parentBytes = encodeBuf(options.parent); } + let authorisationBytes: Uint8Array | undefined; + if (options.authorisedBy !== undefined) { + const authorisingClaims = decodeTokenClaims(options.authorisedBy); + if (authorisingClaims === undefined) { + return { ok: false, reason: "authorisation_malformed" }; + } + const refusal = await checkAuthorisation( + authorisingClaims, + options.identity.deviceId, + { + capability: options.capability, + scope: options.scope, + bearer: options.bearer, + expires: options.expires, + ...(options.delegationsRemaining !== undefined + ? { delegationsRemaining: options.delegationsRemaining } + : {}), + }, + ); + if (refusal !== undefined) { + return { ok: false, reason: refusal }; + } + authorisationBytes = encodeBuf(options.authorisedBy); + } + const claims: TokenClaims = { "token-id": options.tokenId, issuer: options.identity.deviceId, @@ -496,6 +733,12 @@ export async function mintCapabilityToken( ...(options.conditions !== undefined ? { conditions: encodeBuf(options.conditions) } : {}), + ...(authorisationBytes !== undefined + ? { "authorised-by": authorisationBytes } + : {}), + ...(options.grantsCapability !== undefined + ? { "grants-capability": options.grantsCapability } + : {}), }; const payload = encodeBuf(claims); diff --git a/ts/packages/core/test/token-grant-authorisation.unit.test.ts b/ts/packages/core/test/token-grant-authorisation.unit.test.ts new file mode 100644 index 00000000..91768a5b --- /dev/null +++ b/ts/packages/core/test/token-grant-authorisation.unit.test.ts @@ -0,0 +1,667 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import type { PredicateNode } from "trilean"; +import { + MANAGE_GRANT_CAPABILITY, + canGrantVia, + mintCapabilityToken, + verifyCapabilityToken, +} from "../src/domain/tokens.js"; +import { noSelfGrantBar } from "../src/domain/token-predicates.js"; +import { ownerNamedRoomPath } from "../src/domain/room-path.js"; +import { verifyRoomToken } from "../src/domain/room-token-verification.js"; +import { bytesToHex } from "../src/domain/device-id.js"; +import type { IdentityPort } from "../src/ports/identity.js"; +import type { + CapabilityScope, + CapabilityToken, + DeviceId, + RevocationClaims, + TokenClaims, +} from "../src/generated/protocol.js"; +import { + HOUR_MS, + encodeBuf, + equalBytes, + fixedClock, + generateEs256Identity, + neverRevoked, + nextTokenId, + revocationView, + signToken, +} from "./tokens-fixtures.js"; + +// The authorised-by link (wire-mesh#323): a grant-capability (manage:grant) authorises its bearer to mint other capability tokens, and the authoriser's conditions bind the minted child's claims. Every fixture here is signed directly (not via mint) except where a test is explicitly about mint's own enforcement, so verifyCapabilityToken's enforcement is exercised on chains mint would refuse to produce, the same discipline token-delegation-narrowing.unit.test.ts already follows. + +const NOW_MS = 1_893_456_000_000; +const WORK_SCOPE: CapabilityScope = { kind: "folder", path: "/work" }; +const DEPTH_CAP = 64; + +describe("verifyCapabilityToken - authorised-by", () => { + let owner: IdentityPort; + let deputy: IdentityPort; + let thirdParty: IdentityPort; + + beforeAll(async () => { + owner = await generateEs256Identity(); + deputy = await generateEs256Identity(); + thirdParty = await generateEs256Identity(); + }); + + /** A root manage:grant held by deputy, signed by owner, reporting the token-id it was minted under so a revocation test can name it. */ + async function grantCapability( + overrides: Partial<{ + grantsCapability: TokenClaims["capability"]; + conditionsBytes: Uint8Array; + delegationsRemaining: number; + }> = {}, + ): Promise<{ token: CapabilityToken; tokenId: Uint8Array }> { + const tokenId = nextTokenId(); + const token = await signToken(owner, { + tokenId, + bearer: deputy.deviceId, + capability: MANAGE_GRANT_CAPABILITY, + scope: WORK_SCOPE, + expires: NOW_MS + 2 * HOUR_MS, + ...(overrides.grantsCapability !== undefined + ? { grantsCapability: overrides.grantsCapability } + : {}), + ...(overrides.conditionsBytes !== undefined + ? { conditions: overrides.conditionsBytes } + : {}), + ...(overrides.delegationsRemaining !== undefined + ? { delegationsRemaining: overrides.delegationsRemaining } + : {}), + }); + return { token, tokenId }; + } + + /** An exec:pty token minted by deputy under a grant-capability. */ + async function mintedUnder( + authoriser: CapabilityToken, + overrides: Partial<{ + bearer: DeviceId; + scope: CapabilityScope; + expires: number; + delegationsRemaining: number; + capability: TokenClaims["capability"]; + }> = {}, + ): Promise { + return signToken(deputy, { + tokenId: nextTokenId(), + bearer: overrides.bearer ?? thirdParty.deviceId, + capability: overrides.capability ?? "exec:pty", + scope: overrides.scope ?? { kind: "folder", path: "/work/subdir" }, + expires: overrides.expires ?? NOW_MS + HOUR_MS, + ...(overrides.delegationsRemaining !== undefined + ? { delegationsRemaining: overrides.delegationsRemaining } + : {}), + authorisedBy: encodeBuf(authoriser), + }); + } + + function options( + revocation = neverRevoked, + ): Parameters[1] { + return { + identity: owner, + clock: fixedClock(NOW_MS), + revocation, + }; + } + + it("verifies a token minted under a grant-capability, threading root and depth from the authoriser's chain", async () => { + const { token: grant } = await grantCapability({ + grantsCapability: "exec:pty", + }); + const child = await mintedUnder(grant); + + const verdict = await verifyCapabilityToken(child, options()); + + expect(verdict).toMatchObject({ + ok: true, + rootIssuer: owner.deviceId, + depth: 1, + }); + if (verdict.ok) { + expect( + equalBytes( + verdict.rootIssuerKey["public-key"], + owner.identityKey["public-key"], + ), + ).toBe(true); + } + }); + + it("refuses a child whose capability is not the one the authoriser names", async () => { + const { token: grant } = await grantCapability({ + grantsCapability: "exec:pty", + }); + const child = await mintedUnder(grant, { capability: "pin:write" }); + + const verdict = await verifyCapabilityToken(child, options()); + + expect(verdict).toEqual({ ok: false, reason: "authorisation_invalid" }); + }); + + it("treats an absent grants-capability as any verb within scope", async () => { + const { token: grant } = await grantCapability(); + const child = await mintedUnder(grant, { capability: "pin:write" }); + + const verdict = await verifyCapabilityToken(child, options()); + + expect(verdict).toMatchObject({ ok: true }); + if (verdict.ok) { + expect(verdict.claims.capability).toBe("pin:write"); + } + }); + + it("refuses a child minted by someone other than the authoriser's bearer", async () => { + const { token: grant } = await grantCapability(); + const impostor = await generateEs256Identity(); + const child = await signToken(impostor, { + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + scope: { kind: "folder", path: "/work/subdir" }, + expires: NOW_MS + HOUR_MS, + authorisedBy: encodeBuf(grant), + }); + + const verdict = await verifyCapabilityToken(child, options()); + + expect(verdict).toEqual({ ok: false, reason: "authorisation_invalid" }); + }); + + it("refuses a child whose scope widens the authoriser's", async () => { + const { token: grant } = await grantCapability(); + const child = await mintedUnder(grant, { + scope: { kind: "folder", path: "/other" }, + }); + + const verdict = await verifyCapabilityToken(child, options()); + + expect(verdict).toEqual({ ok: false, reason: "authorisation_invalid" }); + }); + + it("refuses a child that outlives the authoriser", async () => { + const { token: grant } = await grantCapability(); + const child = await mintedUnder(grant, { + expires: NOW_MS + 2 * HOUR_MS + HOUR_MS, + }); + + const verdict = await verifyCapabilityToken(child, options()); + + expect(verdict).toEqual({ ok: false, reason: "authorisation_invalid" }); + }); + + it("consumes one delegations-remaining hop across the link: a present strictly-smaller child value passes, an absent one is refused, and 0 on the authoriser is inert", async () => { + const { token: bounded } = await grantCapability({ + delegationsRemaining: 1, + }); + const okChild = await mintedUnder(bounded, { delegationsRemaining: 0 }); + expect(await verifyCapabilityToken(okChild, options())).toMatchObject({ + ok: true, + }); + + const absentChild = await mintedUnder(bounded); + expect(await verifyCapabilityToken(absentChild, options())).toEqual({ + ok: false, + reason: "authorisation_invalid", + }); + + const { token: inert } = await grantCapability({ + delegationsRemaining: 0, + }); + const underInert = await mintedUnder(inert, { delegationsRemaining: 0 }); + expect(await verifyCapabilityToken(underInert, options())).toEqual({ + ok: false, + reason: "authorisation_invalid", + }); + }); + + it("evaluates the authoriser's conditions against the child: the delegate-without-use bar refuses a self-borne child and passes a third party's", async () => { + const { token: grant } = await grantCapability({ + conditionsBytes: encodeBuf([noSelfGrantBar("exec:pty", deputy.deviceId)]), + }); + const selfBorne = await mintedUnder(grant, { + bearer: deputy.deviceId, + }); + const theirs = await mintedUnder(grant, { bearer: thirdParty.deviceId }); + + expect(await verifyCapabilityToken(selfBorne, options())).toEqual({ + ok: false, + reason: "authorisation_invalid", + }); + expect(await verifyCapabilityToken(theirs, options())).toMatchObject({ + ok: true, + }); + }); + + it("the no-self-perpetuation bar refuses a manage:grant minted to the bearer itself and passes one minted onward", async () => { + const { token: grant } = await grantCapability({ + conditionsBytes: encodeBuf([ + noSelfGrantBar(MANAGE_GRANT_CAPABILITY, deputy.deviceId), + ]), + }); + const selfPerpetuated = await mintedUnder(grant, { + capability: MANAGE_GRANT_CAPABILITY, + bearer: deputy.deviceId, + }); + const passedOn = await mintedUnder(grant, { + capability: MANAGE_GRANT_CAPABILITY, + bearer: thirdParty.deviceId, + }); + + expect(await verifyCapabilityToken(selfPerpetuated, options())).toEqual({ + ok: false, + reason: "authorisation_invalid", + }); + expect(await verifyCapabilityToken(passedOn, options())).toMatchObject({ + ok: true, + }); + }); + + it("a bar naming someone other than the child's bearer does not refuse the child, pinning that evaluation reads the CHILD's claims", async () => { + const { token: grant } = await grantCapability({ + conditionsBytes: encodeBuf([noSelfGrantBar("exec:pty", owner.deviceId)]), + }); + const child = await mintedUnder(grant, { bearer: deputy.deviceId }); + + expect(await verifyCapabilityToken(child, options())).toMatchObject({ + ok: true, + }); + }); + + it("fails closed on a subject-mode condition naming an unregistered system", async () => { + const unregistered: PredicateNode = { + kind: "compare", + op: "eq", + left: { + kind: "delegate", + system: "not-a-registered-system", + payload: null, + }, + right: { kind: "booleanLiteral", value: true }, + }; + const { token: grant } = await grantCapability({ + conditionsBytes: encodeBuf([unregistered]), + }); + const child = await mintedUnder(grant); + + expect(await verifyCapabilityToken(child, options())).toEqual({ + ok: false, + reason: "authorisation_invalid", + }); + }); + + it("a grant-capability carrying child-named conditions does not verify standalone: subject systems are indeterminate with no subject", async () => { + const { token: grant } = await grantCapability({ + conditionsBytes: encodeBuf([noSelfGrantBar("exec:pty", deputy.deviceId)]), + }); + + expect(await verifyCapabilityToken(grant, options())).toEqual({ + ok: false, + reason: "conditions_not_satisfied", + }); + }); + + it("refuses a token carrying both parent and authorised-by as malformed", async () => { + const { token: grant } = await grantCapability(); + const root = await signToken(owner, { + tokenId: nextTokenId(), + bearer: deputy.deviceId, + scope: WORK_SCOPE, + expires: NOW_MS + 2 * HOUR_MS, + }); + const child = await signToken(deputy, { + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + scope: { kind: "folder", path: "/work/subdir" }, + expires: NOW_MS + HOUR_MS, + parent: encodeBuf(root), + authorisedBy: encodeBuf(grant), + }); + + expect(await verifyCapabilityToken(child, options())).toEqual({ + ok: false, + reason: "malformed", + }); + }); + + it("refuses a manage:grant token that carries a parent at all", async () => { + const root = await signToken(owner, { + tokenId: nextTokenId(), + bearer: deputy.deviceId, + capability: MANAGE_GRANT_CAPABILITY, + scope: WORK_SCOPE, + expires: NOW_MS + 2 * HOUR_MS, + }); + const grantWithParent = await signToken(deputy, { + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: MANAGE_GRANT_CAPABILITY, + scope: WORK_SCOPE, + expires: NOW_MS + HOUR_MS, + parent: encodeBuf(root), + }); + + expect(await verifyCapabilityToken(grantWithParent, options())).toEqual({ + ok: false, + reason: "authorisation_invalid", + }); + }); + + it("revoking the grant-capability revokes every grant minted under it", async () => { + const { token: grant, tokenId: grantTokenId } = await grantCapability(); + const child = await mintedUnder(grant); + const revocation: RevocationClaims = { + "token-id": grantTokenId, + issuer: owner.deviceId, + "issuer-key": owner.identityKey, + "revoked-at": NOW_MS - 1, + }; + + const verdict = await verifyCapabilityToken( + child, + options(revocationView([revocation])), + ); + + // Refusal is the property that matters: revoking the authoriser takes the whole minted subtree with it. The reason collapses the same way a revoked parent already collapses to parent_invalid. + expect(verdict).toEqual({ ok: false, reason: "authorisation_invalid" }); + }); + + it("walks nested grant-capabilities: a token minted under G2, itself minted under G1, roots at G1's issuer", async () => { + const { token: g1 } = await grantCapability({ + grantsCapability: MANAGE_GRANT_CAPABILITY, + }); + const g2 = await signToken(deputy, { + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: MANAGE_GRANT_CAPABILITY, + scope: WORK_SCOPE, + expires: NOW_MS + HOUR_MS, + authorisedBy: encodeBuf(g1), + }); + const leaf = await signToken(thirdParty, { + tokenId: nextTokenId(), + bearer: owner.deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/deep" }, + expires: NOW_MS + HOUR_MS / 2, + authorisedBy: encodeBuf(g2), + }); + + const verdict = await verifyCapabilityToken(leaf, options()); + + expect(verdict).toMatchObject({ + ok: true, + rootIssuer: owner.deviceId, + depth: 2, + }); + }); + + it("a room owner's root check passes across a grant mint", async () => { + const roomPath = ownerNamedRoomPath(bytesToHex(owner.deviceId), "general"); + const roomGrant = await signToken(owner, { + tokenId: nextTokenId(), + bearer: deputy.deviceId, + capability: MANAGE_GRANT_CAPABILITY, + grantsCapability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: NOW_MS + 2 * HOUR_MS, + }); + const membership = await signToken(deputy, { + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: NOW_MS + HOUR_MS, + authorisedBy: encodeBuf(roomGrant), + }); + + const verdict = await verifyRoomToken(membership, { + identity: owner, + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + expectedBearer: thirdParty.deviceId, + roomPath, + }); + + expect(verdict).toMatchObject({ ok: true }); + }); + + it("refuses a chain deeper than the bound", async () => { + let token = await signToken(owner, { + tokenId: nextTokenId(), + bearer: deputy.deviceId, + scope: WORK_SCOPE, + expires: NOW_MS + (DEPTH_CAP + 2) * HOUR_MS, + }); + for (let hop = 0; hop < DEPTH_CAP; hop += 1) { + token = await signToken(deputy, { + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + scope: WORK_SCOPE, + expires: NOW_MS + (DEPTH_CAP - hop) * HOUR_MS, + parent: encodeBuf(token), + }); + } + + const verdict = await verifyCapabilityToken(token, options()); + + expect(verdict).toEqual({ ok: false, reason: "chain_too_deep" }); + }); +}); + +describe("mintCapabilityToken - authorisedBy", () => { + let owner: IdentityPort; + let deputy: IdentityPort; + let thirdParty: IdentityPort; + + beforeAll(async () => { + owner = await generateEs256Identity(); + deputy = await generateEs256Identity(); + thirdParty = await generateEs256Identity(); + }); + + async function mintedGrantCapability( + overrides: Partial<{ + conditions: PredicateNode[]; + grantsCapability: TokenClaims["capability"]; + }> = {}, + ): Promise { + const verdict = await mintCapabilityToken({ + identity: owner, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: deputy.deviceId, + capability: MANAGE_GRANT_CAPABILITY, + scope: WORK_SCOPE, + expires: NOW_MS + 2 * HOUR_MS, + ...(overrides.grantsCapability !== undefined + ? { grantsCapability: overrides.grantsCapability } + : {}), + ...(overrides.conditions !== undefined + ? { conditions: overrides.conditions } + : {}), + }); + if (!verdict.ok) throw new Error(`mint failed: ${verdict.reason}`); + return verdict.token; + } + + it("mints under a grant-capability and the result verifies", async () => { + const grant = await mintedGrantCapability({ + grantsCapability: "exec:pty", + }); + const verdict = await mintCapabilityToken({ + identity: deputy, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: NOW_MS + HOUR_MS, + authorisedBy: grant, + }); + + expect(verdict.ok).toBe(true); + if (verdict.ok) { + expect( + await verifyCapabilityToken(verdict.token, { + identity: owner, + clock: fixedClock(NOW_MS), + revocation: neverRevoked, + }), + ).toMatchObject({ ok: true, depth: 1 }); + } + }); + + it("refuses at mint time for every link violation the verifier would catch", async () => { + const grant = await mintedGrantCapability({ + grantsCapability: "exec:pty", + }); + const cases = [ + { + capability: "pin:write" as const, + scope: { kind: "folder", path: "/work/subdir" }, + expires: NOW_MS + HOUR_MS, + reason: "authorisation_capability_mismatch", + }, + { + capability: "exec:pty" as const, + scope: { kind: "folder", path: "/other" }, + expires: NOW_MS + HOUR_MS, + reason: "authorisation_scope_does_not_narrow", + }, + { + capability: "exec:pty" as const, + scope: { kind: "folder", path: "/work/subdir" }, + expires: NOW_MS + 2 * HOUR_MS + HOUR_MS, + reason: "authorisation_exceeds_authoriser", + }, + ]; + for (const testCase of cases) { + const verdict = await mintCapabilityToken({ + identity: deputy, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: testCase.capability, + scope: testCase.scope, + expires: testCase.expires, + authorisedBy: grant, + }); + expect(verdict).toEqual({ ok: false, reason: testCase.reason }); + } + }); + + it("refuses a mint the authoriser's subject-mode conditions bar, and one by a non-bearer of the authoriser", async () => { + const grant = await mintedGrantCapability({ + conditions: [noSelfGrantBar("exec:pty", deputy.deviceId)], + }); + const barred = await mintCapabilityToken({ + identity: deputy, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: deputy.deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: NOW_MS + HOUR_MS, + authorisedBy: grant, + }); + expect(barred).toEqual({ + ok: false, + reason: "authorisation_conditions_not_satisfied", + }); + + const impostor = await generateEs256Identity(); + const notBearer = await mintCapabilityToken({ + identity: impostor, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: NOW_MS + HOUR_MS, + authorisedBy: grant, + }); + expect(notBearer).toEqual({ + ok: false, + reason: "authorisation_bearer_mismatch", + }); + }); + + it("refuses both link kinds at once, and a manage:grant minted through a parent", async () => { + const grant = await mintedGrantCapability(); + const root = await signToken(owner, { + tokenId: nextTokenId(), + bearer: deputy.deviceId, + scope: WORK_SCOPE, + expires: NOW_MS + 2 * HOUR_MS, + }); + const both = await mintCapabilityToken({ + identity: deputy, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: NOW_MS + HOUR_MS, + parent: root, + authorisedBy: grant, + }); + expect(both).toEqual({ ok: false, reason: "links_exclusive" }); + + const grantViaParent = await mintCapabilityToken({ + identity: deputy, + clock: fixedClock(NOW_MS), + tokenId: nextTokenId(), + bearer: thirdParty.deviceId, + capability: MANAGE_GRANT_CAPABILITY, + scope: WORK_SCOPE, + expires: NOW_MS + HOUR_MS, + parent: root, + }); + expect(grantViaParent).toEqual({ + ok: false, + reason: "authorisation_parent_forbidden", + }); + }); +}); + +describe("canGrantVia", () => { + it("answers whether a holder could mint a candidate under a grant-capability, without minting", async () => { + const owner = await generateEs256Identity(); + const deputy = await generateEs256Identity(); + const thirdParty = await generateEs256Identity(); + const grant = await signToken(owner, { + tokenId: nextTokenId(), + bearer: deputy.deviceId, + capability: MANAGE_GRANT_CAPABILITY, + grantsCapability: "exec:pty", + scope: WORK_SCOPE, + expires: NOW_MS + 2 * HOUR_MS, + conditions: encodeBuf([noSelfGrantBar("exec:pty", deputy.deviceId)]), + }); + const candidate = { + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + bearer: thirdParty.deviceId, + expires: NOW_MS + HOUR_MS, + }; + + expect(await canGrantVia(grant, deputy.deviceId, candidate, NOW_MS)).toBe( + true, + ); + expect( + await canGrantVia( + grant, + deputy.deviceId, + { ...candidate, bearer: deputy.deviceId }, + NOW_MS, + ), + ).toBe(false); + expect( + await canGrantVia(grant, thirdParty.deviceId, candidate, NOW_MS), + ).toBe(false); + }); +}); diff --git a/ts/packages/core/test/tokens-fixtures.ts b/ts/packages/core/test/tokens-fixtures.ts index b5bb8d08..65689bca 100644 --- a/ts/packages/core/test/tokens-fixtures.ts +++ b/ts/packages/core/test/tokens-fixtures.ts @@ -127,6 +127,12 @@ export interface TokenSeed { validUntil?: number; /** Defaults to "exec:pty", the value every pre-existing test relies on implicitly. Overridable so a test can mint a manage:revoke-capability authorization token without needing a second signing helper. */ capability?: CapabilityVerb; + /** Encoded verbatim as authorised-by: the grant-capability authorising this mint (wire-mesh#323). */ + authorisedBy?: Uint8Array; + /** Encoded verbatim as grants-capability, on a manage:grant token. */ + grantsCapability?: CapabilityVerb; + /** Encoded verbatim as conditions, already CBOR bytes of a trilean predicate-node array. */ + conditions?: Uint8Array; } /** Builds and signs one capability token as `identity` -- explicit field-by-field construction rather than spreading a partial claims object, since TokenClaims' own `.catchall(z.unknown())` index signature (the spec's forward-compatible extension-field pattern) makes a spread-based `Omit` lose the specific field types. Deliberately does none of mintCapabilityToken's own narrowing checks -- tests exercising verifyCapabilityToken's own enforcement need to construct chains mint would refuse to produce. */ @@ -149,6 +155,13 @@ export async function signToken( ...(seed.validUntil !== undefined ? { "valid-until": seed.validUntil } : {}), + ...(seed.authorisedBy !== undefined + ? { "authorised-by": seed.authorisedBy } + : {}), + ...(seed.grantsCapability !== undefined + ? { "grants-capability": seed.grantsCapability } + : {}), + ...(seed.conditions !== undefined ? { conditions: seed.conditions } : {}), }; const payload = encodeBuf(claims); From b4a07904607277115a3ae80928999f476a0ffc9f Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 17:55:50 +0100 Subject: [PATCH 3/8] feat(rust): type the grant-permission token claims authorised-by, grants-capability and a typed delegations-remaining join TokenClaims so none can fall into the extension tail unchecked, with a round-trip test covering all three at once. --- rust/crates/wire-mesh-wire/src/tokens.rs | 72 +++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/rust/crates/wire-mesh-wire/src/tokens.rs b/rust/crates/wire-mesh-wire/src/tokens.rs index ea2cf86e..fd3b8c90 100644 --- a/rust/crates/wire-mesh-wire/src/tokens.rs +++ b/rust/crates/wire-mesh-wire/src/tokens.rs @@ -491,9 +491,20 @@ pub struct TokenClaims { /// silently dropped the way `valid-until` once did before that gap was /// found and fixed. pub conditions: Option>, - /// `bstr .cbor capability-token` — a fully self-contained nested + /// `bstr .cbor capability-token`: a fully self-contained nested /// COSE_Sign1 of the parent token, opaque at this layer. pub parent: Option>, + /// Bounds how many further delegation hops the chain may take below + /// this token; absent means unbounded, 0 means none. Typed (never left + /// to `extra`) precisely so the verifier can enforce it: an untyped + /// field is an unchecked field. + pub delegations_remaining: Option, + /// `bstr .cbor capability-token`: the grant-capability that authorised + /// this token's mint (wire-mesh#323), opaque at this layer. + pub authorised_by: Option>, + /// 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, pub extra: CanonicalMap, } @@ -546,6 +557,15 @@ impl Encode<()> for TokenClaims { if let Some(parent) = &self.parent { builder.push_bytes("parent", parent); } + if let Some(remaining) = self.delegations_remaining { + builder.push("delegations-remaining", &remaining); + } + if let Some(authorised_by) = &self.authorised_by { + builder.push_bytes("authorised-by", authorised_by); + } + if let Some(grants) = &self.grants_capability { + builder.push("grants-capability", &grants.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); @@ -577,6 +597,9 @@ pub(crate) fn token_claims_from(d: &mut Decoder<'_>) -> Result = None; let mut conditions: Option> = None; let mut parent: Option> = None; + let mut delegations_remaining: Option = None; + let mut authorised_by: Option> = None; + let mut grants_capability: Option = None; let mut extra = CanonicalMap::new(); while let Some(key) = map.next_key(d)? { match key { @@ -593,6 +616,13 @@ pub(crate) fn token_claims_from(d: &mut Decoder<'_>) -> Result strict::set_once(&mut valid_until, strict::uint_value(d)?)?, "conditions" => strict::set_once(&mut conditions, strict::bytes_value(d)?)?, "parent" => strict::set_once(&mut parent, strict::bytes_value(d)?)?, + "delegations-remaining" => { + strict::set_once(&mut delegations_remaining, strict::uint_value(d)?)? + } + "authorised-by" => strict::set_once(&mut authorised_by, strict::bytes_value(d)?)?, + "grants-capability" => { + strict::set_once(&mut grants_capability, strict::text_value(d)?)? + } other => { let value = CborValue::decode_strict(d)?; extra.insert(other.to_owned(), value)?; @@ -611,6 +641,9 @@ pub(crate) fn token_claims_from(d: &mut Decoder<'_>) -> Result Date: Thu, 1 Oct 2026 17:55:50 +0100 Subject: [PATCH 4/8] feat(rust): evaluate not/and/or and the subject-mode delegate systems The predicate subset gains strict-Kleene not/and/or (the shape of the no-self-grant bars) and two delegate systems, grantee-is and granted-capability-is, which resolve against the minted child a grant-capability's conditions bind and are indeterminate without one. --- .../wire-mesh-core/src/domain/predicates.rs | 228 ++++++++++++++++-- 1 file changed, 203 insertions(+), 25 deletions(-) diff --git a/rust/crates/wire-mesh-core/src/domain/predicates.rs b/rust/crates/wire-mesh-core/src/domain/predicates.rs index 806124ce..bc9e5037 100644 --- a/rust/crates/wire-mesh-core/src/domain/predicates.rs +++ b/rust/crates/wire-mesh-core/src/domain/predicates.rs @@ -20,16 +20,25 @@ //! CDE, or isn't an array at all) is a hard decode error, mirroring how `parent`'s own nested //! `bstr .cbor` is already handled in `tokens.rs`. //! -//! No `delegate` system is registered yet (mirroring `token-predicates.ts`'s own -//! `extraHandlers: {} ` default), so every `delegate` node currently resolves to -//! indeterminate -- correct, fail-closed behaviour, not a placeholder to fill in later. +//! Two `delegate` systems are registered, both subject-mode (wire-mesh#323): they resolve +//! only when a subject is supplied (an authorised-by walk evaluating the authoriser's +//! conditions against the minted child's claims) and are indeterminate otherwise. +//! `not`/`and`/`or` decode and evaluate under strict Kleene semantics, since the named +//! no-self-grant bars are `not(and(...))` trees; every other boolean connective stays +//! `Unsupported` (fail-closed) until something actually needs it. use std::collections::BTreeMap; +use crate::domain::room_path::device_id_to_hex; use minicbor::Decoder; use wire_mesh_wire::error::DecodeError; +use wire_mesh_wire::identity::DeviceId; use wire_mesh_wire::value::{CanonicalMap, CborValue}; +/// The two subject-mode delegate systems (wire-mesh#323), matching token-predicates.ts. +pub const GRANTEE_IS: &str = "grantee-is"; +pub const GRANTED_CAPABILITY_IS: &str = "granted-capability-is"; + /// `compare`'s comparison operator (trilean's `ComparisonOperatorSchema`). #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ComparisonOperator { @@ -99,11 +108,34 @@ pub enum PredicateNode { left: ExpressionNode, right: ExpressionNode, }, + Not { + operand: Box, + }, + And { + left: Box, + right: Box, + }, + Or { + left: Box, + right: Box, + }, /// A legal `PredicateNode` kind this module has no support for, or a value that does not - /// match the `compare` shape above. Always evaluates to indeterminate. + /// match the shapes above. Always evaluates to indeterminate. Unsupported, } +/// The minted child a grant-capability's conditions are evaluated against (wire-mesh#323 +/// subject mode): exactly the claims the two registered systems read, nothing more. +pub struct GrantSubject<'a> { + pub capability: &'a str, + pub bearer: &'a DeviceId, +} + +/// Three-valued (strict Kleene) predicate result: `None` is indeterminate, and an +/// indeterminate anywhere in a tree refuses the whole conditions list, exactly as trilean's +/// own evaluator treats it. +pub type Tri = Option; + fn field<'a>(map: &'a CanonicalMap, key: &str) -> Option<&'a CborValue> { map.get(&CborValue::Text(key.to_owned())) } @@ -203,6 +235,27 @@ impl PredicateNode { }; PredicateNode::Compare { op, left, right } } + Some("not") => match field(map, "operand").map(PredicateNode::from_cbor) { + Some(operand) => PredicateNode::Not { + operand: Box::new(operand), + }, + None => PredicateNode::Unsupported, + }, + Some(kind @ ("and" | "or")) => { + let (Some(left), Some(right)) = ( + field(map, "left").map(PredicateNode::from_cbor), + field(map, "right").map(PredicateNode::from_cbor), + ) else { + return PredicateNode::Unsupported; + }; + let left = Box::new(left); + let right = Box::new(right); + if kind == "and" { + PredicateNode::And { left, right } + } else { + PredicateNode::Or { left, right } + } + } _ => PredicateNode::Unsupported, } } @@ -231,7 +284,10 @@ enum ComputedValue { Instant(String), } -fn evaluate_expression(node: &ExpressionNode) -> Option { +fn evaluate_expression( + node: &ExpressionNode, + subject: Option<&GrantSubject<'_>>, +) -> Option { match node { ExpressionNode::NumberLiteral { value, unit } => Some(ComputedValue::Number { value: *value, @@ -239,10 +295,20 @@ fn evaluate_expression(node: &ExpressionNode) -> Option { }), ExpressionNode::BooleanLiteral { value } => Some(ComputedValue::Boolean(*value)), ExpressionNode::InstantLiteral { value } => Some(ComputedValue::Instant(value.clone())), - // No delegate system is registered (see the module doc comment): every delegate - // resolves to indeterminate, matching `token-predicates.ts`'s own empty - // `extraHandlers` default exactly. - ExpressionNode::Delegate { .. } | ExpressionNode::Unsupported => None, + ExpressionNode::Delegate { system, payload } => { + let subject = subject?; + let holds = match (system.as_str(), payload) { + (GRANTEE_IS, CborValue::Text(expected)) => { + device_id_to_hex(subject.bearer) == *expected + } + (GRANTED_CAPABILITY_IS, CborValue::Text(expected)) => { + subject.capability == expected + } + _ => return None, + }; + Some(ComputedValue::Boolean(holds)) + } + ExpressionNode::Unsupported => None, } } @@ -335,23 +401,62 @@ fn days_from_civil(y: i64, m: u32, d: u32) -> i64 { era * 146_097 + doe - 719_468 } -fn evaluate_predicate(node: &PredicateNode) -> bool { +/// Strict Kleene connectives: an indeterminate operand propagates through `not` (stays +/// indeterminate, never true), short-circuits `and`/`or` only when the other leg is already +/// decisive, and refuses the whole tree otherwise. +fn not(operand: Tri) -> Tri { + operand.map(|value| !value) +} + +fn and(left: Tri, right: Tri) -> Tri { + match (left, right) { + (Some(false), _) | (_, Some(false)) => Some(false), + (Some(true), Some(true)) => Some(true), + _ => None, + } +} + +fn or(left: Tri, right: Tri) -> Tri { + match (left, right) { + (Some(true), _) | (_, Some(true)) => Some(true), + (Some(false), Some(false)) => Some(false), + _ => None, + } +} + +fn evaluate_predicate(node: &PredicateNode, subject: Option<&GrantSubject<'_>>) -> Tri { match node { PredicateNode::Compare { op, left, right } => { - let (Some(l), Some(r)) = (evaluate_expression(left), evaluate_expression(right)) else { - return false; + let (Some(l), Some(r)) = ( + evaluate_expression(left, subject), + evaluate_expression(right, subject), + ) else { + return None; }; - compare_values(*op, &l, &r) + Some(compare_values(*op, &l, &r)) } - PredicateNode::Unsupported => false, + PredicateNode::Not { operand } => not(evaluate_predicate(operand, subject)), + PredicateNode::And { left, right } => and( + evaluate_predicate(left, subject), + evaluate_predicate(right, subject), + ), + PredicateNode::Or { left, right } => or( + evaluate_predicate(left, subject), + evaluate_predicate(right, subject), + ), + PredicateNode::Unsupported => None, } } -/// Evaluates a `conditions` list as an implicit AND: every entry must hold. An empty list -/// (no `conditions` field, or one that decoded to zero entries) is trivially satisfied, -/// matching `token-predicates.ts`'s own `evaluateConditions` exactly. -pub fn evaluate_conditions(nodes: &[PredicateNode]) -> bool { - nodes.iter().all(evaluate_predicate) +/// Evaluates a `conditions` list as an implicit AND: every entry must hold definitely. An +/// empty list (no `conditions` field, or one that decoded to zero entries) is trivially +/// satisfied, matching `token-predicates.ts`'s own `evaluateConditions` exactly. `subject` +/// is the minted child in subject mode (wire-mesh#323), and `None` evaluates against the +/// carrying token's own claims, where the two subject systems are indeterminate. +pub fn evaluate_conditions(nodes: &[PredicateNode], subject: Option<&GrantSubject<'_>>) -> bool { + nodes + .iter() + .all(|node| evaluate_predicate(node, subject) == Some(true)) } #[cfg(test)] @@ -359,7 +464,7 @@ mod tests { use super::*; fn compare(op: ComparisonOperator, left: ExpressionNode, right: ExpressionNode) -> bool { - evaluate_predicate(&PredicateNode::Compare { op, left, right }) + evaluate_predicate(&PredicateNode::Compare { op, left, right }, None) == Some(true) } fn number(value: f64) -> ExpressionNode { @@ -425,7 +530,7 @@ mod tests { #[test] fn a_delegate_operand_is_always_indeterminate() { - // No system is registered (see the module doc comment): a compare against a delegate + // No subject is supplied (see the module doc comment): a compare against a delegate // operand can never resolve, regardless of the other operand. let delegate = ExpressionNode::Delegate { system: "anything".to_owned(), @@ -456,7 +561,7 @@ mod tests { } let nodes = decode_conditions(&buf).expect("a well-formed array must still decode"); assert_eq!(nodes, vec![PredicateNode::Unsupported]); - assert!(!evaluate_conditions(&nodes)); + assert!(!evaluate_conditions(&nodes, None)); } #[test] @@ -490,12 +595,85 @@ mod tests { right: ExpressionNode::BooleanLiteral { value: true }, }] ); - // No system is registered, so this still fails closed even though it decoded cleanly. - assert!(!evaluate_conditions(&nodes)); + // No subject is supplied, so the delegate stays indeterminate and fails closed even + // though it decoded cleanly. + assert!(!evaluate_conditions(&nodes, None)); } #[test] fn empty_conditions_list_is_trivially_satisfied() { - assert!(evaluate_conditions(&[])); + assert!(evaluate_conditions(&[], None)); + } + + #[test] + fn not_and_or_follow_strict_kleene_semantics() { + let t = || PredicateNode::Compare { + op: ComparisonOperator::Eq, + left: ExpressionNode::BooleanLiteral { value: true }, + right: ExpressionNode::BooleanLiteral { value: true }, + }; + let f = || PredicateNode::Compare { + op: ComparisonOperator::Eq, + left: ExpressionNode::BooleanLiteral { value: false }, + right: ExpressionNode::BooleanLiteral { value: true }, + }; + let indet = || PredicateNode::Unsupported; + let eval = |node: &PredicateNode| evaluate_predicate(node, None); + assert_eq!(eval(&t()), Some(true)); + assert_eq!(not(eval(&f())), Some(true)); + assert_eq!(not(eval(&indet())), None); + assert_eq!(and(eval(&f()), eval(&indet())), Some(false)); + assert_eq!(and(eval(&t()), eval(&indet())), None); + assert_eq!(or(eval(&t()), eval(&indet())), Some(true)); + assert_eq!(or(eval(&f()), eval(&indet())), None); + } + + #[test] + fn subject_systems_read_the_subject_and_fail_closed_without_one() { + let bearer = DeviceId([0xAB; 32]); + let subject = GrantSubject { + capability: "exec:pty", + bearer: &bearer, + }; + let node = |system: &str, payload: CborValue| PredicateNode::Compare { + op: ComparisonOperator::Eq, + left: ExpressionNode::Delegate { + system: system.to_owned(), + payload, + }, + right: ExpressionNode::BooleanLiteral { value: true }, + }; + let bearer_hex = device_id_to_hex(&bearer); + assert!( + evaluate_predicate( + &node(GRANTEE_IS, CborValue::Text(bearer_hex.clone())), + Some(&subject) + ) == Some(true) + ); + assert!( + evaluate_predicate( + &node(GRANTEE_IS, CborValue::Text("00".to_owned())), + Some(&subject) + ) == Some(false) + ); + assert!( + evaluate_predicate( + &node( + GRANTED_CAPABILITY_IS, + CborValue::Text("exec:pty".to_owned()) + ), + Some(&subject) + ) == Some(true) + ); + // No subject, a non-text payload, or an unregistered system: indeterminate, fail closed. + assert!(evaluate_predicate(&node(GRANTEE_IS, CborValue::Text(bearer_hex)), None).is_none()); + assert!( + evaluate_predicate(&node(GRANTEE_IS, CborValue::UInt(7)), Some(&subject)).is_none() + ); + assert!(evaluate_predicate( + &node("other-system", CborValue::Text("x".to_owned())), + Some(&subject) + ) + .is_none()); } } From 8c3d55bf514fa1c2912b116ae8d57b2962ab2426 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 17:55:51 +0100 Subject: [PATCH 5/8] feat(rust): walk the authorised-by link in the token verifier The iterative walk tracks which claim links each pair of tokens, upholds the authorisation obligations (bearer, manage:grant verb, grants-capability, scope and expiry narrowing, one delegations-remaining hop, subject-mode conditions against the minted child) and closes the gap that left delegations-remaining unchecked on parent links. Tests mirror the TypeScript matrix. --- rust/crates/wire-mesh-core/src/domain/room.rs | 6 + .../src/domain/room_token_verification.rs | 6 + .../wire-mesh-core/src/domain/tokens.rs | 545 +++++++++++++++++- 3 files changed, 528 insertions(+), 29 deletions(-) diff --git a/rust/crates/wire-mesh-core/src/domain/room.rs b/rust/crates/wire-mesh-core/src/domain/room.rs index 9cd9b761..ec26d1fe 100644 --- a/rust/crates/wire-mesh-core/src/domain/room.rs +++ b/rust/crates/wire-mesh-core/src/domain/room.rs @@ -242,6 +242,9 @@ mod tests { valid_until: None, conditions: None, parent: None, + delegations_remaining: None, + authorised_by: None, + grants_capability: None, extra: CanonicalMap::new(), }; issuer @@ -786,6 +789,9 @@ mod tests { valid_until: None, conditions: None, parent: None, + delegations_remaining: None, + authorised_by: None, + grants_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 da4799af..cb8877dc 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 @@ -179,6 +179,9 @@ mod tests { valid_until: None, conditions: None, parent: None, + delegations_remaining: None, + authorised_by: None, + grants_capability: None, extra: CanonicalMap::new(), }; issuer @@ -339,6 +342,9 @@ mod tests { valid_until: None, conditions: None, parent: None, + delegations_remaining: None, + authorised_by: None, + grants_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 f035ddea..19f93263 100644 --- a/rust/crates/wire-mesh-core/src/domain/tokens.rs +++ b/rust/crates/wire-mesh-core/src/domain/tokens.rs @@ -25,11 +25,19 @@ const REVOKE_CAPABILITY: &str = "manage:revoke"; pub const MAX_CHAIN_DEPTH: usize = 64; /// Why a token was rejected. +/// The capability verb of a grant-capability (wire-mesh#323), kept in sync with +/// `spec/registry/core-capabilities.md` and the TS core's own constant. +pub const MANAGE_GRANT_CAPABILITY: &str = "manage:grant"; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum TokenRejection { /// Not a well-formed COSE_Sign1 over token-claims, or the chain is /// malformed somewhere. Malformed(String), + /// The authorised-by link's obligations failed: the cited grant-capability + /// did not fully verify, or the bearer/verb/scope/expiry/depth rules of the + /// link, or its subject-mode conditions against the minted child. + AuthorisationInvalid(&'static str), /// The issuer-key's COSE algorithm is not one this verifier implements. UnsupportedAlgorithm(i64), /// The signature over the Sig_structure does not verify against the @@ -90,6 +98,9 @@ impl core::fmt::Display for TokenRejection { fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { match self { TokenRejection::Malformed(m) => write!(f, "malformed token: {m}"), + TokenRejection::AuthorisationInvalid(why) => { + write!(f, "authorised-by link invalid: {why}") + } TokenRejection::UnsupportedAlgorithm(alg) => { write!(f, "unsupported COSE algorithm {alg}") } @@ -267,6 +278,15 @@ async fn revocation_entry_grants_revoke( } } +/// Which claim links the token below to the one above it (wire-mesh#323): a `parent` +/// link narrows authority, an `authorised-by` link permits a mint. One token carries at +/// most one of them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum LinkKind { + Parent, + Authorisation, +} + /// Verify a capability token, walking its whole delegation chain. /// /// For every link from the presented token up to its root: @@ -283,10 +303,16 @@ async fn revocation_entry_grants_revoke( /// ([`revocation_entry_grants_revoke`], wire-mesh#84), /// 4. the link's expiry, not-before, and valid-until windows hold at /// `clock.now()`, -/// 5. against the link *below* it: the parent's bearer is the child's -/// issuer, the capability verb is unchanged, the child's scope lies -/// within the parent's, and the child's expiry does not exceed the -/// parent's — narrowing only, never widening. +/// 5. against the link *below* it, per that link's own kind ([`LinkKind`]): +/// a parent link narrows (the parent's bearer is the child's issuer, the +/// verb is unchanged, the child's scope and expiry stay within the +/// parent's, and delegations-remaining consumes a hop), and an +/// authorised-by link upholds the mint's authorisation (the authoriser's +/// bearer is the child's issuer, its verb is manage:grant and matches its +/// grants-capability, the child's scope and expiry stay within the +/// authoriser's, delegations-remaining consumes a hop, and its +/// conditions hold against the child's claims) — narrowing only, never +/// widening, on either link. pub async fn verify_capability_token( identity: &dyn Identity, clock: &dyn Clock, @@ -298,6 +324,7 @@ pub async fn verify_capability_token( let now = clock.now_unix_ms(); let mut current = token.clone(); let mut child: Option = None; + let mut child_link: Option = None; let mut leaf: Option = None; let mut effective_expires = u64::MAX; let mut seen: Vec> = Vec::new(); @@ -387,40 +414,120 @@ pub async fn verify_capability_token( }); } } + // The two link kinds are mutually exclusive, and a manage:grant token is minted only + // through authorised-by, so an issuer's subject-mode bars can never be sidestepped by + // minting the next grant-capability through a parent link instead. + match (claims.parent.is_some(), claims.authorised_by.is_some()) { + (true, true) => { + return TokenVerdict::Invalid(TokenRejection::Malformed( + "a token carries both parent and authorised-by".to_owned(), + )); + } + (true, false) if claims.capability.0 == MANAGE_GRANT_CAPABILITY => { + return TokenVerdict::Invalid(TokenRejection::AuthorisationInvalid( + "a manage:grant token carries a parent link", + )); + } + _ => {} + } + if let Some(conditions_bytes) = &claims.conditions { let nodes = match crate::domain::predicates::decode_conditions(conditions_bytes) { Ok(nodes) => nodes, Err(e) => return TokenVerdict::Invalid(TokenRejection::Malformed(e.to_string())), }; - if !crate::domain::predicates::evaluate_conditions(&nodes) { + // Subject mode (wire-mesh#323): a token reached through an authorised-by link has + // its conditions evaluated against the child it authorised, the minted token below + // it; every other link (and the leaf itself) evaluates against its own claims, + // where the subject systems are indeterminate and fail closed. + let subject = match (&child, child_link) { + (Some(child_claims), Some(LinkKind::Authorisation)) => { + Some(crate::domain::predicates::GrantSubject { + capability: &child_claims.capability.0, + bearer: &child_claims.bearer, + }) + } + _ => None, + }; + if !crate::domain::predicates::evaluate_conditions(&nodes, subject.as_ref()) { return TokenVerdict::Invalid(TokenRejection::ConditionsNotSatisfied { token_id: claims.token_id, }); } } - // Narrowing against the link below, checked from the parent's - // side of the relationship. + // The link obligations, checked from the upper token's side of the relationship. if let Some(child_claims) = &child { - if child_claims.issuer != claims.bearer { - return TokenVerdict::Invalid(TokenRejection::NotNarrowed( - "the parent's bearer is not the child's issuer", - )); - } - if child_claims.capability != claims.capability { - return TokenVerdict::Invalid(TokenRejection::NotNarrowed( - "the delegated capability verb differs from the parent's", - )); - } - if !child_claims.scope.is_within(&claims.scope) { - return TokenVerdict::Invalid(TokenRejection::NotNarrowed( - "the delegated scope is not within the parent's subtree", - )); - } - if child_claims.expires > claims.expires { - return TokenVerdict::Invalid(TokenRejection::NotNarrowed( - "the delegated expiry is later than the parent's", - )); + match child_link { + Some(LinkKind::Parent) => { + if child_claims.issuer != claims.bearer { + return TokenVerdict::Invalid(TokenRejection::NotNarrowed( + "the parent's bearer is not the child's issuer", + )); + } + if child_claims.capability != claims.capability { + return TokenVerdict::Invalid(TokenRejection::NotNarrowed( + "the delegated capability verb differs from the parent's", + )); + } + if !child_claims.scope.is_within(&claims.scope) { + return TokenVerdict::Invalid(TokenRejection::NotNarrowed( + "the delegated scope is not within the parent's subtree", + )); + } + if child_claims.expires > claims.expires { + return TokenVerdict::Invalid(TokenRejection::NotNarrowed( + "the delegated expiry is later than the parent's", + )); + } + // delegations-remaining consumes a hop down a parent link too: a bounded + // parent must not be re-delegated into an unbounded or equal child. + if let Some(remaining) = claims.delegations_remaining { + if child_claims + .delegations_remaining + .map_or(true, |child_value| child_value >= remaining) + { + return TokenVerdict::Invalid(TokenRejection::NotNarrowed( + "the child's delegations-remaining is not strictly less", + )); + } + } + } + Some(LinkKind::Authorisation) => { + let invalid = |why: &'static str| { + TokenVerdict::Invalid(TokenRejection::AuthorisationInvalid(why)) + }; + if claims.bearer != child_claims.issuer { + return invalid("the authoriser's bearer is not the minted token's issuer"); + } + if claims.capability.0 != MANAGE_GRANT_CAPABILITY { + return invalid("the authorising capability is not manage:grant"); + } + if let Some(grants) = &claims.grants_capability { + if *grants != child_claims.capability { + return invalid( + "the minted verb differs from the authoriser's grants-capability", + ); + } + } + if !child_claims.scope.is_within(&claims.scope) { + return invalid("the minted scope is not within the authoriser's"); + } + if child_claims.expires > claims.expires { + return invalid("the minted expiry is later than the authoriser's"); + } + if let Some(remaining) = claims.delegations_remaining { + if child_claims + .delegations_remaining + .map_or(true, |child_value| child_value >= remaining) + { + return invalid( + "the minted delegations-remaining is not strictly less", + ); + } + } + } + None => {} } } @@ -429,8 +536,9 @@ pub async fn verify_capability_token( leaf = Some(claims.clone()); } - match claims.parent.clone() { - Some(parent) => { + match (claims.parent.clone(), claims.authorised_by.clone()) { + (Some(parent), _) => { + child_link = Some(LinkKind::Parent); child = Some(claims); match CoseSign1::decode_bytes(&parent) { Ok(next) => current = next, @@ -439,7 +547,17 @@ pub async fn verify_capability_token( } } } - None => { + (None, Some(authoriser)) => { + child_link = Some(LinkKind::Authorisation); + child = Some(claims); + match CoseSign1::decode_bytes(&authoriser) { + Ok(next) => current = next, + Err(e) => { + return TokenVerdict::Invalid(TokenRejection::Malformed(e.to_string())) + } + } + } + (None, None) => { let root_issuer = claims.issuer; let root_issuer_key = claims.issuer_key.clone(); return match leaf { @@ -496,6 +614,7 @@ mod tests { use super::*; use crate::adapters::node_identity::NodeIdentity; use crate::domain::revocation::RevocationView; + use crate::domain::room_path::device_id_to_hex; use wire_mesh_wire::management::{RevocationClaims, RevocationEntry}; use wire_mesh_wire::tokens::{CapabilityScope, CapabilityVerb}; use wire_mesh_wire::value::CanonicalMap; @@ -526,6 +645,9 @@ mod tests { valid_until: None, conditions: None, parent: None, + delegations_remaining: None, + authorised_by: None, + grants_capability: None, extra: CanonicalMap::new(), } } @@ -550,6 +672,9 @@ mod tests { valid_until: None, conditions: None, parent: None, + delegations_remaining: None, + authorised_by: None, + grants_capability: None, extra: CanonicalMap::new(), } } @@ -1573,4 +1698,366 @@ mod tests { assert!(!verdict_authorises(&verdict, "pin:write", &outside)); assert!(!verdict_authorises(&verdict, "pin:read", &inside)); } + + // wire-mesh#323: the authorised-by link. A grant-capability (manage:grant) held by a + // deputy authorises the deputy to mint other capability tokens; the minted token cites + // the authoriser, whose conditions are evaluated against the minted child's claims. + + const GRANT_TOKEN_ID: [u8; 16] = [0x51; 16]; + const MINTED_TOKEN_ID: [u8; 16] = [0x52; 16]; + + /// The CBOR bytes of a no-self-grant bar: not(and(granted-capability-is verb, + /// grantee-is bearer-hex)), the exact node shape the TS side's noSelfGrantBar builds. + fn bar_conditions(verb: &str, bearer_hex: &str) -> Vec { + fn delegate_compare(e: &mut minicbor::Encoder<&mut Vec>, system: &str, payload: &str) { + e.map(3).unwrap(); + e.str("kind").unwrap().str("delegate").unwrap(); + e.str("system").unwrap().str(system).unwrap(); + e.str("payload").unwrap().str(payload).unwrap(); + } + fn boolean_true(e: &mut minicbor::Encoder<&mut Vec>) { + e.map(2).unwrap(); + e.str("kind").unwrap().str("booleanLiteral").unwrap(); + e.str("value").unwrap().bool(true).unwrap(); + } + let mut buf = Vec::new(); + let mut e = minicbor::Encoder::new(&mut buf); + e.array(1).unwrap(); + e.map(2).unwrap(); + e.str("kind").unwrap().str("not").unwrap(); + e.str("operand").unwrap(); + e.map(3).unwrap(); + e.str("kind").unwrap().str("and").unwrap(); + e.str("left").unwrap(); + e.map(4).unwrap(); + e.str("op").unwrap().str("eq").unwrap(); + e.str("kind").unwrap().str("compare").unwrap(); + e.str("left").unwrap(); + delegate_compare(&mut e, "granted-capability-is", verb); + e.str("right").unwrap(); + boolean_true(&mut e); + e.str("right").unwrap(); + e.map(4).unwrap(); + e.str("op").unwrap().str("eq").unwrap(); + e.str("kind").unwrap().str("compare").unwrap(); + e.str("left").unwrap(); + delegate_compare(&mut e, "grantee-is", bearer_hex); + e.str("right").unwrap(); + boolean_true(&mut e); + buf + } + + /// A root manage:grant (signed by owner, naming exec:pty) plus an exec:pty token the + /// deputy minted under it, with the mutations applied before each signature. + async fn mint_under_grant( + owner: &NodeIdentity, + mutate_grant: impl FnOnce(&mut TokenClaims), + mutate_child: impl FnOnce(&mut TokenClaims), + ) -> (NodeIdentity, CoseSign1, CoseSign1) { + let deputy = NodeIdentity::generate_ed25519(); + let mut grant_claims = claims_for(owner, *deputy.device_id()); + grant_claims.token_id = GRANT_TOKEN_ID.to_vec(); + grant_claims.capability = CapabilityVerb(MANAGE_GRANT_CAPABILITY.to_owned()); + grant_claims.grants_capability = Some(CapabilityVerb("exec:pty".to_owned())); + mutate_grant(&mut grant_claims); + let grant = mint(owner, &grant_claims).await; + + let mut child_claims = claims_for(&deputy, DeviceId([0xCC; 32])); + child_claims.token_id = MINTED_TOKEN_ID.to_vec(); + child_claims.capability = CapabilityVerb("exec:pty".to_owned()); + child_claims.scope = CapabilityScope { + kind: "folder".to_owned(), + path: Some("/work/subdir".to_owned()), + }; + child_claims.expires = NOW + 30_000; + child_claims.authorised_by = Some(grant.encode_to_vec()); + mutate_child(&mut child_claims); + let child = mint(&deputy, &child_claims).await; + (deputy, grant, child) + } + + #[tokio::test] + async fn a_token_minted_under_a_grant_capability_verifies_with_the_owners_root() { + let owner = NodeIdentity::generate_ed25519(); + let (_, _, child) = mint_under_grant(&owner, |_| {}, |_| {}).await; + match verify(&owner, &RevocationView::new(), &child).await { + TokenVerdict::Valid { root_issuer, .. } => { + assert_eq!(root_issuer, *owner.device_id()); + } + other => panic!("expected Valid, got {other:?}"), + } + } + + #[tokio::test] + async fn a_minted_verb_the_authoriser_does_not_name_is_refused() { + let owner = NodeIdentity::generate_ed25519(); + let (_, _, child) = mint_under_grant( + &owner, + |_| {}, + |child| { + child.capability = CapabilityVerb("pin:write".to_owned()); + }, + ) + .await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &child).await, + TokenVerdict::Invalid(TokenRejection::AuthorisationInvalid(_)) + )); + } + + #[tokio::test] + async fn an_absent_grants_capability_means_any_verb_within_scope() { + let owner = NodeIdentity::generate_ed25519(); + let (_, _, child) = mint_under_grant( + &owner, + |grant| { + grant.grants_capability = None; + }, + |child| { + child.capability = CapabilityVerb("pin:write".to_owned()); + }, + ) + .await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &child).await, + TokenVerdict::Valid { .. } + )); + } + + #[tokio::test] + async fn a_mint_by_someone_other_than_the_authorisers_bearer_is_refused() { + let owner = NodeIdentity::generate_ed25519(); + let (_, grant, _) = mint_under_grant(&owner, |_| {}, |_| {}).await; + let impostor = NodeIdentity::generate_ed25519(); + let mut child_claims = claims_for(&impostor, DeviceId([0xCC; 32])); + child_claims.token_id = MINTED_TOKEN_ID.to_vec(); + child_claims.capability = CapabilityVerb("exec:pty".to_owned()); + child_claims.scope = CapabilityScope { + kind: "folder".to_owned(), + path: Some("/work/subdir".to_owned()), + }; + child_claims.expires = NOW + 30_000; + child_claims.authorised_by = Some(grant.encode_to_vec()); + let child = mint(&impostor, &child_claims).await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &child).await, + TokenVerdict::Invalid(TokenRejection::AuthorisationInvalid(_)) + )); + } + + #[tokio::test] + async fn a_mint_whose_scope_or_expiry_widens_the_authoriser_is_refused() { + let owner = NodeIdentity::generate_ed25519(); + let (_, _, widened_scope) = mint_under_grant( + &owner, + |_| {}, + |child| { + child.scope = CapabilityScope { + kind: "folder".to_owned(), + path: Some("/other".to_owned()), + }; + }, + ) + .await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &widened_scope).await, + TokenVerdict::Invalid(TokenRejection::AuthorisationInvalid(_)) + )); + + let (_, _, outlives) = mint_under_grant( + &owner, + |_| {}, + |child| { + child.expires = NOW + 90_000; + }, + ) + .await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &outlives).await, + TokenVerdict::Invalid(TokenRejection::AuthorisationInvalid(_)) + )); + } + + #[tokio::test] + async fn delegations_remaining_consumes_a_hop_across_either_link() { + let owner = NodeIdentity::generate_ed25519(); + let (_, _, ok_child) = mint_under_grant( + &owner, + |grant| { + grant.delegations_remaining = Some(1); + }, + |child| { + child.delegations_remaining = Some(0); + }, + ) + .await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &ok_child).await, + TokenVerdict::Valid { .. } + )); + + let (_, _, absent_child) = mint_under_grant( + &owner, + |grant| { + grant.delegations_remaining = Some(1); + }, + |_| {}, + ) + .await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &absent_child).await, + TokenVerdict::Invalid(TokenRejection::AuthorisationInvalid(_)) + )); + + // The parent-link rule, previously unenforced in this implementation: a bounded + // parent must not be re-delegated into an unbounded child. + let parent = NodeIdentity::generate_ed25519(); + let delegate = NodeIdentity::generate_ed25519(); + let mut parent_claims = claims_for(&parent, *delegate.device_id()); + parent_claims.delegations_remaining = Some(1); + let parent_token = mint(&parent, &parent_claims).await; + let mut child_claims = claims_for(&delegate, DeviceId([0xDD; 32])); + // A distinct token-id from the parent's: the cycle guard keys on it. + child_claims.token_id = vec![0x53; 16]; + child_claims.scope = CapabilityScope { + kind: "folder".to_owned(), + path: Some("/work/subdir".to_owned()), + }; + child_claims.expires = NOW + 30_000; + child_claims.parent = Some(parent_token.encode_to_vec()); + let child = mint(&delegate, &child_claims).await; + assert!(matches!( + verify(&parent, &RevocationView::new(), &child).await, + TokenVerdict::Invalid(TokenRejection::NotNarrowed(_)) + )); + } + + #[tokio::test] + async fn the_delegate_without_use_bar_binds_the_minted_childs_claims() { + let owner = NodeIdentity::generate_ed25519(); + // The bar names the grant's own bearer (the deputy), computed from the grant claims + // the mutation runs against. + let (_, _, third_partys) = mint_under_grant( + &owner, + |grant| { + grant.conditions = + Some(bar_conditions("exec:pty", &device_id_to_hex(&grant.bearer))); + }, + |child| { + child.bearer = DeviceId([0xEE; 32]); + }, + ) + .await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &third_partys).await, + TokenVerdict::Valid { .. } + )); + + let (deputy, _, self_borne) = mint_under_grant( + &owner, + |grant| { + grant.conditions = + Some(bar_conditions("exec:pty", &device_id_to_hex(&grant.bearer))); + }, + |child| { + child.bearer = deputy_bearer(child); + }, + ) + .await; + drop(deputy); + assert!(matches!( + verify(&owner, &RevocationView::new(), &self_borne).await, + TokenVerdict::Invalid(TokenRejection::ConditionsNotSatisfied { .. }) + )); + } + + /// The deputy's own device-id for a self-borne child: reads it back out of the child's + /// authoriser link, which names the grant whose bearer the bar pinned. + fn deputy_bearer(child: &TokenClaims) -> DeviceId { + let authoriser = child + .authorised_by + .as_ref() + .expect("child cites an authoriser"); + let authoriser_claims = CoseSign1::decode_bytes(authoriser).expect("authoriser decodes"); + authoriser_claims + .decode_claims() + .expect("authoriser claims decode") + .bearer + } + + #[tokio::test] + async fn both_links_at_once_and_a_manage_grant_with_a_parent_are_refused() { + let owner = NodeIdentity::generate_ed25519(); + let (_, grant, _) = mint_under_grant(&owner, |_| {}, |_| {}).await; + let deputy = NodeIdentity::generate_ed25519(); + let root = mint(&owner, &claims_for(&owner, *deputy.device_id())).await; + let mut both = claims_for(&deputy, DeviceId([0xCC; 32])); + both.token_id = MINTED_TOKEN_ID.to_vec(); + both.parent = Some(root.encode_to_vec()); + both.authorised_by = Some(grant.encode_to_vec()); + let both_token = mint(&deputy, &both).await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &both_token).await, + TokenVerdict::Invalid(TokenRejection::Malformed(_)) + )); + + let mut grant_via_parent = claims_for(&deputy, DeviceId([0xCC; 32])); + grant_via_parent.token_id = MINTED_TOKEN_ID.to_vec(); + grant_via_parent.capability = CapabilityVerb(MANAGE_GRANT_CAPABILITY.to_owned()); + grant_via_parent.parent = Some(root.encode_to_vec()); + let grant_via_parent_token = mint(&deputy, &grant_via_parent).await; + assert!(matches!( + verify(&owner, &RevocationView::new(), &grant_via_parent_token).await, + TokenVerdict::Invalid(TokenRejection::AuthorisationInvalid(_)) + )); + } + + #[tokio::test] + async fn revoking_the_grant_capability_revokes_every_grant_minted_under_it() { + let owner = NodeIdentity::generate_ed25519(); + let (_, grant, child) = mint_under_grant(&owner, |_| {}, |_| {}).await; + drop(grant); + let mut view = RevocationView::new(); + view.verify_and_insert( + &mint_revocation(&owner, GRANT_TOKEN_ID.to_vec()).await, + &owner, + ) + .await + .expect("entry verifies"); + assert!(matches!( + verify(&owner, &view, &child).await, + TokenVerdict::Invalid(TokenRejection::Revoked { .. }) + )); + } + + #[tokio::test] + async fn nested_grant_capabilities_walk_to_the_topmost_issuer() { + let owner = NodeIdentity::generate_ed25519(); + let (deputy, _, _) = mint_under_grant(&owner, |_| {}, |_| {}).await; + // g1 names manage:grant, so the deputy may mint a second grant-capability under it. + let mut g1_claims = claims_for(&owner, *deputy.device_id()); + g1_claims.token_id = GRANT_TOKEN_ID.to_vec(); + g1_claims.capability = CapabilityVerb(MANAGE_GRANT_CAPABILITY.to_owned()); + g1_claims.grants_capability = Some(CapabilityVerb(MANAGE_GRANT_CAPABILITY.to_owned())); + let g1_token = mint(&owner, &g1_claims).await; + let third = NodeIdentity::generate_ed25519(); + let mut g2_claims = claims_for(&deputy, *third.device_id()); + g2_claims.token_id = MINTED_TOKEN_ID.to_vec(); + g2_claims.capability = CapabilityVerb(MANAGE_GRANT_CAPABILITY.to_owned()); + g2_claims.expires = NOW + 30_000; + g2_claims.authorised_by = Some(g1_token.encode_to_vec()); + let g2 = mint(&deputy, &g2_claims).await; + let mut leaf = claims_for(&third, DeviceId([0xBB; 32])); + leaf.token_id = vec![0x53; 16]; + leaf.capability = CapabilityVerb("exec:pty".to_owned()); + leaf.expires = NOW + 20_000; + leaf.authorised_by = Some(g2.encode_to_vec()); + let leaf_token = mint(&third, &leaf).await; + match verify(&owner, &RevocationView::new(), &leaf_token).await { + TokenVerdict::Valid { root_issuer, .. } => { + assert_eq!(root_issuer, *owner.device_id()); + } + other => panic!("expected Valid, got {other:?}"), + } + } } From 67bae1e2459f52f892f0ef0b8f4643301fc2fe65 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 17:55:51 +0100 Subject: [PATCH 6/8] test(conformance): freeze grant-permission vectors A manage:grant root with the no-self-grant bar in its conditions, an exec:pty mint authorised by it, and a second-level grant-capability passed onward, pinning the claims' wire shape across implementations. --- conformance/generate.ts | 102 +++++++++++++++++++++++++++++++++++++ conformance/tokens.v1.json | 48 +++++++++++++++++ 2 files changed, 150 insertions(+) diff --git a/conformance/generate.ts b/conformance/generate.ts index 73ef5e95..84fd59aa 100644 --- a/conformance/generate.ts +++ b/conformance/generate.ts @@ -127,6 +127,105 @@ const deviceAHex = "11".repeat(SHA256_BYTE_LENGTH); const deviceBHex = "22".repeat(SHA256_BYTE_LENGTH); const deviceCHex = "33".repeat(SHA256_BYTE_LENGTH); +// wire-mesh#323: the grant permission. A manage:grant root (deviceA grants deviceB the +// right to mint, any verb within /work since grants-capability is absent, one hop deep) +// carrying the named no-self-grant bar in its conditions -- not(and(granted-capability-is +// "exec:pty", grantee-is )) -- then an exec:pty token deviceB minted under it +// (citing it via authorised-by, granted to deviceC so the bar holds), and a second-level +// manage:grant deviceB minted onward to deviceC, itself naming exec:pty. +const noSelfGrantBar = { + kind: "not", + operand: { + kind: "and", + left: { + kind: "compare", + op: "eq", + left: { + kind: "delegate", + system: "granted-capability-is", + payload: "exec:pty", + }, + right: { kind: "booleanLiteral", value: true }, + }, + right: { + kind: "compare", + op: "eq", + left: { kind: "delegate", system: "grantee-is", payload: deviceBHex }, + right: { kind: "booleanLiteral", value: true }, + }, + }, +}; +const noSelfGrantBarBytes = Buffer.from( + encode([noSelfGrantBar], cdeEncodeOptions), +).toString("hex"); + +const manageGrantRootTokenClaims: JsonWire = { + "token-id": hex("05".repeat(TOKEN_ID_BYTE_LENGTH)), + issuer: deviceA, + "issuer-key": { alg: -7, "public-key": publicKeyEs256A }, + bearer: deviceB, + capability: "manage:grant", + scope: { kind: "folder", path: "/work" }, + expires: 1893456000000, + "delegations-remaining": 1, + conditions: hex(noSelfGrantBarBytes), +}; +const manageGrantRootToken: JsonWire = [ + hex(wireHex({ 1: -7, 4: deviceA })), + {}, + hex(wireHex(manageGrantRootTokenClaims)), + signatureFiller, +]; +const manageGrantRootTokenVector = vector( + "capability_token_v1_manage_grant_root", + manageGrantRootToken, +); + +const grantAuthorisedTokenClaims: JsonWire = { + "token-id": hex("06".repeat(TOKEN_ID_BYTE_LENGTH)), + issuer: deviceB, + "issuer-key": { alg: -7, "public-key": publicKeyEs256B }, + bearer: deviceC, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: 1861920000000, + "authorised-by": hex(manageGrantRootTokenVector.wire_hex), + "delegations-remaining": 0, +}; +const grantAuthorisedToken: JsonWire = [ + hex(wireHex({ 1: -7, 4: deviceB })), + {}, + hex(wireHex(grantAuthorisedTokenClaims)), + signatureFiller, +]; +const grantAuthorisedTokenVector = vector( + "capability_token_v1_grant_authorised_exec_pty", + grantAuthorisedToken, +); + +const manageGrantDelegatedTokenClaims: JsonWire = { + "token-id": hex("07".repeat(TOKEN_ID_BYTE_LENGTH)), + issuer: deviceB, + "issuer-key": { alg: -7, "public-key": publicKeyEs256B }, + bearer: deviceC, + capability: "manage:grant", + scope: { kind: "folder", path: "/work" }, + expires: 1861920000000, + "grants-capability": "exec:pty", + "authorised-by": hex(manageGrantRootTokenVector.wire_hex), + "delegations-remaining": 0, +}; +const manageGrantDelegatedToken: JsonWire = [ + hex(wireHex({ 1: -7, 4: deviceB })), + {}, + hex(wireHex(manageGrantDelegatedTokenClaims)), + signatureFiller, +]; +const manageGrantDelegatedTokenVector = vector( + "capability_token_v1_manage_grant_delegated", + manageGrantDelegatedToken, +); + // A room:member grant chain demonstrating this session's own delegations-remaining fix: the owner (deviceA) issues a root grant to deviceB capped at one further re-delegation, and deviceB narrows it (a strictly lower value, 0) when re-delegating to deviceC -- deviceC's own token therefore bears no further-delegation authority at all, closing the unbounded-admission gap the claim exists to fix. const roomMemberRootTokenClaims: JsonWire = { "token-id": hex("03".repeat(TOKEN_ID_BYTE_LENGTH)), @@ -268,6 +367,9 @@ const handleRecordVector = vector("handle_record_v1_dns_anchored", [ const tokenVectors: Vector[] = [ rootTokenVector, delegatedTokenVector, + manageGrantRootTokenVector, + grantAuthorisedTokenVector, + manageGrantDelegatedTokenVector, roomMemberRootTokenVector, roomMemberDelegatedTokenVector, roomNoticeVector, diff --git a/conformance/tokens.v1.json b/conformance/tokens.v1.json index 1442ba5a..60865127 100644 --- a/conformance/tokens.v1.json +++ b/conformance/tokens.v1.json @@ -34,6 +34,54 @@ ], "wire_hex": "845828a2613126613458202222222222222222222222222222222222222222222222222222222222222222a05902a0a86573636f7065a2646b696e6466666f6c64657264706174686c2f776f726b2f7375626469726662656172657258203333333333333333333333333333333333333333333333333333333333333333666973737565725820222222222222222222222222222222222222222222222222222222222222222266706172656e74590180845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059010fa76573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450010101010101010101010101010101016a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff67657870697265731b000001b18314880068746f6b656e2d696450020202020202020202020202020202026a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd5840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" }, + { + "name": "capability_token_v1_manage_grant_root", + "message": [ + { + "hex": "a2613126613458201111111111111111111111111111111111111111111111111111111111111111" + }, + {}, + { + "hex": "a96573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450050505050505050505050505050505056a6361706162696c6974796c6d616e6167653a6772616e746a636f6e646974696f6e7359014681a2646b696e64636e6f74676f706572616e64a3646b696e6463616e64646c656674a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d756772616e7465642d6361706162696c6974792d6973677061796c6f616468657865633a707479657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f5657269676874a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d6a6772616e7465652d6973677061796c6f6164784032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f56a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e6701" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + "wire_hex": "845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059027ea96573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450050505050505050505050505050505056a6361706162696c6974796c6d616e6167653a6772616e746a636f6e646974696f6e7359014681a2646b696e64636e6f74676f706572616e64a3646b696e6463616e64646c656674a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d756772616e7465642d6361706162696c6974792d6973677061796c6f616468657865633a707479657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f5657269676874a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d6a6772616e7465652d6973677061796c6f6164784032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f56a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "name": "capability_token_v1_grant_authorised_exec_pty", + "message": [ + { + "hex": "a2613126613458202222222222222222222222222222222222222222222222222222222222222222" + }, + {}, + { + "hex": "a96573636f7065a2646b696e6466666f6c64657264706174686c2f776f726b2f7375626469726662656172657258203333333333333333333333333333333333333333333333333333333333333333666973737565725820222222222222222222222222222222222222222222222222222222222222222267657870697265731b000001b18314880068746f6b656e2d696450060606060606060606060606060606066a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd6d617574686f72697365642d62795902ef845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059027ea96573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450050505050505050505050505050505056a6361706162696c6974796c6d616e6167653a6772616e746a636f6e646974696f6e7359014681a2646b696e64636e6f74676f706572616e64a3646b696e6463616e64646c656674a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d756772616e7465642d6361706162696c6974792d6973677061796c6f616468657865633a707479657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f5657269676874a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d6a6772616e7465652d6973677061796c6f6164784032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f56a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7564656c65676174696f6e732d72656d61696e696e6700" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + "wire_hex": "845828a2613126613458202222222222222222222222222222222222222222222222222222222222222222a059042da96573636f7065a2646b696e6466666f6c64657264706174686c2f776f726b2f7375626469726662656172657258203333333333333333333333333333333333333333333333333333333333333333666973737565725820222222222222222222222222222222222222222222222222222222222222222267657870697265731b000001b18314880068746f6b656e2d696450060606060606060606060606060606066a6361706162696c69747968657865633a7074796a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd6d617574686f72697365642d62795902ef845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059027ea96573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450050505050505050505050505050505056a6361706162696c6974796c6d616e6167653a6772616e746a636f6e646974696f6e7359014681a2646b696e64636e6f74676f706572616e64a3646b696e6463616e64646c656674a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d756772616e7465642d6361706162696c6974792d6973677061796c6f616468657865633a707479657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f5657269676874a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d6a6772616e7465652d6973677061796c6f6164784032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f56a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7564656c65676174696f6e732d72656d61696e696e67005840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, + { + "name": "capability_token_v1_manage_grant_delegated", + "message": [ + { + "hex": "a2613126613458202222222222222222222222222222222222222222222222222222222222222222" + }, + {}, + { + "hex": "aa6573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258203333333333333333333333333333333333333333333333333333333333333333666973737565725820222222222222222222222222222222222222222222222222222222222222222267657870697265731b000001b18314880068746f6b656e2d696450070707070707070707070707070707076a6361706162696c6974796c6d616e6167653a6772616e746a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd6d617574686f72697365642d62795902ef845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059027ea96573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450050505050505050505050505050505056a6361706162696c6974796c6d616e6167653a6772616e746a636f6e646974696f6e7359014681a2646b696e64636e6f74676f706572616e64a3646b696e6463616e64646c656674a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d756772616e7465642d6361706162696c6974792d6973677061796c6f616468657865633a707479657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f5657269676874a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d6a6772616e7465652d6973677061796c6f6164784032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f56a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff716772616e74732d6361706162696c69747968657865633a7074797564656c65676174696f6e732d72656d61696e696e6700" + }, + { + "hex": "ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + } + ], + "wire_hex": "845828a2613126613458202222222222222222222222222222222222222222222222222222222222222222a0590445aa6573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258203333333333333333333333333333333333333333333333333333333333333333666973737565725820222222222222222222222222222222222222222222222222222222222222222267657870697265731b000001b18314880068746f6b656e2d696450070707070707070707070707070707076a6361706162696c6974796c6d616e6167653a6772616e746a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104ccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccdddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd6d617574686f72697365642d62795902ef845828a2613126613458201111111111111111111111111111111111111111111111111111111111111111a059027ea96573636f7065a2646b696e6466666f6c6465726470617468652f776f726b6662656172657258202222222222222222222222222222222222222222222222222222222222222222666973737565725820111111111111111111111111111111111111111111111111111111111111111167657870697265731b000001b8dac5b40068746f6b656e2d696450050505050505050505050505050505056a6361706162696c6974796c6d616e6167653a6772616e746a636f6e646974696f6e7359014681a2646b696e64636e6f74676f706572616e64a3646b696e6463616e64646c656674a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d756772616e7465642d6361706162696c6974792d6973677061796c6f616468657865633a707479657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f5657269676874a4626f70626571646b696e6467636f6d70617265646c656674a3646b696e646864656c65676174656673797374656d6a6772616e7465652d6973677061796c6f6164784032323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232323232657269676874a2646b696e646e626f6f6c65616e4c69746572616c6576616c7565f56a6973737565722d6b6579a263616c67266a7075626c69632d6b6579584104aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb7564656c65676174696f6e732d72656d61696e696e67015840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff716772616e74732d6361706162696c69747968657865633a7074797564656c65676174696f6e732d72656d61696e696e67005840ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff" + }, { "name": "capability_token_v1_room_member_root_grant", "message": [ From b65bf1798a706bc73f2dd7bc16cf592b20453a04 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 18:13:02 +0100 Subject: [PATCH 7/8] refactor(conformance): move the room-member and grant vectors to their own module generate.ts is at the max-lines cap, so the new grant-permission vectors and the room:member chain they sit beside live in token-vectors.ts, with everything they share with generate.ts handed over explicitly. The regenerated vector files are byte-identical. --- conformance/generate.ts | 195 ++++++----------------------------- conformance/token-vectors.ts | 192 ++++++++++++++++++++++++++++++++++ 2 files changed, 225 insertions(+), 162 deletions(-) create mode 100644 conformance/token-vectors.ts diff --git a/conformance/generate.ts b/conformance/generate.ts index 84fd59aa..ef4b004e 100644 --- a/conformance/generate.ts +++ b/conformance/generate.ts @@ -10,6 +10,7 @@ import { type Vector, } from "@exadev/wire-mesh-conformance"; import { advertVectors } from "./adverts.ts"; +import { grantTokenVectors, roomMemberTokens } from "./token-vectors.ts"; import { writeVectorFiles } from "./vector-files.ts"; function wireHex(message: JsonWire): string { @@ -127,152 +128,25 @@ const deviceAHex = "11".repeat(SHA256_BYTE_LENGTH); const deviceBHex = "22".repeat(SHA256_BYTE_LENGTH); const deviceCHex = "33".repeat(SHA256_BYTE_LENGTH); -// wire-mesh#323: the grant permission. A manage:grant root (deviceA grants deviceB the -// right to mint, any verb within /work since grants-capability is absent, one hop deep) -// carrying the named no-self-grant bar in its conditions -- not(and(granted-capability-is -// "exec:pty", grantee-is )) -- then an exec:pty token deviceB minted under it -// (citing it via authorised-by, granted to deviceC so the bar holds), and a second-level -// manage:grant deviceB minted onward to deviceC, itself naming exec:pty. -const noSelfGrantBar = { - kind: "not", - operand: { - kind: "and", - left: { - kind: "compare", - op: "eq", - left: { - kind: "delegate", - system: "granted-capability-is", - payload: "exec:pty", - }, - right: { kind: "booleanLiteral", value: true }, - }, - right: { - kind: "compare", - op: "eq", - left: { kind: "delegate", system: "grantee-is", payload: deviceBHex }, - right: { kind: "booleanLiteral", value: true }, - }, - }, -}; -const noSelfGrantBarBytes = Buffer.from( - encode([noSelfGrantBar], cdeEncodeOptions), -).toString("hex"); - -const manageGrantRootTokenClaims: JsonWire = { - "token-id": hex("05".repeat(TOKEN_ID_BYTE_LENGTH)), - issuer: deviceA, - "issuer-key": { alg: -7, "public-key": publicKeyEs256A }, - bearer: deviceB, - capability: "manage:grant", - scope: { kind: "folder", path: "/work" }, - expires: 1893456000000, - "delegations-remaining": 1, - conditions: hex(noSelfGrantBarBytes), -}; -const manageGrantRootToken: JsonWire = [ - hex(wireHex({ 1: -7, 4: deviceA })), - {}, - hex(wireHex(manageGrantRootTokenClaims)), - signatureFiller, -]; -const manageGrantRootTokenVector = vector( - "capability_token_v1_manage_grant_root", - manageGrantRootToken, -); - -const grantAuthorisedTokenClaims: JsonWire = { - "token-id": hex("06".repeat(TOKEN_ID_BYTE_LENGTH)), - issuer: deviceB, - "issuer-key": { alg: -7, "public-key": publicKeyEs256B }, - bearer: deviceC, - capability: "exec:pty", - scope: { kind: "folder", path: "/work/subdir" }, - expires: 1861920000000, - "authorised-by": hex(manageGrantRootTokenVector.wire_hex), - "delegations-remaining": 0, -}; -const grantAuthorisedToken: JsonWire = [ - hex(wireHex({ 1: -7, 4: deviceB })), - {}, - hex(wireHex(grantAuthorisedTokenClaims)), +// wire-mesh#323's grant-permission vectors, and the room:member chain the noticeboard and manage-request vectors below embed, live in token-vectors.ts (this file is at the max-lines cap); everything they share with the vectors below is handed over explicitly. (this file is at the max-lines cap); everything they share with the vectors below is handed to them explicitly. +const grantVectorContext = { + deviceA, + deviceB, + deviceC, + deviceAHex, + deviceBHex, + publicKeyEs256A, + publicKeyEs256B, signatureFiller, -]; -const grantAuthorisedTokenVector = vector( - "capability_token_v1_grant_authorised_exec_pty", - grantAuthorisedToken, -); - -const manageGrantDelegatedTokenClaims: JsonWire = { - "token-id": hex("07".repeat(TOKEN_ID_BYTE_LENGTH)), - issuer: deviceB, - "issuer-key": { alg: -7, "public-key": publicKeyEs256B }, - bearer: deviceC, - capability: "manage:grant", - scope: { kind: "folder", path: "/work" }, - expires: 1861920000000, - "grants-capability": "exec:pty", - "authorised-by": hex(manageGrantRootTokenVector.wire_hex), - "delegations-remaining": 0, + wireHex, + protectedHeaderHex: (device: JsonWire) => wireHex({ 1: -7, 4: device }), + tokenIdByteLength: TOKEN_ID_BYTE_LENGTH, }; -const manageGrantDelegatedToken: JsonWire = [ - hex(wireHex({ 1: -7, 4: deviceB })), - {}, - hex(wireHex(manageGrantDelegatedTokenClaims)), - signatureFiller, -]; -const manageGrantDelegatedTokenVector = vector( - "capability_token_v1_manage_grant_delegated", - manageGrantDelegatedToken, -); +const grantVectors: Vector[] = grantTokenVectors(grantVectorContext); +const roomMember = roomMemberTokens(grantVectorContext); +const roomMemberVectors: Vector[] = roomMember.vectors; // A room:member grant chain demonstrating this session's own delegations-remaining fix: the owner (deviceA) issues a root grant to deviceB capped at one further re-delegation, and deviceB narrows it (a strictly lower value, 0) when re-delegating to deviceC -- deviceC's own token therefore bears no further-delegation authority at all, closing the unbounded-admission gap the claim exists to fix. -const roomMemberRootTokenClaims: JsonWire = { - "token-id": hex("03".repeat(TOKEN_ID_BYTE_LENGTH)), - issuer: deviceA, - "issuer-key": { alg: -7, "public-key": publicKeyEs256A }, - bearer: deviceB, - capability: "room:member", - scope: { kind: "room", path: `${deviceAHex}/general` }, - expires: 1893456000000, - "delegations-remaining": 1, -}; - -const roomMemberRootToken: JsonWire = [ - hex(wireHex({ 1: -7, 4: deviceA })), - {}, - hex(wireHex(roomMemberRootTokenClaims)), - signatureFiller, -]; - -const roomMemberRootTokenVector = vector( - "capability_token_v1_room_member_root_grant", - roomMemberRootToken, -); - -const roomMemberDelegatedTokenClaims: JsonWire = { - "token-id": hex("04".repeat(TOKEN_ID_BYTE_LENGTH)), - issuer: deviceB, - "issuer-key": { alg: -7, "public-key": publicKeyEs256B }, - bearer: deviceC, - capability: "room:member", - scope: { kind: "room", path: `${deviceAHex}/general` }, - expires: 1861920000000, - parent: hex(roomMemberRootTokenVector.wire_hex), - "delegations-remaining": 0, -}; - -const roomMemberDelegatedToken: JsonWire = [ - hex(wireHex({ 1: -7, 4: deviceB })), - {}, - hex(wireHex(roomMemberDelegatedTokenClaims)), - signatureFiller, -]; - -const roomMemberDelegatedTokenVector = vector( - "capability_token_v1_room_member_delegated_no_further_delegation", - roomMemberDelegatedToken, -); // core/room's own noticeboard entry (room.cddl's room-notice), self-certifying the same way capability-token and handle-record already are. deviceB posts to the same general room its roomMemberRootToken already grants it membership in, embedding that exact token in full so a reader with no other context can verify posting authority from the notice alone. const NOTICE_ID_BYTE_LENGTH = 16; // opaque notice-id, arbitrarily sized like token-id @@ -281,7 +155,7 @@ const roomNoticeClaims: JsonWire = { room: `${deviceAHex}/general`, poster: deviceB, "poster-key": { alg: -7, "public-key": publicKeyEs256B }, - token: roomMemberRootToken, + token: roomMember.rootToken, "notice-id": hex("a1".repeat(NOTICE_ID_BYTE_LENGTH)), "posted-at": 1861920000000, "content-type": "text/plain", @@ -302,7 +176,7 @@ const roomNoticeForwardClaims: JsonWire = { room: `${deviceAHex}/general`, poster: deviceC, "poster-key": { alg: -7, "public-key": publicKeyEs256B }, // reusing B's synthetic key bytes for C is fine here -- this file freezes envelope shape, not real per-device key material - token: roomMemberDelegatedToken, + token: roomMember.delegatedToken, "notice-id": hex("a2".repeat(NOTICE_ID_BYTE_LENGTH)), "posted-at": 1861920100000, "content-type": "application/x-room-notice", @@ -327,7 +201,7 @@ const roomNoticeEncryptedClaims: JsonWire = { room: `${deviceAHex}/general`, poster: deviceB, "poster-key": { alg: -7, "public-key": publicKeyEs256B }, - token: roomMemberRootToken, + token: roomMember.rootToken, "notice-id": hex("a3".repeat(NOTICE_ID_BYTE_LENGTH)), "posted-at": 1861920200000, "content-type": "text/plain+aes256gcm", @@ -367,11 +241,8 @@ const handleRecordVector = vector("handle_record_v1_dns_anchored", [ const tokenVectors: Vector[] = [ rootTokenVector, delegatedTokenVector, - manageGrantRootTokenVector, - grantAuthorisedTokenVector, - manageGrantDelegatedTokenVector, - roomMemberRootTokenVector, - roomMemberDelegatedTokenVector, + ...grantVectors, + ...roomMemberVectors, roomNoticeVector, roomNoticeForwardVector, roomNoticeEncryptedVector, @@ -611,7 +482,7 @@ const frameVectors: Vector[] = [ }, }, scope: { kind: "room", path: `${deviceAHex}/general` }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), // room.read is batched -- an agent typically drains and marks read several messages in one pass, and a one-message-per-round-trip receipt verb would turn one drain into N round trips per peer. vector("manage_request_v1_room_read", { @@ -626,7 +497,7 @@ const frameVectors: Vector[] = [ }, }, scope: { kind: "room", path: `${deviceAHex}/general` }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), vector("manage_request_v1_room_leave", { type: "manage-request", @@ -636,7 +507,7 @@ const frameVectors: Vector[] = [ params: { verb: "room.leave" }, }, scope: { kind: "room", path: `${deviceAHex}/general` }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), vector("manage_request_v1_room_members", { type: "manage-request", @@ -646,7 +517,7 @@ const frameVectors: Vector[] = [ params: { verb: "room.members" }, }, scope: { kind: "room", path: `${deviceAHex}/general` }, - token: roomMemberRootToken, + 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. vector("manage_request_v1_room_join_dm", { @@ -667,7 +538,7 @@ const frameVectors: Vector[] = [ params: { verb: "room.invite", invitee: deviceC, - token: roomMemberDelegatedToken, + token: roomMember.delegatedToken, }, }, scope: { kind: "room", path: `${deviceAHex}/general` }, @@ -685,7 +556,7 @@ const frameVectors: Vector[] = [ }, }, scope: { kind: "room", path: `${deviceAHex}/general` }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), // room.rekey under the "full history" policy: wrapped-key is an array, one entry per historical epoch being granted to a newly admitted member. vector("manage_request_v1_room_rekey_full_history", { @@ -700,7 +571,7 @@ const frameVectors: Vector[] = [ }, }, scope: { kind: "room", path: `${deviceAHex}/general` }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), // room-join-ok: the approval response to room.join, carrying the freshly minted grant AND the room's current membership so a joiner learns who else is there on the same round trip that grants it membership. vector("manage_response_v1_room_join_ok", { @@ -708,7 +579,7 @@ const frameVectors: Vector[] = [ "request-id": 11, outcome: { result: "ok", - "granted-token": roomMemberDelegatedToken, + "granted-token": roomMember.delegatedToken, members: [{ device: deviceA }, { device: deviceB }], }, }), @@ -835,7 +706,7 @@ const frameVectors: Vector[] = [ }, }, scope: { kind: "group" }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), // manage-ok extended per threshold-commit's own comment: "manage-ok extended with: participant: device-id, hiding: bstr, binding: bstr" -- returning a commitment IS the participant's act of authorisation. vector("manage_response_v1_threshold_commit_ok", { @@ -863,7 +734,7 @@ const frameVectors: Vector[] = [ }, }, scope: { kind: "group" }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), // manage-ok extended per threshold-sign's own comment: "manage-ok extended with: share: bstr .cbor threshold-share-envelope" -- the released share, self-certifying under the releasing participant's own PERSONAL key (never the group's). vector("manage_response_v1_threshold_sign_ok", { @@ -901,7 +772,7 @@ const frameVectors: Vector[] = [ }, }, scope: { kind: "group" }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), vector("manage_request_v1_threshold_abort_without_reason", { type: "manage-request", @@ -911,7 +782,7 @@ const frameVectors: Vector[] = [ params: { verb: "threshold.abort", "session-id": 1 }, }, scope: { kind: "group" }, - token: roomMemberRootToken, + token: roomMember.rootToken, }), // Fresh DKG: existing-group-key absent, proof-of-knowledge REQUIRED and present. vector("manage_request_v1_threshold_keygen_round1_fresh_dkg", { diff --git a/conformance/token-vectors.ts b/conformance/token-vectors.ts new file mode 100644 index 00000000..8fc8fa9e --- /dev/null +++ b/conformance/token-vectors.ts @@ -0,0 +1,192 @@ +// Token vectors kept out of generate.ts the same way adverts.ts already is: generate.ts +// sits at this repo's own max-lines cap, so when a set of token vectors grows the file it +// moves here instead. Everything shared with generate.ts (the synthetic identities, the +// signature filler, the CDE hex encoders) arrives via the context parameter rather than +// being redefined here, so the two files cannot drift apart on what deviceB or a filler +// signature is. + +import { encode, cdeEncodeOptions } from "cbor2"; +import { hex, type JsonWire, type Vector } from "@exadev/wire-mesh-conformance"; + +/** The synthetic identities and encoders generate.ts already defines, as one object so this file adds no second copy of them. */ +export interface TokenVectorContext { + readonly deviceA: JsonWire; + readonly deviceB: JsonWire; + readonly deviceC: JsonWire; + readonly deviceAHex: string; + readonly deviceBHex: string; + readonly publicKeyEs256A: JsonWire; + readonly publicKeyEs256B: JsonWire; + readonly signatureFiller: JsonWire; + readonly wireHex: (message: JsonWire) => string; + /** The int-keyed COSE protected header for a device's tokens, as a hex marker. */ + readonly protectedHeaderHex: (device: JsonWire) => string; + /** Opaque token-id length, arbitrarily sized like a UUID. */ + readonly tokenIdByteLength: number; +} + +export function grantTokenVectors(ctx: Readonly): Vector[] { + const vector = (name: string, message: JsonWire): Vector => ({ + name, + message, + wire_hex: ctx.wireHex(message), + }); + const token = ( + issuer: JsonWire, + issuerKey: JsonWire, + claims: JsonWire, + ): JsonWire => [ + hex(ctx.protectedHeaderHex(issuer)), + {}, + hex(ctx.wireHex(claims)), + ctx.signatureFiller, + ]; + + // The named no-self-grant bar, the exact node shape the TS core's noSelfGrantBar + // builds: not(and(granted-capability-is "exec:pty", grantee-is )). + const noSelfGrantBar = { + kind: "not", + operand: { + kind: "and", + left: { + kind: "compare", + op: "eq", + left: { + kind: "delegate", + system: "granted-capability-is", + payload: "exec:pty", + }, + right: { kind: "booleanLiteral", value: true }, + }, + right: { + kind: "compare", + op: "eq", + left: { + kind: "delegate", + system: "grantee-is", + payload: ctx.deviceBHex, + }, + right: { kind: "booleanLiteral", value: true }, + }, + }, + }; + const noSelfGrantBarBytes = Buffer.from( + encode([noSelfGrantBar], cdeEncodeOptions), + ).toString("hex"); + + // A manage:grant root (deviceA grants deviceB the right to mint, any verb within + // /work since grants-capability is absent, one hop deep) carrying that bar, then an + // exec:pty token deviceB minted under it (citing it via authorised-by, granted to + // deviceC so the bar holds), and a second-level manage:grant deviceB passed onward to + // deviceC, itself naming exec:pty. + const manageGrantRootTokenVector = vector( + "capability_token_v1_manage_grant_root", + token(ctx.deviceA, ctx.publicKeyEs256A, { + "token-id": hex("05".repeat(ctx.tokenIdByteLength)), + issuer: ctx.deviceA, + "issuer-key": { alg: -7, "public-key": ctx.publicKeyEs256A }, + bearer: ctx.deviceB, + capability: "manage:grant", + scope: { kind: "folder", path: "/work" }, + expires: 1893456000000, + "delegations-remaining": 1, + conditions: hex(noSelfGrantBarBytes), + }), + ); + + const grantAuthorisedTokenVector = vector( + "capability_token_v1_grant_authorised_exec_pty", + token(ctx.deviceB, ctx.publicKeyEs256B, { + "token-id": hex("06".repeat(ctx.tokenIdByteLength)), + issuer: ctx.deviceB, + "issuer-key": { alg: -7, "public-key": ctx.publicKeyEs256B }, + bearer: ctx.deviceC, + capability: "exec:pty", + scope: { kind: "folder", path: "/work/subdir" }, + expires: 1861920000000, + "authorised-by": hex(manageGrantRootTokenVector.wire_hex), + "delegations-remaining": 0, + }), + ); + + const manageGrantDelegatedTokenVector = vector( + "capability_token_v1_manage_grant_delegated", + token(ctx.deviceB, ctx.publicKeyEs256B, { + "token-id": hex("07".repeat(ctx.tokenIdByteLength)), + issuer: ctx.deviceB, + "issuer-key": { alg: -7, "public-key": ctx.publicKeyEs256B }, + bearer: ctx.deviceC, + capability: "manage:grant", + scope: { kind: "folder", path: "/work" }, + expires: 1861920000000, + "grants-capability": "exec:pty", + "authorised-by": hex(manageGrantRootTokenVector.wire_hex), + "delegations-remaining": 0, + }), + ); + + return [ + manageGrantRootTokenVector, + grantAuthorisedTokenVector, + manageGrantDelegatedTokenVector, + ]; +} + +/// core/room's own membership chain demonstrating delegations-remaining: the owner +/// (deviceA) issues a root grant to deviceB capped at one further re-delegation, and +/// deviceB narrows it (a strictly lower value, 0) when re-delegating to deviceC, whose +/// own token therefore bears no further-delegation authority at all. +export function roomMemberTokens(ctx: Readonly): { + vectors: Vector[]; + /** The root room:member token's own JsonWire shape, for vectors that embed it (a room-notice's posting authority, a manage-request's presented token). */ + rootToken: JsonWire; + delegatedToken: JsonWire; +} { + const vector = (name: string, message: JsonWire): Vector => ({ + name, + message, + wire_hex: ctx.wireHex(message), + }); + const token = (issuer: JsonWire, claims: JsonWire): JsonWire => [ + hex(ctx.protectedHeaderHex(issuer)), + {}, + hex(ctx.wireHex(claims)), + ctx.signatureFiller, + ]; + const roomPath = `${ctx.deviceAHex}/general`; + + const root = vector( + "capability_token_v1_room_member_root_grant", + token(ctx.deviceA, { + "token-id": hex("03".repeat(ctx.tokenIdByteLength)), + issuer: ctx.deviceA, + "issuer-key": { alg: -7, "public-key": ctx.publicKeyEs256A }, + bearer: ctx.deviceB, + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: 1893456000000, + "delegations-remaining": 1, + }), + ); + + const delegated = vector( + "capability_token_v1_room_member_delegated_no_further_delegation", + token(ctx.deviceB, { + "token-id": hex("04".repeat(ctx.tokenIdByteLength)), + issuer: ctx.deviceB, + "issuer-key": { alg: -7, "public-key": ctx.publicKeyEs256B }, + bearer: ctx.deviceC, + capability: "room:member", + scope: { kind: "room", path: roomPath }, + expires: 1861920000000, + parent: hex(root.wire_hex), + "delegations-remaining": 0, + }), + ); + + return { + vectors: [root, delegated], + rootToken: root.message, + delegatedToken: delegated.message, + }; +} From 49272b9302da444d0e37152438411592750c9e09 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Thu, 1 Oct 2026 18:13:02 +0100 Subject: [PATCH 8/8] feat(web-console): describe the grant-authorisation verdict and refusal reasons --- ts/packages/web-console/src/grants.ts | 3 +++ ts/packages/web-console/src/mint-grant.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/ts/packages/web-console/src/grants.ts b/ts/packages/web-console/src/grants.ts index 9cafbf08..2ee248ec 100644 --- a/ts/packages/web-console/src/grants.ts +++ b/ts/packages/web-console/src/grants.ts @@ -110,6 +110,9 @@ const REASON_TEXT: Readonly> = { parent_invalid: "its parent grant is no longer valid", conditions_invalid: "its conditions are invalid", conditions_not_satisfied: "its conditions are not met", + authorisation_invalid: "exceeds what its authorising grant allows", + chain_too_deep: "its chain is deeper than allowed", + chain_cycle: "its chain revisits a token", }; export function describeGrantStatus(status: Readonly): string { diff --git a/ts/packages/web-console/src/mint-grant.ts b/ts/packages/web-console/src/mint-grant.ts index 2b7a7e37..5882c045 100644 --- a/ts/packages/web-console/src/mint-grant.ts +++ b/ts/packages/web-console/src/mint-grant.ts @@ -54,6 +54,22 @@ const REFUSAL_TEXT: Readonly> = { capability_mismatch: "its capability differs from the grant it delegates from", delegation_exceeds_parent: "it permits more delegation than its parent does", + links_exclusive: + "it would carry both a parent grant and an authorising grant", + authorisation_malformed: "the grant authorising it is malformed", + authorisation_parent_forbidden: + "a grant permission cannot be delegated from a parent grant", + authorisation_bearer_mismatch: + "the grant authorising it is not held by this device", + authorisation_capability_mismatch: + "the grant authorising it names a different capability", + authorisation_scope_does_not_narrow: + "its scope is wider than the grant authorising it", + authorisation_exceeds_authoriser: "it would outlast the grant authorising it", + authorisation_delegation_exceeds: + "it permits more delegation than the grant authorising it does", + authorisation_conditions_not_satisfied: + "the grant authorising it does not allow this mint", }; function randomTokenId(): Uint8Array {