Skip to content

Commit bf9d52a

Browse files
committed
Add CredentialStatusIssuer to handle multistatus issuance.
1 parent 4d77fe6 commit bf9d52a

File tree

6 files changed

+305
-161
lines changed

6 files changed

+305
-161
lines changed

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
implementation and sufficient issuance calls). No changes are needed in new
2121
deployments of this version given that no index allocation state will yet
2222
exist (when following the above breaking changes requirements).
23+
- **BREAKING**: Change the unique index for credential status to use
24+
`meta.credentialStatus.id` instead of what is in the VC itself, as
25+
the credential status ID may not be present in a VC (with a credential
26+
status).
2327

2428
## 25.1.0 - 2023-11-14
2529

lib/CredentialStatusIssuer.js

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*!
2+
* Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved.
3+
*/
4+
import * as bedrock from '@bedrock/core';
5+
import assert from 'assert-plus';
6+
import {CredentialStatusWriter} from './CredentialStatusWriter.js';
7+
import {getIssuerAndSuite} from './helpers.js';
8+
import {logger} from './logger.js';
9+
import {constants as rlConstants} from '@bedrock/vc-revocation-list-context';
10+
import {constants as slConstants} from '@bedrock/vc-status-list-context';
11+
12+
export class CredentialStatusIssuer {
13+
constructor({config, documentLoader, documentStore} = {}) {
14+
assert.object(config, 'config');
15+
assert.func(documentLoader, 'documentLoader');
16+
assert.object(documentStore, 'documentStore');
17+
this.config = config;
18+
this.documentLoader = documentLoader;
19+
this.documentStore = documentStore;
20+
this.credential = null;
21+
this.writers = [];
22+
this.statusResultMap = null;
23+
}
24+
25+
async initialize({credential} = {}) {
26+
assert.object(credential, 'credential');
27+
this.credential = credential;
28+
29+
// see if config indicates a credential status should be set
30+
const {config, documentLoader, documentStore, writers} = this;
31+
const {statusListOptions = []} = config;
32+
33+
if(statusListOptions.length === 0) {
34+
// nothing to do, no credential statuses to be written
35+
return;
36+
}
37+
38+
// create VC status writer(s); there may be N-many credential status
39+
// writers, one for each status for the same credential, each will write
40+
// a result into the status result map
41+
this.statusResultMap = new Map();
42+
43+
// `type` defaults to `RevocationList2020`
44+
for(const statusListConfig of statusListOptions) {
45+
const {type = 'RevocationList2020', suiteName} = statusListConfig;
46+
if(type === 'RevocationList2020') {
47+
if(!credential['@context'].includes(
48+
rlConstants.VC_REVOCATION_LIST_CONTEXT_V1_URL)) {
49+
credential['@context'].push(
50+
rlConstants.VC_REVOCATION_LIST_CONTEXT_V1_URL);
51+
}
52+
} else {
53+
if(!credential['@context'].includes(slConstants.CONTEXT_URL_V1)) {
54+
credential['@context'].push(slConstants.CONTEXT_URL_V1);
55+
}
56+
}
57+
58+
// FIXME: this process should setup writers for the N-many statuses ...
59+
// for which the configuration should have zcaps/oauth privileges to
60+
// connect to those status services and register VCs for status tracking
61+
// and / or update status
62+
63+
// FIXME: the status service will need to issue and serve the SLC on
64+
// demand -- and use cases may require redirection URLs for this
65+
// FIXME: the status service will need access to its own other issuer
66+
// instance for issuing SLCs
67+
// FIXME: remove `issuer` and `suite` these will be handled by a status
68+
// service instead
69+
70+
const {issuer, suite} = await getIssuerAndSuite({config, suiteName});
71+
const slcsBaseUrl = config.id + bedrock.config['vc-issuer'].routes.slcs;
72+
writers.push(new CredentialStatusWriter({
73+
slcsBaseUrl,
74+
documentLoader,
75+
documentStore,
76+
issuer,
77+
statusListConfig,
78+
suite
79+
}));
80+
}
81+
}
82+
83+
async issue() {
84+
// ensure every credential status writer has a result in the result map
85+
const {credential, writers, statusResultMap} = this;
86+
if(writers.length === 0) {
87+
// no status to write
88+
return [];
89+
}
90+
91+
// code assumes there are only a handful of statuses such that no work queue
92+
// is required; but ensure all writes finish before continuing since this
93+
// code can run in a loop and cause overwrite bugs with slow database calls
94+
const results = await Promise.allSettled(writers.map(async w => {
95+
if(statusResultMap.has(w)) {
96+
return;
97+
}
98+
statusResultMap.set(w, await w.write({credential}));
99+
}));
100+
101+
// throw any errors for failed writes
102+
for(const {status, reason} of results) {
103+
if(status === 'rejected') {
104+
throw reason;
105+
}
106+
}
107+
108+
// produce combined `credentialStatus` meta
109+
const credentialStatus = [];
110+
for(const [, statusMeta] of statusResultMap) {
111+
credentialStatus.push(...statusMeta.map(
112+
({credentialStatus}) => credentialStatus));
113+
}
114+
console.log('combined credential status meta', credentialStatus);
115+
return credentialStatus;
116+
}
117+
118+
async hasDuplicate() {
119+
// check every status map result and remove any duplicates to allow a rerun
120+
// for those writers
121+
const {statusResultMap} = this;
122+
const entries = [...statusResultMap.entries()];
123+
const results = await Promise.allSettled(entries.map(
124+
async ([w, statusMeta]) => {
125+
const exists = await w.exists({statusMeta});
126+
if(exists) {
127+
// FIXME: remove logging
128+
console.log('+++duplicate credential status',
129+
statusResultMap.get(w));
130+
statusResultMap.delete(w);
131+
}
132+
return exists;
133+
}));
134+
for(const {status, reason, value} of results) {
135+
// if checking for a duplicate failed for any writer, we can't handle it
136+
// gracefully; throw
137+
if(status === 'rejected') {
138+
throw reason;
139+
}
140+
if(value) {
141+
return true;
142+
}
143+
}
144+
console.log('---no duplicate credential status');
145+
return false;
146+
}
147+
148+
finish() {
149+
const {writers} = this;
150+
if(writers.length === 0) {
151+
return;
152+
}
153+
// do not wait for status writing to complete (this would be an unnecessary
154+
// performance hit)
155+
writers.map(w => w.finish().catch(error => {
156+
// logger errors for later analysis, but do not throw them; credential
157+
// status write can be continued later by another process
158+
logger.error(error.message, {error});
159+
}));
160+
}
161+
}

