Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bitstring-multibase-encoded-list.md
Original file line number Diff line number Diff line change
@@ -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.
68 changes: 68 additions & 0 deletions packages/vc/src/verification/is-revoked.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { gzipSync } from "node:zlib"

import {
createDidDocumentFromKeypair,
createDidWebUri,
Expand Down Expand Up @@ -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" })),
Expand Down
33 changes: 32 additions & 1 deletion packages/vc/src/verification/is-revoked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, "="),
)
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
export type RevocationCheckOptions = {
/**
* The resolver used to verify the status list credential's proof.
Expand Down Expand Up @@ -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`,
Expand Down