From f1588cdbd7ce93d77025393c724d2694ea4d1024 Mon Sep 17 00:00:00 2001 From: Jeremy Wang Date: Tue, 23 Jun 2026 13:52:15 -0700 Subject: [PATCH] feat: support StatusList2021Entry credential status Add StatusList2021Entry to the supported status types and a dedicated handler. The status endpoint returns a non-standard envelope {"statusList":""} whose inner JWT is signed by the key published at the JWT `jku` (the issuer's JWKS endpoint), not the key embedded in its `iss` did:key, so it is verified via jku+kid rather than the DID resolver - bounded by: status list `iss` must equal the credential issuer, and `jku` origin must equal the statusListCredential origin. `encodedList` is decoded as base64url(gzip(bitstring)), MSB-first. --- common/utils.js | 127 ++++++++++++++++++++++++++-- test/unit/17-statuslist2021.test.js | 124 +++++++++++++++++++++++++++ 2 files changed, 243 insertions(+), 8 deletions(-) create mode 100644 test/unit/17-statuslist2021.test.js diff --git a/common/utils.js b/common/utils.js index a081bfbb..5743a62d 100644 --- a/common/utils.js +++ b/common/utils.js @@ -20,10 +20,14 @@ import { checkStatus as checkStatusBitstring } from '@digitalbazaar/vc-bitstring-status-list'; import {ConfidentialClientApplication} from '@azure/msal-node'; -import {decodeJwt} from 'jose'; +import {agent} from '@bedrock/https-agent'; +import { + decodeJwt, decodeProtectedHeader, importJWK, jwtVerify +} from 'jose'; import {didResolver} from './documentLoader.js'; import {expandTypes} from '../lib/workflows/common/type-expansion.js'; import {generateId} from 'bnid'; +import {gunzipSync} from 'node:zlib'; import {httpClient} from '@digitalbazaar/http-client'; import {JSONPath} from 'jsonpath-plus'; import {logger} from '../lib/logger.js'; @@ -170,10 +174,114 @@ export const unenvelopeJwtVp = vpToken => { // Verify Utilities +const STATUS_LIST_2021_ENTRY_TYPE = 'StatusList2021Entry'; const SUPPORTED_STATUS_ENTRY_TYPES = [ - 'BitstringStatusListEntry' + 'BitstringStatusListEntry', + STATUS_LIST_2021_ENTRY_TYPE ]; +// MSB-first bit lookup within a decoded StatusList2021 bitstring (per spec). +const _getStatusBit = (bytes, index) => + (bytes[index >> 3] >> (7 - (index % 8))) & 1; + +// Verifies the signature of a TWDIW status list JWT. The issuer signs status +// lists with a key published at the JWT `jku` (its JWKS endpoint), NOT the key +// embedded in its `iss` did:key, so we cannot verify via the did resolver. +// Trust is bounded by: (1) the status list must be issued by the same issuer +// as the credential, and (2) the `jku` must be same-origin as the VC-attested +// statusListCredential URL (signing key fetched from the issuer's own infra +// over TLS). +const _verifyStatusListSignature = async ({listJwt, url, issuerId}) => { + const header = decodeProtectedHeader(listJwt); + const payload = decodeJwt(listJwt); + if(issuerId && payload.iss !== issuerId) { + throw new Error('status list issuer does not match credential issuer'); + } + if(!header.jku || new URL(header.jku).origin !== new URL(url).origin) { + throw new Error('status list signing keys (jku) are not same-origin'); + } + const {data: jwks} = await httpClient.get(header.jku, {agent}); + const keys = Array.isArray(jwks?.keys) ? jwks.keys : + (Array.isArray(jwks) ? jwks : []); + const jwk = keys.find(k => k.kid === header.kid) ?? keys[0]; + if(!jwk) { + throw new Error('no matching status list signing key at jku'); + } + await jwtVerify(listJwt, await importJWK({...jwk, alg: 'ES256'}, 'ES256')); + return payload; +}; + +/** + * Verifies a `StatusList2021Entry` credentialStatus (used by TWDIW VCs). + * + * The status list endpoint returns a non-standard envelope + * `{"statusList": ""}` whose inner JWT is a `StatusList2021Credential`. + * `encodedList` is base64url(gzip(bitstring)); a set bit at `statusListIndex` + * (MSB-first) means the credential is revoked/suspended. + * + * @param {object} options - Options. + * @param {object} options.credential - The credential being checked. + * @returns {Promise} A `{verified, errors}` status result. + */ +const checkStatusList2021 = async ({credential}) => { + const issuerId = typeof credential?.issuer === 'string' ? + credential.issuer : credential?.issuer?.id; + const entries = arrayOf(credential?.credentialStatus) + .filter(s => arrayOf(s.type).includes(STATUS_LIST_2021_ENTRY_TYPE)); + for(const entry of entries) { + const url = entry.statusListCredential; + if(!url) { + return {verified: false, errors: ['Missing statusListCredential URL']}; + } + // fetch and unwrap the non-standard {"statusList": ""} envelope + let listJwt; + try { + const {data} = await httpClient.get(url, {agent}); + listJwt = typeof data?.statusList === 'string' ? data.statusList : + (typeof data === 'string' ? data : null); + } catch(e) { + return { + verified: false, + errors: [`Unable to fetch status list (${url}): ${e.message}`] + }; + } + if(!listJwt) { + return {verified: false, errors: [`Unexpected status list at ${url}`]}; + } + // verify the status list's signature (jku-published key; see helper) + let payload; + try { + payload = await _verifyStatusListSignature({listJwt, url, issuerId}); + } catch(e) { + return { + verified: false, + errors: [`Status list signature invalid: ${e.message}`] + }; + } + // decode the bitstring and check this credential's index + const cs = payload?.vc?.credentialSubject ?? {}; + let bytes; + try { + bytes = gunzipSync(Buffer.from(cs.encodedList ?? '', 'base64url')); + } catch(e) { + return { + verified: false, + errors: [`Unable to decode status list: ${e.message}`] + }; + } + if(_getStatusBit(bytes, Number(entry.statusListIndex))) { + const purpose = cs.statusPurpose ?? entry.statusPurpose; + return { + verified: false, + errors: [purpose === 'suspension' ? + 'The credential has been suspended.' : + 'The credential has been revoked.'] + }; + } + } + return {verified: true}; +}; + const checkStatus = async options => { const {credential} = options; const statuses = arrayOf(credential?.credentialStatus); @@ -185,17 +293,20 @@ const checkStatus = async options => { const statusEntryTypes = statuses.map( status => arrayOf(status.type) ).flat(); - if(statusEntryTypes.find(tt => !SUPPORTED_STATUS_ENTRY_TYPES.includes(tt))) { + const unsupported = statusEntryTypes.filter( + tt => !SUPPORTED_STATUS_ENTRY_TYPES.includes(tt)); + if(unsupported.length) { return { verified: false, - errors: [ - `Unsupported status entry type(s): ${ - statusEntryTypes - .filter(tt => !SUPPORTED_STATUS_ENTRY_TYPES.includes(tt)) - .join(', ')}`] + errors: [`Unsupported status entry type(s): ${unsupported.join(', ')}`] }; } + // route StatusList2021Entry to its dedicated handler + if(statusEntryTypes.includes(STATUS_LIST_2021_ENTRY_TYPE)) { + return checkStatusList2021(options); + } + return checkStatusBitstring(options); }; diff --git a/test/unit/17-statuslist2021.test.js b/test/unit/17-statuslist2021.test.js new file mode 100644 index 00000000..3174de89 --- /dev/null +++ b/test/unit/17-statuslist2021.test.js @@ -0,0 +1,124 @@ +/*! + * Copyright 2023 - 2026 California Department of Motor Vehicles + * Copyright 2023 - 2026 Digital Bazaar, Inc. + * + * SPDX-License-Identifier: BSD-3-Clause + */ + +import * as sinon from 'sinon'; +import {SignJWT, exportJWK, generateKeyPair} from 'jose'; +import expect from 'expect.js'; +import {gzipSync} from 'node:zlib'; +import {httpClient} from '@digitalbazaar/http-client'; +import {verifyUtils} from '../../common/utils.js'; + +const ISSUER = 'did:key:zStatusListIssuerExample00000000000000000000000000'; +const STATUS_URL = 'https://issuer.example/status/1'; +const JKU = 'https://issuer.example/.well-known/jwks.json'; +const REVOKED_INDEX = 5; +const CLEAR_INDEX = 9; + +// Build a StatusList2021 bitstring with REVOKED_INDEX set (MSB-first, per spec), +// gzip + base64url it, and sign the wrapping status-list JWT with an ES256 key +// published at `jku` (TWDIW signs status lists with a jku key, not the iss +// did:key). +const buildStatusList = async ({privateKey, statusPurpose}) => { + const bytes = new Uint8Array(16); + bytes[REVOKED_INDEX >> 3] |= 1 << (7 - (REVOKED_INDEX % 8)); + const encodedList = Buffer.from(gzipSync(Buffer.from(bytes))) + .toString('base64url'); + return new SignJWT({vc: {credentialSubject: {encodedList, statusPurpose}}}) + .setProtectedHeader({alg: 'ES256', kid: 'key-2', jku: JKU}) + .setIssuer(ISSUER) + .sign(privateKey); +}; + +const credentialWithStatus = statusListIndex => ({ + issuer: ISSUER, + credentialStatus: { + type: 'StatusList2021Entry', + statusListCredential: STATUS_URL, + statusListIndex + } +}); + +describe('StatusList2021Entry credential status', () => { + let getStub; + + const stubEndpoints = async ({statusPurpose = 'revocation'} = {}) => { + const {publicKey, privateKey} = await generateKeyPair('ES256'); + const publicJwk = {...await exportJWK(publicKey), kid: 'key-2', alg: 'ES256'}; + const listJwt = await buildStatusList({privateKey, statusPurpose}); + getStub = sinon.stub(httpClient, 'get'); + getStub.withArgs(STATUS_URL).resolves({data: {statusList: listJwt}}); + getStub.withArgs(JKU).resolves({data: {keys: [publicJwk]}}); + }; + + afterEach(() => { + sinon.restore(); + }); + + it('reports a revoked credential as not verified', async () => { + await stubEndpoints(); + const result = await verifyUtils.checkStatus( + {credential: credentialWithStatus(REVOKED_INDEX)}); + expect(result.verified).to.be(false); + expect(result.errors[0]).to.contain('revoked'); + }); + + it('reports a suspended credential as not verified', async () => { + await stubEndpoints({statusPurpose: 'suspension'}); + const result = await verifyUtils.checkStatus( + {credential: credentialWithStatus(REVOKED_INDEX)}); + expect(result.verified).to.be(false); + expect(result.errors[0]).to.contain('suspended'); + }); + + it('verifies a credential whose index bit is clear', async () => { + await stubEndpoints(); + const result = await verifyUtils.checkStatus( + {credential: credentialWithStatus(CLEAR_INDEX)}); + expect(result.verified).to.be(true); + }); + + it('fails when the status list jku is not same-origin as the list URL', + async () => { + const {publicKey, privateKey} = await generateKeyPair('ES256'); + const publicJwk = + {...await exportJWK(publicKey), kid: 'key-2', alg: 'ES256'}; + // sign with a jku on a DIFFERENT origin than statusListCredential + const listJwt = + await new SignJWT({vc: {credentialSubject: {encodedList: ''}}}) + .setProtectedHeader( + {alg: 'ES256', kid: 'key-2', jku: 'https://evil.example/jwks'}) + .setIssuer(ISSUER) + .sign(privateKey); + getStub = sinon.stub(httpClient, 'get'); + getStub.withArgs(STATUS_URL).resolves({data: {statusList: listJwt}}); + getStub.withArgs('https://evil.example/jwks') + .resolves({data: {keys: [publicJwk]}}); + const result = await verifyUtils.checkStatus( + {credential: credentialWithStatus(REVOKED_INDEX)}); + expect(result.verified).to.be(false); + expect(result.errors[0]).to.contain('same-origin'); + }); + + it('fails when the status list iss does not match the credential issuer', + async () => { + const {publicKey, privateKey} = await generateKeyPair('ES256'); + const publicJwk = + {...await exportJWK(publicKey), kid: 'key-2', alg: 'ES256'}; + const listJwt = + await new SignJWT({vc: {credentialSubject: {encodedList: ''}}}) + .setProtectedHeader({alg: 'ES256', kid: 'key-2', jku: JKU}) + .setIssuer('did:key:zSomeOtherIssuer') + .sign(privateKey); + getStub = sinon.stub(httpClient, 'get'); + getStub.withArgs(STATUS_URL).resolves({data: {statusList: listJwt}}); + getStub.withArgs(JKU).resolves({data: {keys: [publicJwk]}}); + const result = await verifyUtils.checkStatus( + {credential: credentialWithStatus(REVOKED_INDEX)}); + expect(result.verified).to.be(false); + expect(result.errors[0]).to.contain('issuer'); + }); +});