Revocation authority today is tied strictly to being the original issuer of a token -- verified by signature match against the token's own recorded issuer field (management.cddl's revocation-entry), not by holding any separate credential. There is no mechanism to delegate the right to revoke a token to a party outside its own issuing chain (e.g. a security-team member who never personally minted the grant they need to pull).
This isn't an oversight: ordinary capability delegation (tokens.cddl) is safe to delegate because it's self-limiting by construction -- a delegated grant can never exceed what the delegator already held, so scope narrowing bounds the damage a malicious sub-delegate can do to no worse than what the delegator was already trusted with. Revocation has no equivalent shrinkage. Handing someone the power to revoke a token isn't sharing a slice of the delegator's own resource, it's handing them power over a third party's (the bearer's) existing standing -- there's no natural notion of "a narrower revocation right" the way there's a natural notion of "a narrower room-membership right" via scope containment.
The result today: only the party who structurally sits at a scope's root (the room owner, the group root) has any broad revoke reach at all, and only via the existing ancestor-chain cascade (revoking your own root grant revokes everything delegated beneath it) -- not via any explicit, delegable "may revoke" right. Someone outside the issuing chain entirely cannot be given revoke authority under the current model.
A candidate design was sketched in the design conversation that surfaced this gap: a "revoke" capability reusing the existing narrowing / delegations-remaining / expires machinery unchanged (since scope narrowing is already domain-agnostic), one new optional authorization field on revocation-claims (a nested nested bstr .cbor capability-token, the same embedded-token idiom parent and room-notice already use), and an additive OR-branch on the revocation-entry verifier obligation -- valid if the existing issuer-matches check passes, or if a currently-valid embedded authorization token grants "revoke" over a scope the target token's own scope narrows into. If "revoke" becomes an ordinary capability this way, #79's canGrant() needs no special-casing for it -- it already answers "does this held token cover this capability at this scope" generically, so a "revoke" grant is just another capability it can check without any dedicated logic. Not a formal blocking relationship: this is a detail of how the candidate design would compose with #79, not a dependency on it landing first.
Deliberately not implemented here -- this issue records the open question and the reason it's hard, with the candidate shape noted for whoever picks it up, not a committed implementation plan.
Update (2026-09-14): checked the candidate design directly against the current RevocationCheck/RevocationView implementation rather than leaving it at the conversational sketch above. RevocationCheck.isRevoked(tokenId, issuer): Promise<boolean> (ts/packages/core/src/domain/tokens.ts) and createRevocationView() (ts/packages/core/src/domain/revocation-view.ts) currently store a flat set of tokenId:issuer composite keys and answer a plain boolean — that shape can't carry the authorization branch as-is, for two reasons. First, the authorization check needs the target token's own scope (to test scopeNarrows(authorization.scope, targetScope)), which never reaches isRevoked today. Second, verifying the nested authorization capability-token needs an IdentityPort and Clock, and RevocationView is constructed with neither — those only exist at verifyTokenChain call time.
The fix that keeps the existing separation of concerns (RevocationView as a dumb store, every verifier obligation living in tokens.ts, matching how expiry/not-before/valid-until/scope-narrowing are already checked there rather than inside the revocation port) is to change the port's own contract from a boolean query to entriesFor(tokenId: Uint8Array): Promise<readonly RevocationClaims[]> — every recorded, already-signature-verified revocation-claims for that token-id, across every issuer that has ever submitted one, with no filtering by the store itself. verifyTokenChain (which already holds identity/clock/revocation in scope for the recursive parent-chain walk) then does the actual obligation check per entry: revoked if entry.issuer equals the token's own issuer (today's existing rule, unchanged), OR if entry.authorization is present and, once decoded and verified via the ordinary verifyCapabilityToken(authToken, { identity, clock, revocation, expectedBearer: entry.issuer }) (the expectedBearer check here is what proves the authorization was actually granted to this specific revoker, not merely held by someone else), the resulting claims carry capability === "revoke" and scopeNarrows(claims.scope, targetToken.scope) holds. This is roughly 15 call sites to update (all in TS, all in ts/packages/core and its two web-console test fakes — checked directly, grep -rn isRevoked across both ts/ and rust/), a real but bounded refactor.
Rust needs the identical treatment, and this one is NOT optional/deferrable the way it might look at a glance: rust/crates/wire-mesh-core/src/domain/revocation.rs already has a full, independent revocation implementation today (unlike valid-until, where only the TS verifier existed before PR #116 added the field to both languages) — so building the authorization branch into TS alone would recreate exactly the class of cross-language security gap Codex's automated review already caught once this session on valid-until (a Rust verifier silently accepting what the TS verifier would reject). Both languages land together or not at all.
One real sequencing note, not a formal blocking relationship but worth recording plainly: this design's core edit is inside verifyTokenChain in ts/packages/core/src/domain/tokens.ts, the exact function #85's generic predicate-list evaluator is concurrently rewriting in this session (replacing several of its hardcoded narrowing checks with a single evaluatePredicate call). Implementing this issue needs to happen strictly after #85 merges, not concurrently, to avoid a direct file conflict — and once #85's real shape lands, it's worth a second look at whether this authorization check belongs as one of its predicate ops (a delegate system, the same mechanism #85 already uses for bearer-is/scope-narrows/etc.) rather than as a second, separately-hardcoded check bolted on next to it. They are checking different signed objects (a token's own claims vs. a revocation-entry's authorization), so they may legitimately stay separate — left for whoever picks this up once #85's actual merged shape is visible, not decided here.
Revocation authority today is tied strictly to being the original issuer of a token -- verified by signature match against the token's own recorded
issuerfield (management.cddl'srevocation-entry), not by holding any separate credential. There is no mechanism to delegate the right to revoke a token to a party outside its own issuing chain (e.g. a security-team member who never personally minted the grant they need to pull).This isn't an oversight: ordinary capability delegation (
tokens.cddl) is safe to delegate because it's self-limiting by construction -- a delegated grant can never exceed what the delegator already held, so scope narrowing bounds the damage a malicious sub-delegate can do to no worse than what the delegator was already trusted with. Revocation has no equivalent shrinkage. Handing someone the power to revoke a token isn't sharing a slice of the delegator's own resource, it's handing them power over a third party's (the bearer's) existing standing -- there's no natural notion of "a narrower revocation right" the way there's a natural notion of "a narrower room-membership right" via scope containment.The result today: only the party who structurally sits at a scope's root (the room owner, the group root) has any broad revoke reach at all, and only via the existing ancestor-chain cascade (revoking your own root grant revokes everything delegated beneath it) -- not via any explicit, delegable "may revoke" right. Someone outside the issuing chain entirely cannot be given revoke authority under the current model.
A candidate design was sketched in the design conversation that surfaced this gap: a
"revoke"capability reusing the existing narrowing /delegations-remaining/expiresmachinery unchanged (since scope narrowing is already domain-agnostic), one new optionalauthorizationfield onrevocation-claims(a nested nestedbstr .cbor capability-token, the same embedded-token idiomparentandroom-noticealready use), and an additive OR-branch on the revocation-entry verifier obligation -- valid if the existing issuer-matches check passes, or if a currently-valid embeddedauthorizationtoken grants"revoke"over a scope the target token's own scope narrows into. If"revoke"becomes an ordinary capability this way,#79'scanGrant()needs no special-casing for it -- it already answers "does this held token cover this capability at this scope" generically, so a"revoke"grant is just another capability it can check without any dedicated logic. Not a formal blocking relationship: this is a detail of how the candidate design would compose with#79, not a dependency on it landing first.Deliberately not implemented here -- this issue records the open question and the reason it's hard, with the candidate shape noted for whoever picks it up, not a committed implementation plan.
Update (2026-09-14): checked the candidate design directly against the current
RevocationCheck/RevocationViewimplementation rather than leaving it at the conversational sketch above.RevocationCheck.isRevoked(tokenId, issuer): Promise<boolean>(ts/packages/core/src/domain/tokens.ts) andcreateRevocationView()(ts/packages/core/src/domain/revocation-view.ts) currently store a flat set oftokenId:issuercomposite keys and answer a plain boolean — that shape can't carry the authorization branch as-is, for two reasons. First, the authorization check needs the target token's own scope (to testscopeNarrows(authorization.scope, targetScope)), which never reachesisRevokedtoday. Second, verifying the nestedauthorizationcapability-token needs anIdentityPortandClock, andRevocationViewis constructed with neither — those only exist atverifyTokenChaincall time.The fix that keeps the existing separation of concerns (
RevocationViewas a dumb store, every verifier obligation living intokens.ts, matching how expiry/not-before/valid-until/scope-narrowing are already checked there rather than inside the revocation port) is to change the port's own contract from a boolean query toentriesFor(tokenId: Uint8Array): Promise<readonly RevocationClaims[]>— every recorded, already-signature-verified revocation-claims for that token-id, across every issuer that has ever submitted one, with no filtering by the store itself.verifyTokenChain(which already holdsidentity/clock/revocationin scope for the recursive parent-chain walk) then does the actual obligation check per entry: revoked ifentry.issuerequals the token's own issuer (today's existing rule, unchanged), OR ifentry.authorizationis present and, once decoded and verified via the ordinaryverifyCapabilityToken(authToken, { identity, clock, revocation, expectedBearer: entry.issuer })(theexpectedBearercheck here is what proves the authorization was actually granted to this specific revoker, not merely held by someone else), the resulting claims carrycapability === "revoke"andscopeNarrows(claims.scope, targetToken.scope)holds. This is roughly 15 call sites to update (all in TS, all ints/packages/coreand its twoweb-consoletest fakes — checked directly,grep -rn isRevokedacross bothts/andrust/), a real but bounded refactor.Rust needs the identical treatment, and this one is NOT optional/deferrable the way it might look at a glance:
rust/crates/wire-mesh-core/src/domain/revocation.rsalready has a full, independent revocation implementation today (unlikevalid-until, where only the TS verifier existed before PR #116 added the field to both languages) — so building the authorization branch into TS alone would recreate exactly the class of cross-language security gap Codex's automated review already caught once this session onvalid-until(a Rust verifier silently accepting what the TS verifier would reject). Both languages land together or not at all.One real sequencing note, not a formal blocking relationship but worth recording plainly: this design's core edit is inside
verifyTokenChainints/packages/core/src/domain/tokens.ts, the exact function#85's generic predicate-list evaluator is concurrently rewriting in this session (replacing several of its hardcoded narrowing checks with a singleevaluatePredicatecall). Implementing this issue needs to happen strictly after#85merges, not concurrently, to avoid a direct file conflict — and once#85's real shape lands, it's worth a second look at whether this authorization check belongs as one of its predicate ops (adelegatesystem, the same mechanism#85already uses forbearer-is/scope-narrows/etc.) rather than as a second, separately-hardcoded check bolted on next to it. They are checking different signed objects (a token's own claims vs. a revocation-entry's authorization), so they may legitimately stay separate — left for whoever picks this up once#85's actual merged shape is visible, not decided here.