From 238121a666b7f8855596f31e48e4822d2f1770d4 Mon Sep 17 00:00:00 2001 From: Joseph Mearman Date: Sun, 13 Sep 2026 06:18:56 +0100 Subject: [PATCH] feat(core): expose canGrant as a queryable narrowing check mintCapabilityToken already enforced every one of tokens.cddl's own delegation-narrowing rules (bearer match, expiry within parent, scope narrows, same capability, delegations-remaining strictly less than parent's), but only as a side effect of actually attempting a mint. Extracts that arithmetic into checkNarrowing, shared by mintCapabilityToken and a new canGrant(heldToken, deviceId, candidate, now) pure query, so a caller can ask whether a delegation would succeed without attempting (and potentially failing) a real mint, and the two can never silently drift into different ideas of what narrows a token. canGrant's signature extends the issue's own originally-sketched (heldToken, capability, scope) to also take the querying device-id (needed for the bearer-match check mint itself performs) and now (needed to reject an already-expired candidate the same way mint's own first check does) -- both genuinely required for the query to agree with what a real mint attempt would actually decide, not optional extras. --- ts/packages/core/src/domain/tokens.ts | 87 +++++++--- ts/packages/core/test/tokens.test.ts | 234 ++++++++++++++++++++++++++ 2 files changed, 302 insertions(+), 19 deletions(-) diff --git a/ts/packages/core/src/domain/tokens.ts b/ts/packages/core/src/domain/tokens.ts index 6928718..ca6fdf7 100644 --- a/ts/packages/core/src/domain/tokens.ts +++ b/ts/packages/core/src/domain/tokens.ts @@ -314,6 +314,64 @@ export type MintVerdict = | { ok: true; token: CapabilityToken } | { ok: false; reason: MintRefusalReason }; +/** What a would-be delegation needs, to check it narrows a specific parent -- everything mintCapabilityToken itself checks a delegation against, independent of the tokenId/bearer/notBefore/signing concerns unique to actually minting one. */ +interface NarrowingCandidate { + capability: TokenClaims["capability"]; + scope: TokenClaims["scope"]; + expires: number; + delegationsRemaining?: number; +} + +/** The narrowing arithmetic tokens.cddl's own delegation obligations require (bearer match, expiry within the parent's, scope narrows, same capability, delegations-remaining strictly less than the parent's) -- shared between mintCapabilityToken (which additionally builds and signs the resulting token) and canGrant (a pure query with no minting side effect at all), so the two can never silently drift into two different ideas of what "narrows" means. Returns the specific refusal reason, or undefined when every rule is satisfied. */ +function checkNarrowing( + parentClaims: Readonly, + granterDeviceId: DeviceId, + candidate: Readonly, +): MintRefusalReason | undefined { + if (!bytesEqual(parentClaims.bearer, granterDeviceId)) { + return "parent_bearer_mismatch"; + } + if (candidate.expires > parentClaims.expires) { + return "expires_exceeds_parent"; + } + if (!scopeNarrows(parentClaims.scope, candidate.scope)) { + return "scope_does_not_narrow"; + } + if (parentClaims.capability !== candidate.capability) { + return "capability_mismatch"; + } + const parentRemaining = parentClaims["delegations-remaining"]; + if ( + parentRemaining !== undefined && + (candidate.delegationsRemaining === undefined || + candidate.delegationsRemaining >= parentRemaining) + ) { + return "delegation_exceeds_parent"; + } + 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. + * + * 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. + */ +export function canGrant( + heldToken: CapabilityToken, + deviceId: DeviceId, + candidate: Readonly, + now: number, +): boolean { + if (candidate.expires <= now) { + return false; + } + const heldClaims = decodeTokenClaims(heldToken); + if (heldClaims === undefined) { + return false; + } + return checkNarrowing(heldClaims, deviceId, candidate) === undefined; +} + export interface MintCapabilityTokenOptions { /** The issuer -- signs the token, and supplies the self-certifying issuer/issuer-key claims. */ identity: IdentityPort; @@ -347,25 +405,16 @@ export async function mintCapabilityToken( if (parentClaims === undefined) { return { ok: false, reason: "parent_malformed" }; } - if (!bytesEqual(parentClaims.bearer, options.identity.deviceId)) { - return { ok: false, reason: "parent_bearer_mismatch" }; - } - if (options.expires > parentClaims.expires) { - return { ok: false, reason: "expires_exceeds_parent" }; - } - if (!scopeNarrows(parentClaims.scope, options.scope)) { - return { ok: false, reason: "scope_does_not_narrow" }; - } - if (parentClaims.capability !== options.capability) { - return { ok: false, reason: "capability_mismatch" }; - } - const parentRemaining = parentClaims["delegations-remaining"]; - if ( - parentRemaining !== undefined && - (options.delegationsRemaining === undefined || - options.delegationsRemaining >= parentRemaining) - ) { - return { ok: false, reason: "delegation_exceeds_parent" }; + const refusal = checkNarrowing(parentClaims, options.identity.deviceId, { + capability: options.capability, + scope: options.scope, + expires: options.expires, + ...(options.delegationsRemaining !== undefined + ? { delegationsRemaining: options.delegationsRemaining } + : {}), + }); + if (refusal !== undefined) { + return { ok: false, reason: refusal }; } parentBytes = encodeBuf(options.parent); } diff --git a/ts/packages/core/test/tokens.test.ts b/ts/packages/core/test/tokens.test.ts index b3baec4..a403e4c 100644 --- a/ts/packages/core/test/tokens.test.ts +++ b/ts/packages/core/test/tokens.test.ts @@ -5,6 +5,7 @@ import { createNodeIdentity } from "../src/adapters/node-identity.js"; import { createMemoryStorage } from "../src/adapters/memory-storage.js"; import { createSystemClock } from "../src/adapters/system-clock.js"; import { + canGrant, mintCapabilityToken, mintRevocationEntry, verifyCapabilityToken, @@ -1374,6 +1375,239 @@ describe("mintCapabilityToken", () => { }); }); +describe("canGrant", () => { + let issuer: IdentityPort; + let bearerIdentity: IdentityPort; + + beforeAll(async () => { + issuer = await generateEs256Identity(); + bearerIdentity = await generateEs256Identity(); + }); + + const now = 1_893_456_000_000; + const workScope: CapabilityScope = { kind: "folder", path: "/work" }; + + it("agrees with a real mint's own verdict: true when narrowing succeeds", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + const candidate = { + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH } as CapabilityScope, + expires: now + HOUR_MS, + delegationsRemaining: 0, + }; + expect( + canGrant(rootVerdict.token, bearerIdentity.deviceId, candidate, now), + ).toBe(true); + + const delegatedVerdict = await mintCapabilityToken({ + identity: bearerIdentity, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: (await generateEs256Identity()).deviceId, + ...candidate, + parent: rootVerdict.token, + }); + expect(delegatedVerdict.ok).toBe(true); + }); + + it("agrees with a real mint's own verdict: false when the querying device does not hold the token's bearer", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + issuer.deviceId, + { + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }, + now, + ), + ).toBe(false); + }); + + it("agrees with a real mint's own verdict: false when the candidate's expiry exceeds the held token's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS + 1, + }, + now, + ), + ).toBe(false); + }); + + it("agrees with a real mint's own verdict: false when the candidate's scope does not narrow the held token's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "exec:pty", + scope: { kind: "folder", path: "/elsewhere" }, + expires: now + HOUR_MS, + }, + now, + ), + ).toBe(false); + }); + + it("agrees with a real mint's own verdict: false when the candidate's capability differs", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "room:member", + scope: workScope, + expires: now + HOUR_MS, + }, + now, + ), + ).toBe(false); + }); + + it("agrees with a real mint's own verdict: false when the candidate's delegations-remaining would not be strictly less than the held token's", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "room:member", + scope: { kind: "room", path: ROOM_MEMBER_ROOM_PATH }, + expires: now + HOUR_MS, + delegationsRemaining: 1, + }, + now, + ), + ).toBe(false); + }); + + it("returns false for a candidate that is already expired, without needing to consult the held token at all", async () => { + const rootVerdict = await mintCapabilityToken({ + identity: issuer, + clock: fixedClock(now), + tokenId: nextTokenId(), + bearer: bearerIdentity.deviceId, + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }); + expect(rootVerdict.ok).toBe(true); + if (!rootVerdict.ok) return; + + expect( + canGrant( + rootVerdict.token, + bearerIdentity.deviceId, + { + capability: "exec:pty", + scope: workScope, + expires: now - 1, + }, + now, + ), + ).toBe(false); + }); + + it("returns false for a malformed held token", () => { + const malformedToken: CapabilityToken = [ + new Uint8Array(), + {}, + null, + new Uint8Array(P256_SIGNATURE_BYTE_LENGTH), + ]; + expect( + canGrant( + malformedToken, + bearerIdentity.deviceId, + { + capability: "exec:pty", + scope: workScope, + expires: now + HOUR_MS, + }, + now, + ), + ).toBe(false); + }); +}); + describe("mintRevocationEntry", () => { it("mints a revocation entry that verifyRevocationEntry accepts", async () => { const issuer = await generateEs256Identity();