Skip to content

Delegating revoke authority outside the issuing chain has no natural narrowing model #84

Description

@Mearman

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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions