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/.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/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) }, ) 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/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/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/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-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-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(), 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) }