From 15d87fc648bc8e14ef32ed6c30a4c41eb7f4dbff Mon Sep 17 00:00:00 2001 From: ecamli <107792368+erkancamli@users.noreply.github.com> Date: Mon, 28 Sep 2026 09:07:09 +0000 Subject: [PATCH 1/2] fix(vc): read Multibase base64url Bitstring Status List encodedLists Bitstring Status List v1.0 defines encodedList as the Multibase base64url (u prefix, no padding) form of the GZIP-compressed bitstring. isRevoked handed it straight to BitBuffer.fromBitstring, which expects plain padded base64, so a list from a conformant issuer, including the example in the specification, failed as an unreadable encodedList and the status check could not complete. A u-prefixed list is now checked against the base64url alphabet and rewritten into the base64 bit-buffers reads; its inflate already accepts GZIP. Lists without the prefix are read as before, and plain base64 of a GZIP or zlib stream never starts with u, so the two forms cannot be confused. --- .../bitstring-multibase-encoded-list.md | 11 +++++ .../vc/src/verification/is-revoked.test.ts | 49 +++++++++++++++++++ packages/vc/src/verification/is-revoked.ts | 31 +++++++++++- 3 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 .changeset/bitstring-multibase-encoded-list.md 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..2d097548 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,53 @@ 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 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..5f837f15 100644 --- a/packages/vc/src/verification/is-revoked.ts +++ b/packages/vc/src/verification/is-revoked.ts @@ -46,6 +46,35 @@ 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) + if (!/^[A-Za-z0-9_-]+$/.test(base64url)) { + 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 +543,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`, From 32ddfa5e5e3b1535fc01460c5954cb031882e995 Mon Sep 17 00:00:00 2001 From: ecamli <107792368+erkancamli@users.noreply.github.com> Date: Mon, 28 Sep 2026 18:59:31 +0000 Subject: [PATCH 2/2] fix(vc): reject multibase encodedLists of impossible length A base64url string whose length is 1 modulo 4 cannot encode any byte string, but base64-js drops the trailing character and decodes the rest, so a malformed list could be read as the list before it. Treat such a list as unreadable. --- .../vc/src/verification/is-revoked.test.ts | 19 +++++++++++++++++++ packages/vc/src/verification/is-revoked.ts | 4 +++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/vc/src/verification/is-revoked.test.ts b/packages/vc/src/verification/is-revoked.test.ts index 2d097548..9d073eb2 100644 --- a/packages/vc/src/verification/is-revoked.test.ts +++ b/packages/vc/src/verification/is-revoked.test.ts @@ -486,6 +486,25 @@ describe("isRevoked", () => { 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 5f837f15..da49a968 100644 --- a/packages/vc/src/verification/is-revoked.ts +++ b/packages/vc/src/verification/is-revoked.ts @@ -65,7 +65,9 @@ function decodeEncodedList(encodedList: string): BitBuffer { } const base64url = encodedList.slice(1) - if (!/^[A-Za-z0-9_-]+$/.test(base64url)) { + // 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") }