From b6aae9c01ed41669d0cdc5d418a0b0b3e797827b Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Mon, 3 Aug 2026 20:20:36 -0400 Subject: [PATCH 1/3] fix(vc): bind a credential's issuer to the DID that signed it did-jwt-vc builds the credential issuer as `{ id: iss, ...payload.issuer }`, so an `issuer` object in the JWT payload replaces the `id` taken from `iss`. The signature binds `iss` only, and nothing compared the two. Anyone could sign a credential with their own key, name another DID as the issuer, and pass every issuer check downstream: `trustedIssuers` in `verifyParsedCredential`, `trustedReceiptIssuers` in `verifyPaymentReceipt`, and the status list issuer check in `isRevoked`. `parseJwtCredential` now rejects a credential whose `issuer.id` differs from the verified signer. The presentation path has the same weakness through the re-exported `verifyPresentation`; that is documented at the export and left for a separate change. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/vc-issuer-signer-binding.md | 29 +++++++++ packages/vc/src/index.ts | 13 ++++ .../verification/parse-jwt-credential.test.ts | 64 ++++++++++++++++++- .../src/verification/parse-jwt-credential.ts | 12 ++++ .../vc/src/verification/verify-proof.test.ts | 26 +++++++- 5 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 .changeset/vc-issuer-signer-binding.md diff --git a/.changeset/vc-issuer-signer-binding.md b/.changeset/vc-issuer-signer-binding.md new file mode 100644 index 0000000..86418ea --- /dev/null +++ b/.changeset/vc-issuer-signer-binding.md @@ -0,0 +1,29 @@ +--- +"@agentcommercekit/vc": patch +--- + +Security: bind a credential's issuer to the DID that signed it (CWE-290). + +`parseJwtCredential` returned the credential `normalizeCredential` builds from +the JWT. That function derives the issuer as `{ id: iss, ...payload.issuer }`, +so an `issuer` object in the payload replaces the `id` taken from `iss`. The +signature binds `iss` only, and nothing compared the two. + +Anyone could therefore sign a credential with their own key, put +`issuer: { id: "" }` in the payload, and produce a credential that +verifies and reports that DID as its issuer. Every issuer check downstream +accepted it: the `trustedIssuers` list in `verifyParsedCredential`, +`trustedReceiptIssuers` in `verifyPaymentReceipt`, and the status list issuer +check in `isRevoked`. With `jti` set to the status list URL, the same forgery +also passed the URL binding on a status list credential and cleared a +revocation. + +`parseJwtCredential` now rejects a credential whose `issuer.id` differs from the +verified signer, with `InvalidCredentialError`. + +This covers the credential path only. `verifyPresentation` is re-exported from +did-jwt-vc unchanged, and `normalizeJwtPresentationPayload` sets `holder` from +`iss` only when the payload carries no `holder`, so a presentation can still +name a holder that did not sign it. That function also does not verify the +proofs of the credentials it embeds. Nothing in this repository calls it. Treat +its result as unverified until a bound wrapper replaces it. diff --git a/packages/vc/src/index.ts b/packages/vc/src/index.ts index 749b572..3e98bb5 100644 --- a/packages/vc/src/index.ts +++ b/packages/vc/src/index.ts @@ -17,4 +17,17 @@ export * from "./verification/parse-jwt-credential" export * from "./verification/verify-parsed-credential" export * from "./verification/verify-proof" +/** + * Re-exported from did-jwt-vc unchanged. + * + * This does NOT bind the presentation to its signer the way + * {@link parseJwtCredential} binds a credential. `normalizeJwtPresentationPayload` + * takes `holder` from `iss` only when the payload carries no `holder`, so a + * presentation can name a holder that did not sign it. It also does not verify + * the proofs of the credentials it embeds. + * + * Check `holder` against the verified signer yourself, and pass each embedded + * credential's `proof.jwt` through {@link parseJwtCredential}, before you trust + * anything this returns. + */ export { verifyPresentation } diff --git a/packages/vc/src/verification/parse-jwt-credential.test.ts b/packages/vc/src/verification/parse-jwt-credential.test.ts index 98ce918..85b5308 100644 --- a/packages/vc/src/verification/parse-jwt-credential.test.ts +++ b/packages/vc/src/verification/parse-jwt-credential.test.ts @@ -3,7 +3,7 @@ import { createDidWebUri, getDidResolver, } from "@agentcommercekit/did" -import { createJwtSigner } from "@agentcommercekit/jwt" +import { createJwt, createJwtSigner } from "@agentcommercekit/jwt" import { generateKeypair } from "@agentcommercekit/keys" import { verifyCredential } from "did-jwt-vc" import { expect, it, vi } from "vitest" @@ -35,11 +35,35 @@ function mockDecodedCredential(verifiableCredential: unknown): void { const mocked = vi.mocked(verifyCredential) mocked.mockImplementationOnce(() => Promise.resolve( - Object.assign(Object.create(null), { verifiableCredential }), + Object.assign(Object.create(null), { + verifiableCredential, + // `parseJwtCredential` compares the decoded issuer against the DID the + // signature binds, so the stub must report a matching signer. + issuer: decodedIssuerId(verifiableCredential), + }), ), ) } +/** + * Read `issuer.id` off a decoded-credential fixture, which may be any shape. + */ +function decodedIssuerId(credential: unknown): string | undefined { + if (typeof credential !== "object" || credential === null) { + return undefined + } + + const { issuer } = credential as { issuer?: unknown } + + if (typeof issuer !== "object" || issuer === null) { + return undefined + } + + const { id } = issuer as { id?: unknown } + + return typeof id === "string" ? id : undefined +} + it("parseJwtCredential should parse a valid credential", async () => { const resolver = getDidResolver() @@ -139,3 +163,39 @@ it("returns the decoded credential for a JSON-LD object context entry", async () verifiableCredential, ) }) + +it("rejects a credential whose payload issuer does not match the signer", async () => { + const resolver = getDidResolver() + + const attackerKeypair = await generateKeypair("secp256k1") + const attackerDid = createDidWebUri("https://attacker.example.com") + resolver.addToCache( + attackerDid, + createDidDocumentFromKeypair({ + did: attackerDid, + keypair: attackerKeypair, + }), + ) + + const victimDid = createDidWebUri("https://issuer.example.com") + + // `normalizeCredential` builds the issuer as `{ id: iss, ...payload.issuer }`, + // so a payload-level `issuer.id` replaces the DID bound to the signature. + const jwt = await createJwt( + { + issuer: { id: victimDid }, + nbf: Math.floor(Date.now() / 1000) - 10, + vc: { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + credentialSubject: { id: "did:web:subject.example.com" }, + }, + }, + { issuer: attackerDid, signer: createJwtSigner(attackerKeypair) }, + { alg: "ES256K" }, + ) + + await expect(parseJwtCredential(jwt, resolver)).rejects.toThrow( + InvalidCredentialError, + ) +}) diff --git a/packages/vc/src/verification/parse-jwt-credential.ts b/packages/vc/src/verification/parse-jwt-credential.ts index 62531b8..5166ae2 100644 --- a/packages/vc/src/verification/parse-jwt-credential.ts +++ b/packages/vc/src/verification/parse-jwt-credential.ts @@ -65,5 +65,17 @@ export async function parseJwtCredential( ) } + // The signature binds the `iss` claim, and `result.issuer` is that claim. + // `normalizeCredential` builds the credential issuer as + // `{ id: iss, ...payload.issuer }`, so an `issuer.id` in the payload silently + // replaces the DID that signed. Without this check anyone can sign a + // credential with their own key, name another DID as the issuer, and pass + // every downstream issuer check. + if (result.verifiableCredential.issuer.id !== result.issuer) { + throw new InvalidCredentialError( + "Credential issuer does not match the DID that signed the JWT", + ) + } + return result.verifiableCredential } diff --git a/packages/vc/src/verification/verify-proof.test.ts b/packages/vc/src/verification/verify-proof.test.ts index a55f428..4e2278b 100644 --- a/packages/vc/src/verification/verify-proof.test.ts +++ b/packages/vc/src/verification/verify-proof.test.ts @@ -27,11 +27,35 @@ vi.mock("./verify-credential-jwt", () => ({ function mockDecodedCredential(verifiableCredential: unknown): void { vi.mocked(verifyCredential).mockImplementationOnce(() => Promise.resolve( - Object.assign(Object.create(null), { verifiableCredential }), + Object.assign(Object.create(null), { + verifiableCredential, + // `parseJwtCredential` compares the decoded issuer against the DID the + // signature binds, so the stub must report a matching signer. + issuer: decodedIssuerId(verifiableCredential), + }), ), ) } +/** + * Read `issuer.id` off a decoded-credential fixture, which may be any shape. + */ +function decodedIssuerId(credential: unknown): string | undefined { + if (typeof credential !== "object" || credential === null) { + return undefined + } + + const { issuer } = credential as { issuer?: unknown } + + if (typeof issuer !== "object" || issuer === null) { + return undefined + } + + const { id } = issuer as { id?: unknown } + + return typeof id === "string" ? id : undefined +} + describe("verifyProof", () => { const mockResolver: Resolvable = { resolve: vi.fn(), From 335b5d4e83de4011a6380b8cf481923bca9fa69e Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Mon, 3 Aug 2026 20:20:52 -0400 Subject: [PATCH 2/3] fix(vc): fail closed when revocation status cannot be verified `isRevoked` returned `false` for every failure. A network error, DNS failure, timeout, HTTP error, non-JSON body, or any body that did not match the expected shape read as "not revoked", so `verifyParsedCredential` accepted a revoked credential. Anyone able to disrupt the status list endpoint could use a revoked credential indefinitely. The fetched list was also trusted on shape alone: its proof was never verified, its issuer never checked, and it was never bound to the URL the credential named. `statusListIndex` went through `parseInt`, so a non-numeric value became `NaN` and read as an unset bit. An index past the end of the list read as unset too. The entry's `statusPurpose` was ignored, and an unrecognized `credentialStatus` was skipped silently. `isRevoked(credential, options)` now takes a resolver and throws `RevocationCheckError`, or `UnsupportedCredentialStatusError` for a status it does not implement. It verifies the list's proof, requires a trusted issuer and a matching `id`, rejects an expired, pre-dated or multi-bit list, checks the purpose, validates the index and its bounds, restricts the URL to http(s), refuses redirects, and bounds the body and the decode. `verifyParsedCredential` checks `trustedIssuers` before the revocation fetch, so an untrusted issuer cannot make the verifier dereference a URL it chose. Status lists carry a 24 hour expiry by default, because every earlier version of a list stays validly signed and can be replayed to clear a later revocation. Pass `null` to opt out, or bound age with `maxStatusListAgeMs`. Both errors carry one fixed message, with the URL and the response in `detail` and `cause`, because API handlers return the message to the caller. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/vc-revocation-fail-closed.md | 60 ++ packages/vc/AGENTS.md | 2 +- packages/vc/README.md | 41 +- .../src/revocation/status-list-credential.ts | 17 + packages/vc/src/revocation/types.ts | 6 + packages/vc/src/schemas/valibot.ts | 6 + packages/vc/src/schemas/zod.ts | 6 + packages/vc/src/verification/errors.ts | 52 ++ .../vc/src/verification/is-revoked.test.ts | 606 ++++++++++++++++-- packages/vc/src/verification/is-revoked.ts | 526 ++++++++++++++- packages/vc/src/verification/is-verifiable.ts | 22 + .../verify-parsed-credential.test.ts | 51 ++ .../verification/verify-parsed-credential.ts | 38 +- .../verify-revoked-credential.test.ts | 117 ++++ .../api-utils/src/middleware/error-handler.ts | 17 +- 15 files changed, 1454 insertions(+), 113 deletions(-) create mode 100644 .changeset/vc-revocation-fail-closed.md create mode 100644 packages/vc/src/verification/is-verifiable.ts create mode 100644 packages/vc/src/verification/verify-revoked-credential.test.ts diff --git a/.changeset/vc-revocation-fail-closed.md b/.changeset/vc-revocation-fail-closed.md new file mode 100644 index 0000000..601c5a4 --- /dev/null +++ b/.changeset/vc-revocation-fail-closed.md @@ -0,0 +1,60 @@ +--- +"@agentcommercekit/vc": minor +"@agentcommercekit/ack-pay": minor +--- + +Security: fail closed when a credential's revocation status cannot be verified +(CWE-299). + +`isRevoked` treated every failure as "not revoked". A network error, DNS +failure, timeout, HTTP 4xx/5xx, non-JSON body, or any body that did not match +the expected shape resolved to `false`, so `verifyParsedCredential` accepted a +revoked credential. Anyone able to disrupt reachability of the status list — or +simply presenting a credential while the status endpoint was down — could use a +revoked credential indefinitely. + +The fetched status list was also trusted on shape alone: its proof was never +verified, its issuer never checked, and it was never bound to the URL the +credential pointed at. A tampered or substituted list therefore cleared +revocation for every credential it covered. `statusListIndex` went through +`parseInt`, so a non-numeric value produced `NaN` and read as an unset bit, and +an index past the end of the list also read as unset. The entry's +`statusPurpose` was ignored, and a `credentialStatus` of an unrecognized type +was silently skipped. + +`isRevoked(credential, options)` now takes a resolver and throws +`RevocationCheckError` when the status cannot be established, or +`UnsupportedCredentialStatusError` for a status it does not implement. It +verifies the status list credential's proof, requires it to be issued by the +credential's issuer (override with `revocation.trustedStatusListIssuers`), +requires its `id` to match the dereferenced URL, rejects an expired list, +requires the entry and list `statusPurpose` to match, validates +`statusListIndex` and its bounds, restricts the status list URL to `http(s)`, +and applies a 5s timeout (override with `revocation.statusListTimeoutMs`). + +`verifyParsedCredential` accepts a `revocation` option and now rejects +credentials whose revocation status is undeterminable, so `verifyPaymentReceipt` +rejects them too. It also checks `trustedIssuers` before the revocation check, +so an untrusted issuer can no longer make the verifier dereference a URL of the +issuer's choosing. + +A status list credential stays validly signed after the issuer publishes a newer +version, so anyone holding an older copy could serve it back and clear a later +revocation. `createStatusListCredential` now sets an `expirationDate`, 24 hours +out by default, and issuers must republish the list before it lapses. For +issuers that publish no expiry, `revocation.maxStatusListAgeMs` bounds how old +an accepted list may be. + +`RevocationCheckError` carries one fixed message and puts the URL and the +response in `detail` and `cause`. API error handlers return the message of a +`CredentialVerificationError` to the caller, so the detail must not travel with +it. + +This release is `minor`, not `patch`: `isRevoked` takes a required second +argument, `isRevocable` accepts fewer shapes, and both throw where they returned +`false`. + +The `examples/issuer` status endpoint served the credential wrapped in this +API's `{ ok, data }` envelope, which is not a credential. Every revocation check +against it failed open. It now serves the signed credential directly, as the +W3C Bitstring Status List spec requires. diff --git a/packages/vc/AGENTS.md b/packages/vc/AGENTS.md index 8e2b937..8175370 100644 --- a/packages/vc/AGENTS.md +++ b/packages/vc/AGENTS.md @@ -9,7 +9,7 @@ W3C Verifiable Credentials: creation, signing, verification, and revocation. ## Verification Chain ``` -verifyParsedCredential() → verifyProof() → check expiry → check revocation → verify trusted issuer → verify claims +verifyParsedCredential() → verifyProof() → check expiry → verify trusted issuer → check revocation → verify claims ``` Claims are verified via the `ClaimVerifier` strategy interface: diff --git a/packages/vc/README.md b/packages/vc/README.md index 6b3ef99..51aa5d5 100644 --- a/packages/vc/README.md +++ b/packages/vc/README.md @@ -80,9 +80,46 @@ const revocableCredential = await makeRevocable(credential, { }) // Check if credential is revoked -const revoked = await isRevoked(credential) +const revoked = await isRevoked(credential, { resolver }) ``` +`isRevoked` fails closed. It resolves the status list credential, verifies its +proof, and requires it to be issued by the credential's issuer (override with +`trustedStatusListIssuers`) and bound to the URL the credential points at. If +the status cannot be established — the list is unreachable, unsigned, expired, +served by the wrong issuer, or too short to cover the credential's index — it +throws a `RevocationCheckError` rather than reporting "not revoked". A +`credentialStatus` this library does not implement throws +`UnsupportedCredentialStatusError` for the same reason. + +Pass the credential decoded from a verified proof, not a caller-supplied +object: an unverified `credentialStatus` is attacker-controlled. + +Issuers must serve the status list credential itself at the status list URL, +signed and unwrapped. A body wrapped in an API envelope is not a credential and +will fail the check. + +Every version of a status list stays validly signed after the issuer publishes a +later one, so a party holding an older copy can serve it back and clear a +revocation that happened after it. `createStatusListCredential` therefore sets an +`expirationDate` 24 hours out by default; republish the list before it lapses, or +pass your own `expirationDate`. When you consume lists from an issuer that +publishes no expiry, set `maxStatusListAgeMs` to bound how old an accepted list +may be: + +```ts +await isRevoked(credential, { resolver, maxStatusListAgeMs: 60 * 60 * 1000 }) +``` + +`RevocationCheckError` uses one fixed message and puts the URL and the response +in `detail` and `cause`. `UnsupportedCredentialStatusError` does the same with +the status it could not read. Log `detail`; do not return it to a caller. + +Two limits on the fetch are worth knowing. `statusListTimeoutMs` bounds the +status list request only, not the DID resolution that verifies its proof. And +the request does not follow redirects, so serve the credential at the URL the +`statusListCredential` names. + ## API Reference ### Creation and Signing @@ -96,7 +133,7 @@ const revoked = await isRevoked(credential) - `verifyParsedCredential(credential, options)` - Verify a credential's proof, expiration, and other claims - `verifyProof(proof, resolver)` - Verify a credential's proof - `isExpired(credential)` - Check if a credential is expired -- `isRevoked(credential)` - Check if a credential has been revoked +- `isRevoked(credential, options)` - Check if a credential has been revoked, against a verified status list credential - `parsedJwtCredential(jwt, resolver)` - Parse a JWT credential string into a W3C Credential ### Revocation diff --git a/packages/vc/src/revocation/status-list-credential.ts b/packages/vc/src/revocation/status-list-credential.ts index c460870..3202b3d 100644 --- a/packages/vc/src/revocation/status-list-credential.ts +++ b/packages/vc/src/revocation/status-list-credential.ts @@ -17,8 +17,23 @@ type CreateStatusListCredentialParams = { * The issuer of the status list credential. */ issuer: string + /** + * When the status list credential stops being valid. + * + * Defaults to 24 hours from now. Every version of a status list stays validly + * signed after the issuer publishes a later one, so a party that kept an + * earlier copy can serve it back and clear a revocation that happened after + * it. An expiry bounds how long that replay works. Issuers must re-sign and + * republish the list before it lapses. + * + * Pass `null` for a list with no expiry. Do this only for a list you cannot + * re-sign on a schedule, and expect verifiers to bound its age themselves. + */ + expirationDate?: Date | null } +const DEFAULT_STATUS_LIST_LIFETIME_MS = 24 * 60 * 60 * 1000 + /** * Generates a status list credential. * @@ -29,6 +44,7 @@ export function createStatusListCredential({ url, encodedList, issuer, + expirationDate = new Date(Date.now() + DEFAULT_STATUS_LIST_LIFETIME_MS), }: CreateStatusListCredentialParams): BitstringStatusListCredential { const credentialSubject: BitstringStatusListSubject = { id: `${url}#list`, @@ -47,6 +63,7 @@ export function createStatusListCredential({ statusPurpose: "revocation", encodedList, }, + expirationDate: expirationDate ?? undefined, }) return { ...credential, credentialSubject } diff --git a/packages/vc/src/revocation/types.ts b/packages/vc/src/revocation/types.ts index d4c656b..9f92db3 100644 --- a/packages/vc/src/revocation/types.ts +++ b/packages/vc/src/revocation/types.ts @@ -4,6 +4,12 @@ import type { bitstringStatusListClaimSchema } from "../schemas/valibot" import type { W3CCredential } from "../types" type BitstringStatusListEntry = { + /** + * The specification makes this optional, but `CredentialStatus` requires it + * to stay assignable to did-jwt-vc's `CredentialPayload`. No check here reads + * it: the list is bound to the credential through `statusListCredential` and + * the list's own `id`. + */ id: string type: "BitstringStatusListEntry" statusPurpose: string diff --git a/packages/vc/src/schemas/valibot.ts b/packages/vc/src/schemas/valibot.ts index 85d8c52..7b1162c 100644 --- a/packages/vc/src/schemas/valibot.ts +++ b/packages/vc/src/schemas/valibot.ts @@ -43,4 +43,10 @@ export const bitstringStatusListClaimSchema = v.object({ type: v.literal("BitstringStatusList"), statusPurpose: v.string(), encodedList: v.string(), + /** + * Bits per entry. Defaults to 1 when absent. A list that packs several bits + * per entry puts them at `statusListIndex * statusSize`, so a reader that + * ignores this field reads the wrong bit. + */ + statusSize: v.optional(v.number()), }) diff --git a/packages/vc/src/schemas/zod.ts b/packages/vc/src/schemas/zod.ts index 2bdc549..e8695fe 100644 --- a/packages/vc/src/schemas/zod.ts +++ b/packages/vc/src/schemas/zod.ts @@ -39,4 +39,10 @@ export const bitstringStatusListClaimSchema = z.object({ type: z.literal("BitstringStatusList"), statusPurpose: z.string(), encodedList: z.string(), + /** + * Bits per entry. Defaults to 1 when absent. A list that packs several bits + * per entry puts them at `statusListIndex * statusSize`, so a reader that + * ignores this field reads the wrong bit. + */ + statusSize: z.number().optional(), }) diff --git a/packages/vc/src/verification/errors.ts b/packages/vc/src/verification/errors.ts index f694f61..911c1e3 100644 --- a/packages/vc/src/verification/errors.ts +++ b/packages/vc/src/verification/errors.ts @@ -45,6 +45,58 @@ export class CredentialRevokedError extends CredentialVerificationError { } } +/** + * Thrown when a credential's revocation status cannot be established — the + * status list is unreachable, malformed, unsigned, signed by the wrong issuer, + * or does not cover the credential's index. + * + * An undeterminable status is NOT the same as "not revoked": treating it as + * such lets anyone who can disrupt the status list endpoint resurrect a revoked + * credential (CWE-299). + */ +export class RevocationCheckError extends CredentialVerificationError { + /** + * What actually went wrong, for logs. Kept out of `message` on purpose: API + * error handlers return the message of a {@link CredentialVerificationError} + * to the caller, and the detail names the URL that was dereferenced and the + * response it produced. + */ + readonly detail?: string + + constructor( + message = "Unable to determine credential revocation status", + options: { cause?: unknown; detail?: string } = {}, + ) { + super(message, { cause: options.cause }) + this.name = "RevocationCheckError" + this.detail = options.detail + } +} + +/** + * Thrown when a credential carries a `credentialStatus` this library cannot + * evaluate. The credential may well be revoked, so it is rejected rather than + * accepted on the strength of a status we never read. + */ +export class UnsupportedCredentialStatusError extends CredentialVerificationError { + /** + * Which status could not be evaluated, for logs. Kept out of `message` for + * the same reason as {@link RevocationCheckError.detail}: the value comes + * from the credential, and API error handlers return the message to the + * caller. + */ + readonly detail?: string + + constructor( + message = "Unsupported credential status", + options: { detail?: string } = {}, + ) { + super(message) + this.name = "UnsupportedCredentialStatusError" + this.detail = options.detail + } +} + export class UntrustedIssuerError extends CredentialVerificationError { constructor(message = "Issuer is not a known trusted issuer") { super(message) diff --git a/packages/vc/src/verification/is-revoked.test.ts b/packages/vc/src/verification/is-revoked.test.ts index 08e0410..6c49aa7 100644 --- a/packages/vc/src/verification/is-revoked.test.ts +++ b/packages/vc/src/verification/is-revoked.test.ts @@ -1,9 +1,128 @@ +import { + createDidDocumentFromKeypair, + createDidWebUri, + getDidResolver, +} from "@agentcommercekit/did" +import { createJwtSigner } from "@agentcommercekit/jwt" +import { generateKeypair, type Keypair } from "@agentcommercekit/keys" import { BitBuffer } from "bit-buffers" import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" import { createStatusListCredential } from "../revocation/status-list-credential" +import { signCredential } from "../signing/sign-credential" import type { W3CCredential } from "../types" +import { + RevocationCheckError, + UnsupportedCredentialStatusError, +} from "./errors" import { isRevocable, isRevoked } from "./is-revoked" +import { parseJwtCredential } from "./parse-jwt-credential" + +const statusListUrl = "https://issuer.example.com/status/1" + +async function captureRevocationError( + call: Promise, +): Promise { + try { + await call + } catch (error) { + if (error instanceof RevocationCheckError) { + return error + } + throw error + } + + throw new Error("Expected the revocation check to fail, but it resolved") +} + +const resolver = getDidResolver() +let issuerDid: string +let issuerKeypair: Keypair +let otherIssuerDid: string +let otherIssuerKeypair: Keypair + +async function addIssuer(host: string) { + const keypair = await generateKeypair("secp256k1") + const did = createDidWebUri(host) + resolver.addToCache(did, createDidDocumentFromKeypair({ did, keypair })) + return { did, keypair } +} + +beforeEach(async () => { + const issuer = await addIssuer("https://issuer.example.com") + issuerDid = issuer.did + issuerKeypair = issuer.keypair + + const other = await addIssuer("https://other.example.com") + otherIssuerDid = other.did + otherIssuerKeypair = other.keypair +}) + +/** + * Build the JSON an issuer serves at a status list URL: a status list + * credential carrying a real `JwtProof2020` proof. + */ +async function signedStatusList({ + revokedIndex, + url = statusListUrl, + did = issuerDid, + keypair = issuerKeypair, + encodedList, + statusPurpose = "revocation", + expirationDate, + issuedAt, + statusSize, + credentialType, +}: { + revokedIndex?: number + url?: string + did?: string + keypair?: Keypair + encodedList?: string + statusPurpose?: string + expirationDate?: Date | null + issuedAt?: Date + statusSize?: number + credentialType?: string[] +} = {}) { + const bits = new BitBuffer(1024) + const list = + encodedList ?? + (revokedIndex === undefined + ? bits.toBitstring() + : bits.set(revokedIndex).toBitstring()) + + const base = createStatusListCredential({ + url, + encodedList: list, + issuer: did, + expirationDate, + }) + + // `createStatusListCredential` only issues revocation lists and always stamps + // the current time, so another purpose or an older issuance date has to be + // assembled by hand. + const credential = { + ...base, + credentialSubject: { + ...base.credentialSubject, + statusPurpose, + ...(statusSize === undefined ? {} : { statusSize }), + }, + ...(issuedAt ? { issuanceDate: issuedAt.toISOString() } : {}), + ...(credentialType ? { type: credentialType } : {}), + } + + const jwt = await signCredential(credential, { + did, + signer: createJwtSigner(keypair), + alg: "ES256K", + }) + + const parsed = await parseJwtCredential(jwt, resolver) + + return { ...parsed, credentialSubject: credential.credentialSubject } +} function buildCredential( credentialStatus?: W3CCredential["credentialStatus"], @@ -11,67 +130,57 @@ function buildCredential( return { "@context": ["https://www.w3.org/2018/credentials/v1"], type: ["VerifiableCredential"], - issuer: { id: "did:example:123" }, + issuer: { id: issuerDid }, issuanceDate: "2024-01-01T00:00:00.000Z", credentialSubject: { id: "did:example:subject" }, credentialStatus, } } -function statusEntry( - fields: Record, -): W3CCredential["credentialStatus"] { +function statusEntry(fields: Record = {}) { return { - id: "https://example.com/status-list/1#0", + id: `${statusListUrl}#5`, type: "BitstringStatusListEntry", + statusPurpose: "revocation", + statusListIndex: "5", + statusListCredential: statusListUrl, ...fields, } } -function getStatusListCredential(revokedIndex?: number) { - let bitBuffer = new BitBuffer() - if (revokedIndex !== undefined) { - bitBuffer = bitBuffer.set(revokedIndex) - } - return createStatusListCredential({ - url: "https://example.com/status-list/1", - encodedList: bitBuffer.toBitstring(), - issuer: "did:example:123", +describe("isRevocable", () => { + it("returns false when no credential status is present", () => { + expect(isRevocable(buildCredential(undefined))).toBe(false) }) -} -describe("isRevocable", () => { - it("returns false if no credential status is present", () => { - const credential = buildCredential(undefined) + it("returns false when the status list credential is missing", () => { + const { statusListCredential: _, ...entry } = statusEntry() - expect(isRevocable(credential)).toBe(false) + expect(isRevocable(buildCredential(entry))).toBe(false) }) - it("returns false if status list not present", () => { - const credential = buildCredential(statusEntry({ statusListIndex: "0" })) + it("returns false when the index is missing", () => { + const { statusListIndex: _, ...entry } = statusEntry() - expect(isRevocable(credential)).toBe(false) + expect(isRevocable(buildCredential(entry))).toBe(false) }) - it("returns false if index is not present", () => { - const credential = buildCredential( - statusEntry({ - statusListCredential: "https://example.com/status-list/1", - }), - ) + it("returns false when the status purpose is missing", () => { + const { statusPurpose: _, ...entry } = statusEntry() - expect(isRevocable(credential)).toBe(false) + expect(isRevocable(buildCredential(entry))).toBe(false) }) - it("returns true for a revocable credential", () => { - const credential = buildCredential( - statusEntry({ - statusListIndex: "0", - statusListCredential: "https://example.com/status-list/1", - }), - ) + it("returns false for an unrecognized credential status type", () => { + expect( + isRevocable( + buildCredential(statusEntry({ type: "StatusList2021Entry" })), + ), + ).toBe(false) + }) - expect(isRevocable(credential)).toBe(true) + it("returns true for a well-formed BitstringStatusListEntry", () => { + expect(isRevocable(buildCredential(statusEntry()))).toBe(true) }) }) @@ -87,50 +196,423 @@ describe("isRevoked", () => { mockFetch.mockReset() }) - it("returns false for non-revocable credentials", async () => { - const credential = buildCredential(undefined) + it("returns false for a credential with no credential status", async () => { + await expect( + isRevoked(buildCredential(undefined), { resolver }), + ).resolves.toBe(false) + }) - mockFetch.mockResolvedValueOnce(Response.json(getStatusListCredential())) + it("returns false when the bit at the index is not set", async () => { + mockFetch.mockResolvedValueOnce(Response.json(await signedStatusList())) - expect(await isRevoked(credential)).toBe(false) + await expect( + isRevoked(buildCredential(statusEntry()), { resolver }), + ).resolves.toBe(false) }) - it("returns false when status list cannot be fetched", async () => { - const credential = buildCredential( - statusEntry({ - statusListIndex: "0", - statusListCredential: "https://example.com/status-list/1", - }), + it("returns true when the bit at the index is set", async () => { + mockFetch.mockResolvedValueOnce( + Response.json(await signedStatusList({ revokedIndex: 5 })), ) + await expect( + isRevoked(buildCredential(statusEntry()), { resolver }), + ).resolves.toBe(true) + }) + + it("throws when the status list cannot be fetched", async () => { mockFetch.mockRejectedValueOnce(new Error("Network error")) - expect(await isRevoked(credential)).toBe(false) + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/Could not fetch status list credential/) + }) + + it("throws when the status list responds with an error status", async () => { + mockFetch.mockResolvedValueOnce(new Response("nope", { status: 502 })) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/returned HTTP 502/) + }) + + it("throws when the status list is not valid JSON", async () => { + mockFetch.mockResolvedValueOnce(new Response("error")) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/is not valid JSON/) + }) + + it("throws when the status list is wrapped in an API envelope", async () => { + mockFetch.mockResolvedValueOnce( + Response.json({ ok: true, data: await signedStatusList() }), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch( + /did not return a signed BitstringStatusListCredential/, + ) + }) + + it("throws when the status list carries no proof", async () => { + const { proof: _, ...unsigned } = await signedStatusList() + mockFetch.mockResolvedValueOnce(Response.json(unsigned)) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch( + /did not return a signed BitstringStatusListCredential/, + ) + }) + + it("throws when the status list proof does not verify", async () => { + const statusList = await signedStatusList() + mockFetch.mockResolvedValueOnce( + Response.json({ + ...statusList, + proof: { type: "JwtProof2020", jwt: "not.a.jwt" }, + }), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/has an invalid proof/) + }) + + it("throws when the status list is signed by an untrusted issuer", async () => { + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ + did: otherIssuerDid, + keypair: otherIssuerKeypair, + }), + ), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/not a trusted status list issuer/) + }) + + it("accepts a status list from an explicitly trusted third-party issuer", async () => { + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ + revokedIndex: 5, + did: otherIssuerDid, + keypair: otherIssuerKeypair, + }), + ), + ) + + await expect( + isRevoked(buildCredential(statusEntry()), { + resolver, + trustedStatusListIssuers: [otherIssuerDid], + }), + ).resolves.toBe(true) + }) + + it("throws when the status list is a different, validly signed list", async () => { + // An empty list the same issuer signed for a different URL must not stand in + // for the list this credential points at. + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ url: "https://issuer.example.com/status/2" }), + ), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/declares a different id/) + }) + + it("throws when the status list tracks a different status purpose", async () => { + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ + revokedIndex: 5, + statusPurpose: "suspension", + }), + ), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/tracks 'suspension'/) + }) + + it("throws for a non-numeric status list index", async () => { + mockFetch.mockResolvedValueOnce( + Response.json(await signedStatusList({ revokedIndex: 5 })), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry({ statusListIndex: "five" })), { + resolver, + }), + ) + + expect(error.detail).toMatch(/not a non-negative integer/) + }) + + it("throws for a status list index with trailing garbage", async () => { + mockFetch.mockResolvedValueOnce( + Response.json(await signedStatusList({ revokedIndex: 5 })), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry({ statusListIndex: "5abc" })), { + resolver, + }), + ) + + expect(error.detail).toMatch(/not a non-negative integer/) + }) + + it("throws for an index beyond the end of the status list", async () => { + mockFetch.mockResolvedValueOnce(Response.json(await signedStatusList())) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry({ statusListIndex: "99999" })), { + resolver, + }), + ) + + expect(error.detail).toMatch(/is outside the status list/) + }) + + it("throws for a status list URL with a non-http scheme", async () => { + const error = await captureRevocationError( + isRevoked( + buildCredential( + statusEntry({ statusListCredential: "file:///etc/passwd" }), + ), + { resolver }, + ), + ) + + expect(error.detail).toMatch(/not an absolute http\(s\) URL/) + + expect(mockFetch).not.toHaveBeenCalled() }) - it("returns false when bit at index is not set", async () => { - const credential = buildCredential( - statusEntry({ - statusListIndex: "5", - statusListCredential: "https://example.com/status-list/1", + it("throws for a credential status type it cannot evaluate", async () => { + await expect( + isRevoked(buildCredential(statusEntry({ type: "StatusList2021Entry" })), { + resolver, }), + ).rejects.toThrow(UnsupportedCredentialStatusError) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("throws for a status purpose it cannot evaluate", async () => { + await expect( + isRevoked(buildCredential(statusEntry({ statusPurpose: "suspension" })), { + resolver, + }), + ).rejects.toThrow(UnsupportedCredentialStatusError) + + expect(mockFetch).not.toHaveBeenCalled() + }) + + it("throws when the encoded list cannot be decoded", async () => { + mockFetch.mockResolvedValueOnce( + Response.json(await signedStatusList({ encodedList: "not-a-bitstring" })), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), ) - mockFetch.mockResolvedValueOnce(Response.json(getStatusListCredential())) + expect(error.detail).toMatch(/unreadable encodedList/) + }) + + it("returns false for a null credential status", async () => { + // The type forbids `null`, but a JWT payload can carry it, and reading + // `.type` off it used to throw a TypeError instead of failing closed. + // oxlint-disable-next-line typescript/no-unsafe-type-assertion -- models an untyped JWT payload + const credential = { + ...buildCredential(undefined), + credentialStatus: null, + } as unknown as W3CCredential - expect(await isRevoked(credential)).toBe(false) + await expect(isRevoked(credential, { resolver })).resolves.toBe(false) }) - it("returns true when bit at index is set", async () => { - const credential = buildCredential( - statusEntry({ - statusListIndex: "5", - statusListCredential: "https://example.com/status-list/1", + it("throws when the status list has expired", async () => { + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ + revokedIndex: 5, + expirationDate: new Date(Date.now() - 1000), + }), + ), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/is expired/) + }) + + it("throws when a replayed status list is older than maxStatusListAgeMs", async () => { + // The list is validly signed by the trusted issuer and bound to the right + // URL. Only its age shows that it predates the revocation. + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ + issuedAt: new Date(Date.now() - 60 * 60 * 1000), + }), + ), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { + resolver, + maxStatusListAgeMs: 60_000, }), ) - mockFetch.mockResolvedValueOnce(Response.json(getStatusListCredential(5))) + expect(error.detail).toMatch(/over the 60000ms limit/) + }) - expect(await isRevoked(credential)).toBe(true) + it("accepts a status list within maxStatusListAgeMs", async () => { + mockFetch.mockResolvedValueOnce( + Response.json(await signedStatusList({ revokedIndex: 5 })), + ) + + await expect( + isRevoked(buildCredential(statusEntry()), { + resolver, + maxStatusListAgeMs: 60_000, + }), + ).resolves.toBe(true) + }) + + it("throws for a multi-bit status list it cannot read", async () => { + const statusList = await signedStatusList({ statusSize: 2 }) + mockFetch.mockResolvedValueOnce(Response.json(statusList)) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + // The entry bits of a statusSize 2 list live at index * 2, so reading bit + // `index` would report some other credential's status. + expect(error.detail).toMatch(/uses statusSize 2/) + }) + + it("throws when the fetched credential is not a status list credential", async () => { + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ credentialType: ["VerifiableCredential"] }), + ), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/is not a BitstringStatusListCredential/) + }) + + it("refuses to follow a redirect away from the status list URL", async () => { + mockFetch.mockResolvedValueOnce(Response.json(await signedStatusList())) + + await isRevoked(buildCredential(statusEntry()), { resolver }) + + expect(mockFetch.mock.calls[0]?.[1]?.redirect).toBe("error") + }) + + it("throws when the encoded list exceeds the decode limit", async () => { + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ encodedList: "u".repeat(70_000) }), + ), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/encodedList over the 65536 byte limit/) + }) + + it("throws when the status list body exceeds the size limit", async () => { + mockFetch.mockResolvedValueOnce( + new Response("x".repeat(5_000_001), { + headers: { "content-type": "application/json" }, + }), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/over the 5000000 byte limit/) + }) + + it("throws when the status list declares an oversized content-length", async () => { + mockFetch.mockResolvedValueOnce( + new Response("{}", { headers: { "content-length": "9999999999" } }), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/declares 9999999999 bytes/) + }) + + it("asks for the credential media types the specification serves", async () => { + mockFetch.mockResolvedValueOnce(Response.json(await signedStatusList())) + + await isRevoked(buildCredential(statusEntry()), { resolver }) + + const headers = new Headers(mockFetch.mock.calls[0]?.[1]?.headers) + const accept = headers.get("accept") ?? "" + + expect(accept).toContain("application/vc+ld+json") + expect(accept).toContain("application/json") + }) + + it("keeps the dereferenced URL out of the thrown message", async () => { + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED 169.254.169.254")) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + // API error handlers return this message to the caller, so it must not + // report which host answered or how. + expect(error.message).toBe( + "Unable to determine credential revocation status", + ) + expect(error.message).not.toContain(statusListUrl) + expect(error.message).not.toContain("ECONNREFUSED") + expect(error.detail).toContain(statusListUrl) }) }) diff --git a/packages/vc/src/verification/is-revoked.ts b/packages/vc/src/verification/is-revoked.ts index d3eea26..4825553 100644 --- a/packages/vc/src/verification/is-revoked.ts +++ b/packages/vc/src/verification/is-revoked.ts @@ -1,3 +1,4 @@ +import type { Resolvable } from "@agentcommercekit/did" import { BitBuffer } from "bit-buffers" import { isStatusListCredential } from "../revocation/is-status-list-credential" @@ -5,66 +6,529 @@ import type { BitstringStatusListCredential, Revocable, } from "../revocation/types" -import type { W3CCredential } from "../types" +import type { Verifiable, W3CCredential } from "../types" +import { + RevocationCheckError, + UnsupportedCredentialStatusError, +} from "./errors" +import { isVerifiable } from "./is-verifiable" +import { verifyProof } from "./verify-proof" + +const DEFAULT_STATUS_LIST_TIMEOUT_MS = 5_000 + +/** + * Tolerance for a status list dated ahead of this machine's clock. + * + * Matches the skew `did-jwt` allows when it checks `nbf`, so this check is no + * stricter than the proof verification below it. Without it, an issuer whose + * clock leads by a second fails every revocation check. + */ +const CLOCK_SKEW_MS = 300_000 + +/** + * Cap on the status list response body, applied while the body arrives. + */ +const MAX_STATUS_LIST_BYTES = 5_000_000 + +/** + * Default cap on the `encodedList` string, which is gzipped and then base64 + * encoded. + * + * `BitBuffer.fromBitstring` inflates with no output limit, so the compressed + * length is the only bound available on what the decode allocates. This does + * not make the decode cheap: deflate reaches roughly 1000:1, so 64 KB can still + * produce tens of megabytes. It bounds the cost, and the list must already + * carry a valid proof from a trusted issuer to get this far. + * + * A sparse list of a few hundred thousand entries compresses to well under + * this. A large list with many bits set does not, so callers that consume one + * can raise the cap through `maxEncodedListBytes`. + */ +const DEFAULT_MAX_ENCODED_LIST_BYTES = 64 * 1024 + +export type RevocationCheckOptions = { + /** + * The resolver used to verify the status list credential's proof. + */ + resolver: Resolvable + /** + * The DIDs accepted as issuers of the status list credential. Defaults to the + * issuer of the credential being checked, which is the only party the + * credential itself vouches for. + */ + trustedStatusListIssuers?: string[] + /** + * Milliseconds to wait for the status list credential. Defaults to 5000. + * + * This bounds the status list fetch only. Verifying the list's proof resolves + * the issuer's DID, and that resolution carries its own timeout policy. + */ + statusListTimeoutMs?: number + /** + * Cap on the length of the list's `encodedList` string. Defaults to 65536. + * + * The decode inflates this string with no output limit, so the cap is the + * only bound on what it allocates. Raise it to consume a large list with many + * bits set, which compresses less than a sparse one. A fixed 5 MB cap on the + * whole response body applies first, so raising this beyond that has no + * effect. + */ + maxEncodedListBytes?: number + /** + * Reject a status list issued more than this many milliseconds ago. + * + * A status list credential stays validly signed after the issuer revokes a + * credential in a later version of the list. Anyone who kept a copy of the + * earlier list can serve it back and clear the revocation. An expiry on the + * list closes that replay, and this option closes it for issuers that publish + * a list with no expiry. Off by default, because the W3C Bitstring Status + * List specification does not require an expiry. + */ + maxStatusListAgeMs?: number +} + +/** + * Message for every failure that leaves the revocation status unknown. + * + * The detail goes in the error's `cause`, not its message. `RevocationCheckError` + * extends `CredentialVerificationError`, and API error handlers return that + * message to the caller. A message naming the URL and the remote HTTP status + * turns a verifier into a probe for hosts and ports it can reach. + */ +const UNDETERMINED_STATUS_MESSAGE = + "Unable to determine credential revocation status" + +function undetermined(detail: string, cause?: unknown): RevocationCheckError { + return new RevocationCheckError(UNDETERMINED_STATUS_MESSAGE, { + cause, + detail, + }) +} /** - * Check if a credential is revocable + * Normalize an absolute `http(s)` URL, or return `undefined` if the value is + * not one. Restricting the scheme keeps status list resolution from being + * pointed at `file:`, `data:` or similar targets. + */ +function toHttpUrl(value: string): string | undefined { + let url: URL + + try { + url = new URL(value) + } catch { + return undefined + } + + return url.protocol === "https:" || url.protocol === "http:" + ? url.href + : undefined +} + +/** + * Check if a credential carries a status entry this library can evaluate. * * @param credential - The {@link W3CCredential} to check - * @returns `true` if the credential is revocable, `false` otherwise + * @returns `true` if the credential has a well-formed `BitstringStatusListEntry`, + * `false` otherwise */ export function isRevocable( credential: T, ): credential is Revocable { + const status = credential.credentialStatus + return ( - credential.credentialStatus !== undefined && - "statusListCredential" in credential.credentialStatus && - "statusListIndex" in credential.credentialStatus + typeof status === "object" && + status !== null && + status.type === "BitstringStatusListEntry" && + "statusPurpose" in status && + typeof status.statusPurpose === "string" && + "statusListCredential" in status && + typeof status.statusListCredential === "string" && + "statusListIndex" in status && + typeof status.statusListIndex === "string" ) } -async function fetchStatusList( - credential: Revocable, -): Promise { - const statusListUrl = credential.credentialStatus.statusListCredential +/** + * `parseInt` maps non-numeric input to `NaN` and silently accepts trailing + * garbage; both resolve to an unset bit, i.e. "not revoked". Require the exact + * non-negative integer string the spec calls for instead. + */ +function parseStatusListIndex(value: string): number { + if (!/^\d+$/.test(value)) { + throw undetermined( + `Invalid statusListIndex: '${value}' is not a non-negative integer`, + ) + } + + const index = Number(value) + + if (!Number.isSafeInteger(index)) { + throw undetermined(`statusListIndex out of range: '${value}'`) + } + + return index +} + +/** + * Read a response body, stopping as soon as it passes `limit` bytes. + * + * `response.text()` buffers the whole body first, so a server that streams + * chunks without a `content-length` can put any amount of data in memory before + * a size check on the result runs. + */ +async function readBoundedText( + response: Response, + limit: number, +): Promise { + if (!response.body) { + return "" + } + + const reader = response.body.getReader() + const decoder = new TextDecoder() + let received = 0 + let text = "" try { - const statusListResponse = await fetch(statusListUrl) - const statusListJson = (await statusListResponse.json()) as unknown + for (;;) { + // oxlint-disable-next-line eslint/no-await-in-loop -- sequential reads bound the body as it arrives + const { done, value } = await reader.read() + + if (done) { + break + } - if (!isStatusListCredential(statusListJson)) { - return undefined + received += value.byteLength + + if (received > limit) { + return undefined + } + + text += decoder.decode(value, { stream: true }) } + } finally { + await reader.cancel() + } - return statusListJson - } catch { - return undefined + return text + decoder.decode() +} + +async function fetchJson(url: string, timeoutMs: number): Promise { + let response: Response + + try { + response = await fetch(url, { + // The specification serves the status list credential as a verifiable + // credential media type. A server that enforces content negotiation + // answers 406 to a request that asks only for `application/json`. + headers: { + accept: "application/vc+ld+json, application/ld+json, application/json", + }, + // The scheme check applies to this URL only. Following a redirect would + // send the request to a host the check never saw, which is how a status + // list URL becomes a probe for internal addresses. The status list + // credential must be served at the URL the credential names anyway: its + // `id` has to match that URL to pass the binding check below. + redirect: "error", + signal: AbortSignal.timeout(timeoutMs), + }) + } catch (error) { + throw undetermined( + `Could not fetch status list credential from '${url}'`, + error, + ) + } + + if (!response.ok) { + throw undetermined( + `Fetching status list credential from '${url}' returned HTTP ${response.status}`, + ) + } + + const declaredLength = Number(response.headers.get("content-length")) + + if ( + Number.isFinite(declaredLength) && + declaredLength > MAX_STATUS_LIST_BYTES + ) { + throw undetermined( + `Status list credential from '${url}' declares ${declaredLength} bytes, over the ${MAX_STATUS_LIST_BYTES} byte limit`, + ) + } + + let body: string | undefined + + try { + body = await readBoundedText(response, MAX_STATUS_LIST_BYTES) + } catch (error) { + throw undetermined( + `Could not read status list credential from '${url}'`, + error, + ) + } + + if (body === undefined) { + throw undetermined( + `Status list credential from '${url}' is over the ${MAX_STATUS_LIST_BYTES} byte limit`, + ) + } + + try { + return JSON.parse(body) as unknown + } catch (error) { + throw undetermined( + `Status list credential from '${url}' is not valid JSON`, + error, + ) + } +} + +async function verifyStatusListProof( + proof: Verifiable["proof"], + resolver: Resolvable, + url: string, +): Promise> { + try { + return await verifyProof(proof, resolver) + } catch (error) { + throw undetermined( + `Status list credential from '${url}' has an invalid proof`, + error, + ) } } /** - * Check if a credential is revoked + * Reject a status list that was issued too long ago. * - * @param credential - The {@link W3CCredential} to check - * @returns `true` if the credential is revoked, `false` otherwise + * The issuer signs a new version of the list every time it revokes something. + * Every earlier version stays validly signed, so a party that kept one can + * serve it back and clear a revocation that happened after it. An expiry on the + * list closes this; an age bound closes it for issuers that publish no expiry. */ -export async function isRevoked(credential: W3CCredential): Promise { - if (!isRevocable(credential)) { - return false +function assertStatusListIsFresh( + credential: BitstringStatusListCredential, + url: string, + maxAgeMs: number | undefined, +): void { + const issuedAt = Date.parse(credential.issuanceDate) + + if (Number.isNaN(issuedAt)) { + throw undetermined( + `Status list credential from '${url}' has an unreadable issuanceDate`, + ) + } + + const ageMs = Date.now() - issuedAt + + // Reject a future `issuanceDate` whether or not a maximum age is set. A + // pre-dated list is not evidence of the current status, and a negative age + // would pass the limit below however low the caller sets it. Allow the same + // clock skew the proof verification allows, so an issuer whose clock leads + // slightly does not fail every check. + if (ageMs < -CLOCK_SKEW_MS) { + throw undetermined( + `Status list credential from '${url}' is dated ${-ageMs}ms in the future`, + ) + } + + if (maxAgeMs === undefined) { + return + } + + if (ageMs > maxAgeMs) { + throw undetermined( + `Status list credential from '${url}' is ${ageMs}ms old, over the ${maxAgeMs}ms limit`, + ) + } +} + +/** + * Fetch the status list credential a status entry points at, and establish that + * it is the list that entry refers to: signed, by a trusted issuer, bound to + * the same URL, and unexpired. + */ +async function resolveStatusListCredential( + statusListUrl: string, + { + resolver, + trustedIssuers, + timeoutMs, + maxAgeMs, + }: { + resolver: Resolvable + trustedIssuers: string[] + timeoutMs: number + maxAgeMs?: number + }, +): Promise { + const url = toHttpUrl(statusListUrl) + + if (!url) { + throw undetermined( + `Status list credential URL is not an absolute http(s) URL: '${statusListUrl}'`, + ) + } + + const body = await fetchJson(url, timeoutMs) + + if (!isStatusListCredential(body) || !isVerifiable(body)) { + throw undetermined( + `'${url}' did not return a signed BitstringStatusListCredential`, + ) } - const statusListVc = await fetchStatusList(credential) + // Only the proof is authoritative. The fetched body is attacker-controlled + // wherever the transport or the host is, so every check below reads the + // credential decoded from the verified proof, never the fetched object. + const verified = await verifyStatusListProof(body.proof, resolver, url) - // Cannot verify if status list is not found - if (!statusListVc) { + // `isStatusListCredential` only inspects the subject, so check the credential + // type too. Otherwise anything the trusted issuer signed with a subject that + // happens to carry these fields passes as a status list. + if ( + !isStatusListCredential(verified) || + !verified.type.includes("BitstringStatusListCredential") + ) { + throw undetermined( + `Status list credential from '${url}' is not a BitstringStatusListCredential`, + ) + } + + if (!trustedIssuers.includes(verified.issuer.id)) { + throw undetermined( + `Status list credential from '${url}' was issued by '${verified.issuer.id}', which is not a trusted status list issuer`, + ) + } + + // Bind the list to the URL the credential pointed at. Without this, any list + // the issuer has ever signed — an empty one, say — could be served in place + // of the one that actually carries this credential's bit. + if (verified.id === undefined || toHttpUrl(verified.id) !== url) { + throw undetermined( + `Status list credential from '${url}' declares a different id: '${verified.id ?? ""}'`, + ) + } + + // Check the expiry directly rather than through `isExpired`, which reads an + // unparseable date as "not expired". The expiry is the main bound on status + // list replay, so a malformed one must not quietly remove it. + if (verified.expirationDate !== undefined) { + const expiresAt = Date.parse(verified.expirationDate) + + if (Number.isNaN(expiresAt)) { + throw undetermined( + `Status list credential from '${url}' has an unreadable expirationDate`, + ) + } + + if (expiresAt <= Date.now()) { + throw undetermined(`Status list credential from '${url}' is expired`) + } + } + + assertStatusListIsFresh(verified, url, maxAgeMs) + + return verified +} + +/** + * Check if a credential has been revoked. + * + * Fails closed: any status that cannot be established — an unreachable, + * malformed, unsigned, wrongly-issued or too-short status list, or a + * `credentialStatus` this library does not implement — throws rather than + * returning `false`. "We could not check" is not "not revoked". + * + * @param credential - The {@link W3CCredential} to check. Callers must pass a + * credential decoded from a verified proof: a `credentialStatus` on an + * unverified object is attacker-controlled. + * @param options - The {@link RevocationCheckOptions} to use + * @returns `true` if the credential is revoked, `false` if it is verifiably not + * @throws {RevocationCheckError} if the revocation status cannot be determined + * @throws {UnsupportedCredentialStatusError} if the status cannot be evaluated + */ +export async function isRevoked( + credential: W3CCredential, + options: RevocationCheckOptions, +): Promise { + const status = credential.credentialStatus + + if (status === undefined || status === null) { return false } - const statusList = BitBuffer.fromBitstring( - statusListVc.credentialSubject.encodedList, - ) + if (!isRevocable(credential)) { + throw new UnsupportedCredentialStatusError(undefined, { + detail: `Cannot evaluate credentialStatus of type '${status.type}'`, + }) + } - const index = parseInt(credential.credentialStatus.statusListIndex, 10) + const { statusPurpose, statusListIndex, statusListCredential } = + credential.credentialStatus + + if (statusPurpose !== "revocation") { + throw new UnsupportedCredentialStatusError(undefined, { + detail: `Cannot evaluate credentialStatus with statusPurpose '${statusPurpose}'`, + }) + } + + const index = parseStatusListIndex(statusListIndex) + + const statusList = await resolveStatusListCredential(statusListCredential, { + resolver: options.resolver, + trustedIssuers: options.trustedStatusListIssuers ?? [credential.issuer.id], + timeoutMs: options.statusListTimeoutMs ?? DEFAULT_STATUS_LIST_TIMEOUT_MS, + maxAgeMs: options.maxStatusListAgeMs, + }) + + if (statusList.credentialSubject.statusPurpose !== statusPurpose) { + throw undetermined( + `Status list at '${statusListCredential}' tracks '${statusList.credentialSubject.statusPurpose}', not '${statusPurpose}'`, + ) + } + + // A list that packs several bits per entry puts them at + // `statusListIndex * statusSize`. Reading bit `index` on such a list reads + // some other credential's status, so reject what this code cannot decode. + const { statusSize } = statusList.credentialSubject + + if (statusSize !== undefined && statusSize !== 1) { + throw undetermined( + `Status list at '${statusListCredential}' uses statusSize ${statusSize}, which this version cannot read`, + ) + } + + const { encodedList } = statusList.credentialSubject + const maxEncodedListBytes = + options.maxEncodedListBytes ?? DEFAULT_MAX_ENCODED_LIST_BYTES + + // `encodedList` inflates to a much larger buffer, so bound it before decoding. + if (encodedList.length > maxEncodedListBytes) { + throw undetermined( + `Status list at '${statusListCredential}' has an encodedList over the ${maxEncodedListBytes} byte limit`, + ) + } + + let bits: BitBuffer + + try { + bits = BitBuffer.fromBitstring(encodedList) + } catch (error) { + throw undetermined( + `Status list at '${statusListCredential}' has an unreadable encodedList`, + error, + ) + } + + // An index past the end of the list is not evidence of anything: the list + // simply does not cover this credential. + if (index >= bits.length) { + throw undetermined( + `statusListIndex ${index} is outside the status list at '${statusListCredential}', which holds ${bits.length} entries`, + ) + } - return statusList.test(index) + return bits.test(index) } diff --git a/packages/vc/src/verification/is-verifiable.ts b/packages/vc/src/verification/is-verifiable.ts new file mode 100644 index 0000000..2ee8946 --- /dev/null +++ b/packages/vc/src/verification/is-verifiable.ts @@ -0,0 +1,22 @@ +import type { Verifiable, W3CCredential } from "../types" + +/** + * Check if a credential carries a proof object that can be verified. + * + * This is a shape check only — it says nothing about the proof being valid. + * Use {@link verifyProof} to establish that, and read the credential it returns + * rather than the object passed in here. + * + * @param credential - The {@link W3CCredential} to check + * @returns `true` if the credential carries a typed proof, `false` otherwise + */ +export function isVerifiable( + credential: W3CCredential, +): credential is Verifiable { + return ( + "proof" in credential && + credential.proof !== null && + typeof credential.proof === "object" && + "type" in credential.proof + ) +} diff --git a/packages/vc/src/verification/verify-parsed-credential.test.ts b/packages/vc/src/verification/verify-parsed-credential.test.ts index b1c8cd7..c4d42c2 100644 --- a/packages/vc/src/verification/verify-parsed-credential.test.ts +++ b/packages/vc/src/verification/verify-parsed-credential.test.ts @@ -12,6 +12,7 @@ import { CredentialExpiredError, CredentialRevokedError, InvalidProofError, + RevocationCheckError, UnsupportedCredentialTypeError, UntrustedIssuerError, } from "./errors" @@ -127,6 +128,40 @@ describe("verifyParsedCredential", () => { ).rejects.toThrow(CredentialRevokedError) }) + it("throws when the revocation status cannot be determined", async () => { + const { vc, issuerDid, resolver } = await setup() + + // `isRevoked` fails closed, so an unreachable or untrustworthy status list + // must fail verification rather than pass it. + vi.mocked(isRevoked).mockRejectedValue(new RevocationCheckError()) + + await expect( + verifyParsedCredential(vc, { + trustedIssuers: [issuerDid], + resolver, + }), + ).rejects.toThrow(RevocationCheckError) + }) + + it("passes revocation options and the resolver to the revocation check", async () => { + const { vc, issuerDid, resolver } = await setup() + + await verifyParsedCredential(vc, { + trustedIssuers: [issuerDid], + resolver, + revocation: { + trustedStatusListIssuers: ["did:example:status-list-issuer"], + statusListTimeoutMs: 1234, + }, + }) + + expect(isRevoked).toHaveBeenCalledWith(vc, { + resolver, + trustedStatusListIssuers: ["did:example:status-list-issuer"], + statusListTimeoutMs: 1234, + }) + }) + it("throws for non-trusted issuer", async () => { const { vc, resolver } = await setup() @@ -138,6 +173,22 @@ describe("verifyParsedCredential", () => { ).rejects.toThrow(UntrustedIssuerError) }) + it("rejects a non-trusted issuer before it checks revocation", async () => { + const { vc, resolver } = await setup() + + // The revocation check dereferences a URL taken from the credential. An + // untrusted issuer must never reach it, or any caller can choose a URL for + // this process to request. + await expect( + verifyParsedCredential(vc, { + trustedIssuers: ["did:example:123"], + resolver, + }), + ).rejects.toThrow(UntrustedIssuerError) + + expect(isRevoked).not.toHaveBeenCalled() + }) + it("throws for an invalid proof", async () => { const { vc, issuerDid, resolver } = await setup() diff --git a/packages/vc/src/verification/verify-parsed-credential.ts b/packages/vc/src/verification/verify-parsed-credential.ts index dcc0780..4575710 100644 --- a/packages/vc/src/verification/verify-parsed-credential.ts +++ b/packages/vc/src/verification/verify-parsed-credential.ts @@ -9,7 +9,8 @@ import { UntrustedIssuerError, } from "./errors" import { isExpired } from "./is-expired" -import { isRevoked } from "./is-revoked" +import { isRevoked, type RevocationCheckOptions } from "./is-revoked" +import { isVerifiable } from "./is-verifiable" import type { ClaimVerifier } from "./types" import { verifyProof } from "./verify-proof" @@ -26,17 +27,11 @@ type VerifyCredentialOptions = { * The list of claim verifiers to use */ verifiers?: ClaimVerifier[] -} - -function isVerifiable( - credential: W3CCredential, -): credential is Verifiable { - return ( - "proof" in credential && - credential.proof !== null && - typeof credential.proof === "object" && - "type" in credential.proof - ) + /** + * Options for the revocation check. The resolver is shared with the + * credential's own verification. + */ + revocation?: Omit } /** @@ -74,12 +69,12 @@ export async function verifyParsedCredential( throw new CredentialExpiredError() } - if (await isRevoked(verifiedCredential)) { - throw new CredentialRevokedError() - } - // If trustedIssuers is defined, we require the issuer is in the array (even // if the array is empty). If it is not defined, we skip the check. + // + // This runs before the revocation check on purpose. The revocation check + // dereferences a URL taken from the credential, so checking the issuer first + // means an untrusted issuer never makes this process send a request. if ( Array.isArray(options.trustedIssuers) && !options.trustedIssuers.includes(verifiedCredential.issuer.id) @@ -89,6 +84,17 @@ export async function verifyParsedCredential( ) } + // `isRevoked` throws when the status cannot be established, so an unreachable + // or untrustworthy status list fails verification instead of quietly passing. + if ( + await isRevoked(verifiedCredential, { + ...options.revocation, + resolver: options.resolver, + }) + ) { + throw new CredentialRevokedError() + } + // If verifiers are provided, we verify the credential against them. if (options.verifiers?.length) { const verifiers = options.verifiers.filter((v) => diff --git a/packages/vc/src/verification/verify-revoked-credential.test.ts b/packages/vc/src/verification/verify-revoked-credential.test.ts new file mode 100644 index 0000000..78d7b88 --- /dev/null +++ b/packages/vc/src/verification/verify-revoked-credential.test.ts @@ -0,0 +1,117 @@ +import { + createDidDocumentFromKeypair, + createDidWebUri, + getDidResolver, +} from "@agentcommercekit/did" +import { createJwtSigner } from "@agentcommercekit/jwt" +import { generateKeypair } from "@agentcommercekit/keys" +import { BitBuffer } from "bit-buffers" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" + +import { createCredential } from "../create-credential" +import { makeRevocable } from "../revocation/make-revocable" +import { createStatusListCredential } from "../revocation/status-list-credential" +import { signCredential } from "../signing/sign-credential" +import { CredentialRevokedError, RevocationCheckError } from "./errors" +import { parseJwtCredential } from "./parse-jwt-credential" +import { verifyParsedCredential } from "./verify-parsed-credential" + +const statusListUrl = "https://issuer.example.com/status/1" + +describe("revocation, end to end", () => { + const mockFetch = vi.fn() + beforeEach(() => vi.stubGlobal("fetch", mockFetch)) + afterEach(() => { + vi.unstubAllGlobals() + mockFetch.mockReset() + }) + + async function setup(revoked: boolean) { + const resolver = getDidResolver() + const keypair = await generateKeypair("secp256k1") + const did = createDidWebUri("https://issuer.example.com") + resolver.addToCache(did, createDidDocumentFromKeypair({ did, keypair })) + const signer = { + did, + signer: createJwtSigner(keypair), + alg: "ES256K" as const, + } + + const bits = new BitBuffer(1024) + const list = createStatusListCredential({ + url: statusListUrl, + encodedList: revoked ? bits.set(3).toBitstring() : bits.toBitstring(), + issuer: did, + }) + const servedList = { + ...(await parseJwtCredential( + await signCredential(list, signer), + resolver, + )), + credentialSubject: list.credentialSubject, + } + + const vc = makeRevocable( + createCredential({ + id: "https://issuer.example.com/credentials/3", + type: "TestCredential", + issuer: did, + subject: "did:web:subject.example.com", + attestation: { test: "test" }, + }), + { id: `${statusListUrl}#3`, statusListIndex: 3, statusListUrl }, + ) + const parsed = await parseJwtCredential( + await signCredential(vc, signer), + resolver, + ) + + return { resolver, parsed, servedList, did } + } + + it("rejects a genuinely revoked credential", async () => { + const { resolver, parsed, servedList, did } = await setup(true) + mockFetch.mockResolvedValueOnce(Response.json(servedList)) + + await expect( + verifyParsedCredential(parsed, { resolver, trustedIssuers: [did] }), + ).rejects.toThrow(CredentialRevokedError) + }) + + it("accepts an unrevoked credential", async () => { + const { resolver, parsed, servedList, did } = await setup(false) + mockFetch.mockResolvedValueOnce(Response.json(servedList)) + + await expect( + verifyParsedCredential(parsed, { resolver, trustedIssuers: [did] }), + ).resolves.toBeDefined() + }) + + it("rejects a revoked credential when the status list is unreachable", async () => { + const { resolver, parsed, did } = await setup(true) + mockFetch.mockRejectedValueOnce(new Error("ECONNREFUSED")) + + await expect( + verifyParsedCredential(parsed, { resolver, trustedIssuers: [did] }), + ).rejects.toThrow(RevocationCheckError) + }) + + it("rejects a revoked credential when an unverifiable empty list is served", async () => { + const { resolver, parsed, did } = await setup(true) + const forged = createStatusListCredential({ + url: statusListUrl, + encodedList: new BitBuffer(1024).toBitstring(), + issuer: did, + }) + mockFetch.mockResolvedValueOnce( + Response.json({ + ...forged, + proof: { type: "JwtProof2020", jwt: "forged.jwt.token" }, + }), + ) + + await expect( + verifyParsedCredential(parsed, { resolver, trustedIssuers: [did] }), + ).rejects.toThrow(RevocationCheckError) + }) +}) diff --git a/tools/api-utils/src/middleware/error-handler.ts b/tools/api-utils/src/middleware/error-handler.ts index a8c5eb9..80a9ec3 100644 --- a/tools/api-utils/src/middleware/error-handler.ts +++ b/tools/api-utils/src/middleware/error-handler.ts @@ -1,6 +1,10 @@ import { InvalidPaymentRequestTokenError } from "@agentcommercekit/ack-pay" import { DidResolutionError } from "@agentcommercekit/did" -import { CredentialVerificationError } from "@agentcommercekit/vc" +import { + CredentialVerificationError, + RevocationCheckError, + UnsupportedCredentialStatusError, +} from "@agentcommercekit/vc" import type { Env, ErrorHandler } from "hono" import { HTTPException } from "hono/http-exception" import * as v from "valibot" @@ -13,6 +17,17 @@ export const errorHandler: ErrorHandler = (err, c) => { err instanceof CredentialVerificationError || err instanceof InvalidPaymentRequestTokenError ) { + // These carry one fixed message so the response says nothing about the host + // that was dereferenced or the status the credential declared. Log the + // detail here, or the reason a verification failed is recorded nowhere. + if ( + (err instanceof RevocationCheckError || + err instanceof UnsupportedCredentialStatusError) && + process.env.NODE_ENV !== "test" + ) { + console.error(err.detail ?? err.message, err.cause) + } + return c.json(formatErrorResponse(err), 400) } From 3cb4ee4aa1a545767bad8f11922fc42eb1ef8480 Mon Sep 17 00:00:00 2001 From: Matt Venables Date: Mon, 3 Aug 2026 20:20:59 -0400 Subject: [PATCH 3/3] fix(examples/issuer): serve the status list credential unwrapped The status endpoint returned the credential inside this API's `{ ok, data }` envelope. A verifier dereferences that URL expecting the credential itself, so the body never parsed as one and every revocation check against this issuer failed open. Revocation did not work at all. The endpoint now returns the signed credential directly, as the W3C Bitstring Status List specification requires. It also parses `listId` before the value reaches either the query or the credential `id`, so `/status/01` cannot sign caller-supplied text into the id. Co-Authored-By: Claude Opus 5 (1M context) --- examples/issuer/README.md | 20 ++- examples/issuer/src/routes/status.test.ts | 200 ++++++++++++++++++++++ examples/issuer/src/routes/status.ts | 42 +++-- 3 files changed, 249 insertions(+), 13 deletions(-) create mode 100644 examples/issuer/src/routes/status.test.ts diff --git a/examples/issuer/README.md b/examples/issuer/README.md index c3f27d5..40fd5ea 100644 --- a/examples/issuer/README.md +++ b/examples/issuer/README.md @@ -269,14 +269,28 @@ curl --request DELETE \ #### GET /status/:listId -Retrieve a Bitstring Status List credential for checking revocation status +Retrieve a Bitstring Status List credential for checking revocation status. + +Unlike the other endpoints, this one returns the signed credential directly +rather than in the `{ ok, data }` envelope. Verifiers dereference this URL as +the credential's `statusListCredential` and expect the credential itself; a +wrapped body cannot be verified, so revocation checks would fail. **Response Body** ```json { - "ok": true, - "data": "jwt-string" + "@context": ["https://www.w3.org/2018/credentials/v1"], + "id": "http://localhost:3456/status/1", + "type": ["VerifiableCredential", "BitstringStatusListCredential"], + "issuer": { "id": "did:web:..." }, + "credentialSubject": { + "id": "http://localhost:3456/status/1#list", + "type": "BitstringStatusList", + "statusPurpose": "revocation", + "encodedList": "..." + }, + "proof": { "type": "JwtProof2020", "jwt": "jwt-string" } } ``` diff --git a/examples/issuer/src/routes/status.test.ts b/examples/issuer/src/routes/status.test.ts new file mode 100644 index 0000000..b2558c3 --- /dev/null +++ b/examples/issuer/src/routes/status.test.ts @@ -0,0 +1,200 @@ +import { + bytesToHexString, + DidResolver, + getDidResolver, + isRevoked, + type Revocable, + type W3CCredential, +} from "agentcommercekit" +import { + afterEach, + beforeAll, + beforeEach, + describe, + expect, + it, + vi, +} from "vitest" + +import type { DatabaseClient } from "@/db/get-db" +import { getStatusList } from "@/db/queries/status-lists" +import type { DatabaseStatusList } from "@/db/schema" +import { STATUS_LIST_MAX_SIZE } from "@/db/schema" +import { + createDidWebWithSigner, + type DidWithSigner, +} from "@/test-helpers/did-web-with-signer" + +import app from ".." + +vi.mock("agentcommercekit", async () => { + const actual = await vi.importActual("agentcommercekit") + return { + ...actual, + getDidResolver: vi.fn<() => DidResolver>(), + } +}) + +vi.mock("@/db/queries/status-lists", async () => { + const actual = await vi.importActual("@/db/queries/status-lists") + return { + ...actual, + getStatusList: + vi.fn< + ( + db: DatabaseClient, + listId: number, + ) => Promise + >(), + } +}) + +const baseUrl = "https://issuer.example.com" +const statusListUrl = `${baseUrl}/status/1` +const revokedIndex = 7 + +function statusListData(revoked: number[]) { + return Array.from({ length: STATUS_LIST_MAX_SIZE }, (_, index) => + revoked.includes(index) ? "1" : "0", + ).join("") +} + +function mockStatusList(revoked: number[]) { + vi.mocked(getStatusList).mockResolvedValue({ + id: 1, + credentialType: "ControllerCredential", + data: statusListData(revoked), + createdAt: new Date(), + updatedAt: new Date(), + }) +} + +function revocableCredential(issuerDid: string): Revocable { + return { + "@context": ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + issuer: { id: issuerDid }, + issuanceDate: "2024-01-01T00:00:00.000Z", + credentialSubject: { id: "did:web:subject.example.com" }, + credentialStatus: { + id: `${statusListUrl}#${revokedIndex}`, + type: "BitstringStatusListEntry", + statusPurpose: "revocation", + statusListIndex: String(revokedIndex), + statusListCredential: statusListUrl, + }, + } +} + +describe("GET /status/:listId", () => { + let issuer: DidWithSigner + + beforeAll(async () => { + issuer = await createDidWebWithSigner(baseUrl) + + process.env.ISSUER_PRIVATE_KEY = bytesToHexString(issuer.keypair.privateKey) + process.env.BASE_URL = baseUrl + }) + + beforeEach(() => { + const resolver = new DidResolver() + resolver.addToCache(issuer.did, issuer.didDocument) + vi.mocked(getDidResolver).mockReturnValue(resolver) + }) + + // Unstub here, not at the end of a test body: a failed assertion would + // otherwise leave the `fetch` stub in place for the rest of the file. + afterEach(() => { + vi.unstubAllGlobals() + vi.clearAllMocks() + }) + + it("returns the status list credential unwrapped, so verifiers can read it", async () => { + mockStatusList([]) + + const res = await app.request("/status/1") + const body: unknown = await res.json() + + expect(res.status).toBe(200) + // A verifier dereferences this URL expecting the credential itself. An + // `{ ok, data }` envelope silently defeats every revocation check. + expect(body).toMatchObject({ + id: statusListUrl, + proof: { type: "JwtProof2020" }, + credentialSubject: { statusPurpose: "revocation" }, + }) + expect(body).not.toHaveProperty("data") + }) + + it("serves a status list a verifier reads a revoked credential from", async () => { + mockStatusList([revokedIndex]) + + const res = await app.request("/status/1") + const statusListCredential = await res.json() + + const resolver = new DidResolver() + resolver.addToCache(issuer.did, issuer.didDocument) + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(Response.json(statusListCredential))), + ) + + await expect( + isRevoked(revocableCredential(issuer.did), { resolver }), + ).resolves.toBe(true) + }) + + it("serves a status list a verifier reads an unrevoked credential from", async () => { + mockStatusList([]) + + const res = await app.request("/status/1") + const statusListCredential = await res.json() + + const resolver = new DidResolver() + resolver.addToCache(issuer.did, issuer.didDocument) + vi.stubGlobal( + "fetch", + vi.fn(() => Promise.resolve(Response.json(statusListCredential))), + ) + + await expect( + isRevoked(revocableCredential(issuer.did), { resolver }), + ).resolves.toBe(false) + }) + + it("returns a 404 for an unknown status list", async () => { + vi.mocked(getStatusList).mockResolvedValue(undefined) + + const res = await app.request("/status/999") + + expect(res.status).toBe(404) + }) + + it("serves status list 0, which holds the first issued credentials", async () => { + mockStatusList([]) + + const res = await app.request("/status/0") + + expect(res.status).toBe(200) + expect(getStatusList).toHaveBeenCalledWith(expect.anything(), 0) + }) + + it("returns a 404 for a non-numeric status list id", async () => { + mockStatusList([]) + + const res = await app.request("/status/1abc") + + expect(res.status).toBe(404) + expect(getStatusList).not.toHaveBeenCalled() + }) + + it("signs the parsed id into the credential, not the raw path text", async () => { + mockStatusList([]) + + const res = await app.request("/status/0001") + const body: unknown = await res.json() + + expect(res.status).toBe(200) + expect(body).toMatchObject({ id: statusListUrl }) + }) +}) diff --git a/examples/issuer/src/routes/status.ts b/examples/issuer/src/routes/status.ts index 5b7fde3..5f7a94e 100644 --- a/examples/issuer/src/routes/status.ts +++ b/examples/issuer/src/routes/status.ts @@ -1,7 +1,3 @@ -import { - apiSuccessResponse, - type ApiResponse, -} from "@repo/api-utils/api-response" import { notFound } from "@repo/api-utils/exceptions" import { createStatusListCredential, @@ -11,7 +7,7 @@ import { type Verifiable, } from "agentcommercekit" import { bitstringStatusListClaimSchema } from "agentcommercekit/schemas/valibot" -import { Hono, type Env } from "hono" +import { Hono, type Env, type TypedResponse } from "hono" import { env } from "hono/adapter" import * as v from "valibot" @@ -36,19 +32,45 @@ app.use("*", didResolver()) * - listId: string - ID of the status list to retrieve * * @returns Signed BitstringStatusListCredential with compressed bit string + * + * The credential is returned bare, not wrapped in this API's `{ ok, data }` + * envelope: this URL is the `statusListCredential` a verifier dereferences, and + * the W3C Bitstring Status List spec expects the credential itself at that URL. + * A wrapped body is not a credential, so verifiers cannot check revocation. */ app.get( "/:listId", async ( c, - ): Promise>> => { - const listId = c.req.param("listId") + ): Promise>> => { const db = c.get("db") const issuer = c.get("issuer") const resolver = c.get("resolver") const { BASE_URL } = env(c) - const statusList = await getStatusList(db, parseInt(listId)) + // Parse the id before it reaches either the query or the credential id, so + // `/status/01` and `/status/1abc` cannot sign caller-supplied text into the + // credential id while selecting the same row. + const listId = v.safeParse( + v.pipe( + v.string(), + v.regex(/^\d+$/), + v.transform(Number), + // A long digit string parses to an unsafe integer or `Infinity`, which + // would reach the query. + v.safeInteger(), + // Status list ids are zero-based: `getStatusListPosition` puts the + // first 8192 credentials on list 0. + v.minValue(0), + ), + c.req.param("listId"), + ) + + if (!listId.success) { + return notFound("Status list not found") + } + + const statusList = await getStatusList(db, listId.output) if (!statusList) { return notFound("Status list not found") @@ -57,7 +79,7 @@ app.get( const encodedList = compressBitString(statusList.data) const credential = createStatusListCredential({ - url: `${BASE_URL}/status/${listId}`, + url: `${BASE_URL}/status/${listId.output}`, encodedList, issuer: issuer.did, }) @@ -75,7 +97,7 @@ app.get( credentialSubject, } - return c.json(apiSuccessResponse(verifiableCredential)) + return c.json(verifiableCredential) }, )