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
127 changes: 119 additions & 8 deletions common/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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": "<JWT>"}` 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<object>} 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": "<JWT>"} 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);
Expand All @@ -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);
};

Expand Down
124 changes: 124 additions & 0 deletions test/unit/17-statuslist2021.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
});