diff --git a/.changeset/bitstring-multibase-encoded-list.md b/.changeset/bitstring-multibase-encoded-list.md new file mode 100644 index 00000000..107c1b76 --- /dev/null +++ b/.changeset/bitstring-multibase-encoded-list.md @@ -0,0 +1,11 @@ +--- +"@agentcommercekit/vc": patch +--- + +`isRevoked` now reads a Bitstring Status List `encodedList` in the form the +specification defines: the Multibase base64url (`u` prefix, no padding) form of +the GZIP-compressed bitstring. It passed that value straight to +`BitBuffer.fromBitstring`, which expects plain padded base64, so every list +from a conformant issuer, including the example in the specification, failed +as an unreadable `encodedList` and the credential's status could not be +determined. Lists without the `u` prefix are read exactly as before. diff --git a/packages/vc/src/verification/is-revoked.test.ts b/packages/vc/src/verification/is-revoked.test.ts index 6c49aa73..9d073eb2 100644 --- a/packages/vc/src/verification/is-revoked.test.ts +++ b/packages/vc/src/verification/is-revoked.test.ts @@ -1,3 +1,5 @@ +import { gzipSync } from "node:zlib" + import { createDidDocumentFromKeypair, createDidWebUri, @@ -437,6 +439,72 @@ describe("isRevoked", () => { expect(mockFetch).not.toHaveBeenCalled() }) + // Bitstring Status List v1.0, Section 2.2: `encodedList` is the Multibase + // base64url (no padding) form of the GZIP-compressed bitstring, and the + // bitstring is at least 16KB with index 0 at the left-most bit. + it("reads an encodedList in the form the specification defines", async () => { + const bitstring = new Uint8Array(16 * 1024) + bitstring[0] = 0b0000_0100 // index 5 + + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ + encodedList: `u${gzipSync(bitstring).toString("base64url")}`, + }), + ), + ) + + await expect( + isRevoked(buildCredential(statusEntry()), { resolver }), + ).resolves.toBe(true) + }) + + it("reads the example encodedList from the specification", async () => { + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ + encodedList: + "uH4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAA", + }), + ), + ) + + await expect( + isRevoked(buildCredential(statusEntry()), { resolver }), + ).resolves.toBe(false) + }) + + it("throws when a multibase encodedList is not base64url", async () => { + mockFetch.mockResolvedValueOnce( + Response.json(await signedStatusList({ encodedList: "uH4sI+AAA/" })), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/unreadable encodedList/) + }) + + it("throws when a multibase encodedList has an impossible length", async () => { + // The specification example plus one character: 69 base64url characters + // cannot encode any byte string. + mockFetch.mockResolvedValueOnce( + Response.json( + await signedStatusList({ + encodedList: + "uH4sIAAAAAAAAA-3BMQEAAADCoPVPbQwfoAAAAAAAAAAAAAAAAAAAAIC3AYbSVKsAQAAAA", + }), + ), + ) + + const error = await captureRevocationError( + isRevoked(buildCredential(statusEntry()), { resolver }), + ) + + expect(error.detail).toMatch(/unreadable encodedList/) + }) + it("throws when the encoded list cannot be decoded", async () => { mockFetch.mockResolvedValueOnce( Response.json(await signedStatusList({ encodedList: "not-a-bitstring" })), diff --git a/packages/vc/src/verification/is-revoked.ts b/packages/vc/src/verification/is-revoked.ts index 48255533..da49a968 100644 --- a/packages/vc/src/verification/is-revoked.ts +++ b/packages/vc/src/verification/is-revoked.ts @@ -46,6 +46,37 @@ const MAX_STATUS_LIST_BYTES = 5_000_000 */ const DEFAULT_MAX_ENCODED_LIST_BYTES = 64 * 1024 +/** + * Decode an `encodedList` into its bitstring. + * + * Bitstring Status List v1.0 encodes the list as the Multibase base64url form + * (`u` prefix, no padding) of the GZIP-compressed bitstring. `bit-buffers` + * reads plain, padded base64 and inflates both GZIP and zlib streams, so a + * Multibase list is rewritten into that alphabet first. A list without the + * `u` prefix is read as before: plain base64 of a compressed stream never + * starts with `u`, since that would need a first byte of 0xB8 to 0xBB, which + * is neither a GZIP nor a valid zlib header. + * + * @see {@link https://www.w3.org/TR/vc-bitstring-status-list/#bitstring-expansion-algorithm} + */ +function decodeEncodedList(encodedList: string): BitBuffer { + if (!encodedList.startsWith("u")) { + return BitBuffer.fromBitstring(encodedList) + } + + const base64url = encodedList.slice(1) + // A length of 1 modulo 4 cannot come from any byte string; base64-js would + // otherwise drop the trailing character and read the rest. + if (!/^[A-Za-z0-9_-]+$/.test(base64url) || base64url.length % 4 === 1) { + throw new Error("Multibase encodedList is not base64url without padding") + } + + const base64 = base64url.replaceAll("-", "+").replaceAll("_", "/") + return BitBuffer.fromBitstring( + base64.padEnd(Math.ceil(base64.length / 4) * 4, "="), + ) +} + export type RevocationCheckOptions = { /** * The resolver used to verify the status list credential's proof. @@ -514,7 +545,7 @@ export async function isRevoked( let bits: BitBuffer try { - bits = BitBuffer.fromBitstring(encodedList) + bits = decodeEncodedList(encodedList) } catch (error) { throw undetermined( `Status list at '${statusListCredential}' has an unreadable encodedList`,