Skip to content

Commit 43d11a6

Browse files
committed
Add TerseBitstringStatusListEntry check feature.
1 parent 27a5638 commit 43d11a6

File tree

4 files changed

+448
-34
lines changed

4 files changed

+448
-34
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
# bedrock-vc-verifier ChangeLog
22

3+
## 23.3.0 - 2025-mm-dd
4+
5+
### Added
6+
- Add support for verifing `TerseBitstringStatusListEntry` credential
7+
statuses.
8+
39
## 23.2.0 - 2025-08-29
410

511
### Added

lib/documentLoader.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import '@bedrock/vc-status-list-context';
2121
import '@bedrock/veres-one-context';
2222

2323
const serviceType = 'vc-verifier';
24-
let webLoader;
24+
export let webLoader;
2525

2626
bedrock.events.on('bedrock.init', () => {
2727
// build web loader if configuration calls for it
@@ -48,14 +48,24 @@ bedrock.events.on('bedrock.init', () => {
4848
* @param {object} options.config - The verifier instance config.
4949
* @param {Set} [options.remoteUrlAllowList] - Remote URLs that are
5050
* specifically allowed to be loaded (used for status list checks).
51+
* @param {Map} [options.cache] - An optional cache of URL => document.
5152
*
5253
* @returns {Promise<Function>} The document loader.
5354
*/
54-
export async function createDocumentLoader({config, remoteUrlAllowList} = {}) {
55+
export async function createDocumentLoader({
56+
config, remoteUrlAllowList, cache = new Map()
57+
} = {}) {
5558
const contextDocumentLoader = await createContextDocumentLoader(
5659
{config, serviceType});
5760

5861
return async function documentLoader(url) {
62+
if(cache) {
63+
const document = cache.get(url);
64+
if(document) {
65+
return {contextUrl: null, documentUrl: url, document};
66+
}
67+
}
68+
5969
// handle DID URLs...
6070
if(url.startsWith('did:')) {
6171
let document;

lib/status.js

Lines changed: 178 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
/*!
2-
* Copyright (c) 2019-2024 Digital Bazaar, Inc. All rights reserved.
2+
* Copyright (c) 2019-2025 Digital Bazaar, Inc. All rights reserved.
33
*/
4+
import * as bedrock from '@bedrock/core';
45
import {
56
checkStatus as bitstringStatusListCheckStatus,
67
statusTypeMatches as bitstringStatusListStatusTypeMatches
78
} from '@digitalbazaar/vc-bitstring-status-list';
9+
import {createDocumentLoader, webLoader} from './documentLoader.js';
810
import {
911
checkStatus as revocationListCheckStatus,
1012
statusTypeMatches as revocationListStatusTypeMatches
@@ -14,13 +16,21 @@ import {
1416
statusTypeMatches as statusList2020StatusTypeMatches
1517
} from '@digitalbazaar/vc-status-list';
1618
import assert from 'assert-plus';
17-
import {createDocumentLoader} from './documentLoader.js';
19+
20+
const {util: {BedrockError}} = bedrock;
21+
22+
const TERSE_BITSTRING_STATUS_LIST_ENTRY = 'TerseBitstringStatusListEntry';
23+
// always 2^26 = 67108864 per vc-barcodes spec
24+
const TERSE_BITSTRING_STATUS_LIST_LENGTH = 67108864;
25+
const TERSE_STATUS_PURPOSES = ['revocation', 'suspension'];
26+
const VC_BARCODES_V1_CONTEXT_URL = 'https://w3id.org/vc-barcodes/v1';
1827

1928
const handlerMap = new Map();
2029
handlerMap.set('BitstringStatusListEntry', {
2130
checkStatus: bitstringStatusListCheckStatus,
2231
statusTypeMatches: bitstringStatusListStatusTypeMatches
2332
});
33+
// legacy status entry types
2434
handlerMap.set('RevocationList2020Status', {
2535
checkStatus: revocationListCheckStatus,
2636
statusTypeMatches: revocationListStatusTypeMatches
@@ -36,30 +46,66 @@ export function createCheckStatus({config} = {}) {
3646
assert.object(options.credential, 'options.credential');
3747

3848
try {
39-
const {credential} = options;
40-
const {credentialStatus} = credential;
41-
if(!credentialStatus) {
49+
if(!options.credential.credentialStatus) {
4250
// no status to check
4351
return {verified: true};
4452
}
4553

46-
const handlers = handlerMap.get(credentialStatus.type);
54+
// expand every `TerseBitstringStatusListEntry`
55+
const cache = new Map();
56+
const credential = await _expandAllTerseEntries({
57+
credential: options.credential, cache
58+
});
59+
const {credentialStatus} = credential;
60+
61+
// normalize credential status to an array
62+
const credentialStatuses = Array.isArray(credentialStatus) ?
63+
credentialStatus : [credentialStatus];
64+
65+
// combination of different status types not supported at this time
66+
const expectedType = credentialStatuses?.[0]?.type;
67+
if(credentialStatuses.some(({type}) => type !== expectedType)) {
68+
throw new BedrockError(
69+
'Combinations of different credential status types are not ' +
70+
'presently supported.', {
71+
name: 'NotSupportedError',
72+
details: {
73+
httpStatusCode: 400,
74+
public: true
75+
}
76+
});
77+
}
78+
79+
// get handlers for `expectedType`
80+
const handlers = handlerMap.get(expectedType);
4781
if(!(handlers && handlers.statusTypeMatches({credential}))) {
48-
throw new Error(
49-
`Unsupported credentialStatus type "${credentialStatus.type}".`);
82+
throw new BedrockError(
83+
`Unsupported credentialStatus type "${expectedType}".`, {
84+
name: 'NotSupportedError',
85+
details: {
86+
httpStatusCode: 400,
87+
public: true
88+
}
89+
});
90+
}
91+
92+
// create remote URL allow list from status lists
93+
const remoteUrlAllowList = new Set();
94+
for(const cs of credentialStatuses) {
95+
const url = cs.statusListCredential ?? cs.revocationListCredential;
96+
if(url) {
97+
remoteUrlAllowList.add(url);
98+
}
5099
}
51100

52101
// document loader needs to only allow web loading of status
53102
// list VCs, nothing else
54103
const documentLoader = await createDocumentLoader({
55-
config,
56-
remoteUrlAllowList: new Set([
57-
credentialStatus.statusListCredential ??
58-
credentialStatus.revocationListCredential
59-
])
104+
config, remoteUrlAllowList, cache
60105
});
61106
options = {
62107
...options,
108+
credential,
63109
documentLoader
64110
};
65111
return await handlers.checkStatus(options);
@@ -68,3 +114,122 @@ export function createCheckStatus({config} = {}) {
68114
}
69115
};
70116
}
117+
118+
async function _expandAllTerseEntries({credential, cache} = {}) {
119+
try {
120+
// check for any terse entries
121+
let hasTerseEntries = false;
122+
const {credentialStatus} = credential;
123+
if(Array.isArray(credentialStatus)) {
124+
hasTerseEntries = credentialStatus.some(
125+
cs => cs?.type === TERSE_BITSTRING_STATUS_LIST_ENTRY);
126+
} else if(credentialStatus?.type === TERSE_BITSTRING_STATUS_LIST_ENTRY) {
127+
hasTerseEntries = true;
128+
}
129+
130+
if(!hasTerseEntries) {
131+
return credential;
132+
}
133+
134+
// check for expected context
135+
const {'@context': contexts} = credential;
136+
if(!Array.isArray(contexts)) {
137+
throw new TypeError('"@context" must be an array.');
138+
}
139+
if(!contexts.includes(VC_BARCODES_V1_CONTEXT_URL)) {
140+
throw new TypeError(
141+
`The "@context" array must include "${VC_BARCODES_V1_CONTEXT_URL}".`);
142+
}
143+
144+
// expand any `TerseBitstringStatusListEntry` to `BitstringStatusListEntry`
145+
credential = structuredClone(credential);
146+
if(Array.isArray(credentialStatus)) {
147+
credential.credentialStatus = (await Promise.all(
148+
credentialStatus.map(
149+
async credentialStatus => _expandIfTerseEntry({
150+
credentialStatus, cache
151+
})))).flat();
152+
} else {
153+
credential.credentialStatus = await _expandIfTerseEntry({
154+
credentialStatus, cache
155+
});
156+
}
157+
return credential;
158+
} catch(cause) {
159+
throw new BedrockError(
160+
`Could not expand terse bitstring status list entries: ${cause.message}`,
161+
{
162+
name: 'DataError',
163+
cause,
164+
details: {
165+
httpStatusCode: 400,
166+
public: true
167+
}
168+
});
169+
}
170+
}
171+
172+
async function _expandIfTerseEntry({credentialStatus, cache}) {
173+
if(credentialStatus?.type !== TERSE_BITSTRING_STATUS_LIST_ENTRY) {
174+
// nothing to expand
175+
return credentialStatus;
176+
}
177+
178+
if(!webLoader) {
179+
throw new BedrockError(
180+
`Web loader disabled; cannot load credential status list(s) for `
181+
`status type "${credentialStatus.type}".`, {
182+
name: 'NotSupportedError',
183+
details: {
184+
httpStatusCode: 400,
185+
public: true
186+
}
187+
});
188+
}
189+
190+
// compute two possible expanded statuses, for purposes `revocation` and
191+
// `suspension`...
192+
const credentialStatuses = (await Promise.all(
193+
TERSE_STATUS_PURPOSES.map(async statusPurpose => {
194+
const expanded = _expandTerseEntry({credentialStatus, statusPurpose});
195+
const exists = await _fetchStatusListIfExists({expanded, cache});
196+
return exists ? expanded : undefined;
197+
}))).filter(cs => !!cs);
198+
199+
return credentialStatuses;
200+
}
201+
202+
function _expandTerseEntry({credentialStatus, statusPurpose}) {
203+
// compute `statusListCredential` from other params
204+
const listIndex = Math.floor(
205+
credentialStatus.terseStatusListIndex / TERSE_BITSTRING_STATUS_LIST_LENGTH);
206+
const statusListIndex = credentialStatus.terseStatusListIndex %
207+
TERSE_BITSTRING_STATUS_LIST_LENGTH;
208+
const {terseStatusListBaseUrl} = credentialStatus;
209+
const statusListCredential =
210+
`${terseStatusListBaseUrl}/${statusPurpose}/${listIndex}`;
211+
return {
212+
type: 'BitstringStatusListEntry',
213+
statusListCredential,
214+
statusListIndex: `${statusListIndex}`,
215+
statusPurpose
216+
};
217+
}
218+
219+
async function _fetchStatusListIfExists({expanded, cache}) {
220+
try {
221+
const {statusListCredential} = expanded;
222+
if(cache.has(statusListCredential)) {
223+
return true;
224+
}
225+
const {document} = await webLoader(statusListCredential);
226+
cache.set(statusListCredential, document);
227+
return true;
228+
} catch(e) {
229+
if(e.message === 'NotFoundError') {
230+
// ok for a terse bitstring list to not exist
231+
return false;
232+
}
233+
throw e;
234+
}
235+
}

0 commit comments

Comments
 (0)