lib/CredentialStatusWriter.js

Lines changed: 57 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -201,16 +201,23 @@ export class CredentialStatusWriter {
201201
this.listShard = shardQueue.shift();
202202
}
203203

204+
// FIXME: multiple credential statuses may be added for this writer;
205+
// iterate `item.<TBD>` to add each one
206+
const statusMeta = [];
207+
204208
// 4. Use the SL ID and the IAD from the LS to add the SL ID and the next
205209
// unassigned SL index to a VC's credential status section.
206210
const {
207-
item: {statusListCredential},
211+
item: {statusListCredential, listNumber: listNumber},
208212
} = this.listShard;
209-
const statusListIndex = _getListIndex({listShard: this.listShard});
210-
this._upsertStatusEntry({
211-
credential, statusListCredential, statusListIndex,
213+
const statusListIndex = _getStatusListIndex({listShard: this.listShard});
214+
const meta = this._upsertStatusEntry({
215+
credential, listNumber, statusListCredential, statusListIndex,
212216
credentialStatus: existingCredentialStatus
213217
});
218+
statusMeta.push(meta);
219+
220+
return statusMeta;
214221
}
215222

216223
async finish() {
@@ -285,36 +292,68 @@ export class CredentialStatusWriter {
285292
this.listShard = null;
286293
}
287294

288-
async exists({credential} = {}) {
289-
assert.object(credential, 'credential');
290-
const count = await this.documentStore.edvClient.count({
291-
equals: {'content.credentialStatus.id': credential.credentialStatus.id}
292-
});
293-
return count !== 0;
295+
async exists({statusMeta} = {}) {
296+
assert.object(statusMeta, 'statusMeta');
297+
// check every `statusMeta`'s `credentialStatus.id` for duplicates
298+
const counts = await Promise.all(statusMeta.map(
299+
async ({credentialStatus}) => this.documentStore.edvClient.count({
300+
equals: {'meta.credentialStatus.id': credentialStatus.id}
301+
})));
302+
return counts.some(count => count !== 0);
294303
}
295304

296305
_upsertStatusEntry({
297-
credential, statusListCredential, statusListIndex, credentialStatus
306+
credential, listNumber, statusListCredential, statusListIndex,
307+
credentialStatus
298308
}) {
299-
const {type, statusPurpose} = this.statusListConfig;
309+
const {type, statusPurpose, options} = this.statusListConfig;
300310
const existing = !!credentialStatus;
301311
if(!existing) {
302312
// create new credential status
303313
credentialStatus = {};
304314
}
305315

306-
credentialStatus.id = `${statusListCredential}#${statusListIndex}`;
316+
const meta = {
317+
// include all status information in `meta`
318+
credentialStatus: {
319+
id: `${statusListCredential}#${statusListIndex}`,
320+
type,
321+
statusListCredential,
322+
statusListIndex: `${statusListIndex}`,
323+
statusPurpose
324+
}
325+
};
326+
// include `listNumber` if present (used, for example, for terse lists)
327+
if(listNumber !== undefined) {
328+
meta.credentialStatus.listNumber = listNumber;
329+
}
307330

331+
// include all or subset of status information (depending on type)
308332
if(type === 'RevocationList2020') {
333+
credentialStatus.id = meta.credentialStatus.id;
309334
credentialStatus.type = 'RevocationList2020Status';
310335
credentialStatus.revocationListCredential = statusListCredential;
311336
credentialStatus.revocationListIndex = `${statusListIndex}`;
312-
} else {
313-
// assume `StatusList2021`
337+
} else if(type === 'StatusList2021') {
338+
credentialStatus.id = meta.credentialStatus.id;
314339
credentialStatus.type = 'StatusList2021Entry';
315340
credentialStatus.statusListCredential = statusListCredential;
316341
credentialStatus.statusListIndex = `${statusListIndex}`;
317342
credentialStatus.statusPurpose = statusPurpose;
343+
} else if(type === 'BitstringStatusList') {
344+
credentialStatus.id = meta.credentialStatus.id;
345+
credentialStatus.type = 'BitstringStatusList';
346+
credentialStatus.statusListCredential = statusListCredential;
347+
credentialStatus.statusListIndex = `${statusListIndex}`;
348+
credentialStatus.statusPurpose = statusPurpose;
349+
} else {
350+
// assume `TerseBitstringStatusList`
351+
credentialStatus.type = 'TerseBitstringStatusList';
352+
// express status list index as offset into total list index space
353+
const listSize = options.blockCount * options.blockSize;
354+
const offset = listNumber * listSize + statusListIndex;
355+
// use integer not a string
356+
credentialStatus.statusListIndex = offset;
318357
}
319358

320359
// add credential status if it did not already exist
@@ -330,10 +369,12 @@ export class CredentialStatusWriter {
330369
credential.credentialStatus = credentialStatus;
331370
}
332371
}
372+
373+
return meta;
333374
}
334375
}
335376

336-
function _getListIndex({listShard}) {
377+
function _getStatusListIndex({listShard}) {
337378
const {
338379
blockIndex,
339380
indexAssignmentDoc: {content: {nextLocalIndex}},

lib/ListManager.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,7 @@ export class ListManager {
526526
throw new Error('Not implemented.');
527527
// FIXME: combine other list options to properly generate SLC IDs from
528528
// the chosen list index
529+
// `${this.slcsBaseUrl}/<listIndex>/<statusPurpose>`?
529530
return [listIndex];
530531
}
531532

lib/helpers.js

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,22 @@
11
/*!
2-
* Copyright (c) 2020-2023 Digital Bazaar, Inc. All rights reserved.
2+
* Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved.
33
*/
4+
import * as bedrock from '@bedrock/core';
45
import * as vc from '@digitalbazaar/vc';
6+
import {AsymmetricKey, KmsClient} from '@digitalbazaar/webkms-client';
7+
import {didIo} from '@bedrock/did-io';
58
import {generateId} from 'bnid';
69
import {
710
getCredentialStatus as get2020CredentialStatus
811
} from '@digitalbazaar/vc-revocation-list';
912
import {getCredentialStatus} from '@digitalbazaar/vc-status-list';
13+
import {getSuiteParams} from './suites.js';
14+
import {httpsAgent} from '@bedrock/https-agent';
15+
import {serviceAgents} from '@bedrock/service-agent';
16+
17+
const {util: {BedrockError}} = bedrock;
18+
19+
export const serviceType = 'vc-issuer';
1020

1121
export async function generateLocalId() {
1222
// 128-bit random number, base58 multibase + multihash encoded
@@ -18,6 +28,7 @@ export async function generateLocalId() {
1828
});
1929
}
2030

31+
// FIXME: move elsewhere?
2132
export function getCredentialStatusInfo({credential, statusListConfig}) {
2233
const {type, statusPurpose} = statusListConfig;
2334
let credentialStatus;
@@ -34,9 +45,43 @@ export function getCredentialStatusInfo({credential, statusListConfig}) {
3445
statusListIndex = parseInt(credentialStatus.statusListIndex, 10);
3546
({statusListCredential} = credentialStatus);
3647
}
48+
// FIXME: support other `credentialStatus` types
3749
return {credentialStatus, statusListIndex, statusListCredential};
3850
}
3951

52+
export async function getIssuerAndSuite({
53+
config, suiteName = config.issueOptions.suiteName
54+
}) {
55+
// get suite params for issuing a VC
56+
const {createSuite, referenceId} = getSuiteParams({config, suiteName});
57+
58+
// get assertion method key to use for signing VCs
59+
const {serviceAgent} = await serviceAgents.get({serviceType});
60+
const {
61+
capabilityAgent, zcaps
62+
} = await serviceAgents.getEphemeralAgent({config, serviceAgent});
63+
const invocationSigner = capabilityAgent.getSigner();
64+
const zcap = zcaps[referenceId];
65+
const kmsClient = new KmsClient({httpsAgent});
66+
const assertionMethodKey = await AsymmetricKey.fromCapability(
67+
{capability: zcap, invocationSigner, kmsClient});
68+
69+
// get `issuer` ID by getting key's public controller
70+
let issuer;
71+
try {
72+
const {controller} = await didIo.get({url: assertionMethodKey.id});
73+
issuer = controller;
74+
} catch(e) {
75+
throw new BedrockError(
76+
'Unable to determine credential issuer.', 'AbortError', {
77+
httpStatusCode: 400,
78+
public: true
79+
}, e);
80+
}
81+
const suite = createSuite({signer: assertionMethodKey});
82+
return {issuer, suite};
83+
}
84+
4085
// helpers must export this function and not `issuer` to prevent circular
4186
// dependencies via `CredentialStatusWriter`, `ListManager` and `issuer`
4287
export async function issue({credential, documentLoader, suite}) {

0 commit comments

Comments
 (0)