Skip to content

Commit

Permalink
Add CredentialStatusIssuer to handle multistatus issuance.
Browse files Browse the repository at this point in the history
  • Loading branch information
dlongley committed Feb 16, 2024
1 parent 4d77fe6 commit bf9d52a
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 161 deletions.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@
implementation and sufficient issuance calls). No changes are needed in new
deployments of this version given that no index allocation state will yet
exist (when following the above breaking changes requirements).
- **BREAKING**: Change the unique index for credential status to use
`meta.credentialStatus.id` instead of what is in the VC itself, as
the credential status ID may not be present in a VC (with a credential
status).

## 25.1.0 - 2023-11-14

Expand Down
161 changes: 161 additions & 0 deletions lib/CredentialStatusIssuer.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
/*!
* Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import assert from 'assert-plus';
import {CredentialStatusWriter} from './CredentialStatusWriter.js';
import {getIssuerAndSuite} from './helpers.js';
import {logger} from './logger.js';
import {constants as rlConstants} from '@bedrock/vc-revocation-list-context';
import {constants as slConstants} from '@bedrock/vc-status-list-context';

export class CredentialStatusIssuer {
constructor({config, documentLoader, documentStore} = {}) {
assert.object(config, 'config');
assert.func(documentLoader, 'documentLoader');
assert.object(documentStore, 'documentStore');
this.config = config;
this.documentLoader = documentLoader;
this.documentStore = documentStore;
this.credential = null;
this.writers = [];
this.statusResultMap = null;
}

async initialize({credential} = {}) {
assert.object(credential, 'credential');
this.credential = credential;

// see if config indicates a credential status should be set
const {config, documentLoader, documentStore, writers} = this;
const {statusListOptions = []} = config;

if(statusListOptions.length === 0) {
// nothing to do, no credential statuses to be written
return;
}

// create VC status writer(s); there may be N-many credential status
// writers, one for each status for the same credential, each will write
// a result into the status result map
this.statusResultMap = new Map();

// `type` defaults to `RevocationList2020`
for(const statusListConfig of statusListOptions) {
const {type = 'RevocationList2020', suiteName} = statusListConfig;
if(type === 'RevocationList2020') {
if(!credential['@context'].includes(
rlConstants.VC_REVOCATION_LIST_CONTEXT_V1_URL)) {
credential['@context'].push(
rlConstants.VC_REVOCATION_LIST_CONTEXT_V1_URL);
}
} else {
if(!credential['@context'].includes(slConstants.CONTEXT_URL_V1)) {
credential['@context'].push(slConstants.CONTEXT_URL_V1);
}
}

// FIXME: this process should setup writers for the N-many statuses ...
// for which the configuration should have zcaps/oauth privileges to
// connect to those status services and register VCs for status tracking
// and / or update status

// FIXME: the status service will need to issue and serve the SLC on
// demand -- and use cases may require redirection URLs for this
// FIXME: the status service will need access to its own other issuer
// instance for issuing SLCs
// FIXME: remove `issuer` and `suite` these will be handled by a status
// service instead

const {issuer, suite} = await getIssuerAndSuite({config, suiteName});
const slcsBaseUrl = config.id + bedrock.config['vc-issuer'].routes.slcs;
writers.push(new CredentialStatusWriter({
slcsBaseUrl,
documentLoader,
documentStore,
issuer,
statusListConfig,
suite
}));
}
}

async issue() {
// ensure every credential status writer has a result in the result map
const {credential, writers, statusResultMap} = this;
if(writers.length === 0) {
// no status to write
return [];
}

// code assumes there are only a handful of statuses such that no work queue
// is required; but ensure all writes finish before continuing since this
// code can run in a loop and cause overwrite bugs with slow database calls
const results = await Promise.allSettled(writers.map(async w => {
if(statusResultMap.has(w)) {
return;
}
statusResultMap.set(w, await w.write({credential}));
}));

// throw any errors for failed writes
for(const {status, reason} of results) {
if(status === 'rejected') {
throw reason;
}
}

// produce combined `credentialStatus` meta
const credentialStatus = [];
for(const [, statusMeta] of statusResultMap) {
credentialStatus.push(...statusMeta.map(
({credentialStatus}) => credentialStatus));
}
console.log('combined credential status meta', credentialStatus);
return credentialStatus;
}

async hasDuplicate() {
// check every status map result and remove any duplicates to allow a rerun
// for those writers
const {statusResultMap} = this;
const entries = [...statusResultMap.entries()];
const results = await Promise.allSettled(entries.map(
async ([w, statusMeta]) => {
const exists = await w.exists({statusMeta});
if(exists) {
// FIXME: remove logging
console.log('+++duplicate credential status',
statusResultMap.get(w));
statusResultMap.delete(w);
}
return exists;
}));
for(const {status, reason, value} of results) {
// if checking for a duplicate failed for any writer, we can't handle it
// gracefully; throw
if(status === 'rejected') {
throw reason;
}
if(value) {
return true;
}
}
console.log('---no duplicate credential status');
return false;
}

finish() {
const {writers} = this;
if(writers.length === 0) {
return;
}
// do not wait for status writing to complete (this would be an unnecessary
// performance hit)
writers.map(w => w.finish().catch(error => {
// logger errors for later analysis, but do not throw them; credential
// status write can be continued later by another process
logger.error(error.message, {error});
}));
}
}
73 changes: 57 additions & 16 deletions lib/CredentialStatusWriter.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,16 +201,23 @@ export class CredentialStatusWriter {
this.listShard = shardQueue.shift();
}

// FIXME: multiple credential statuses may be added for this writer;
// iterate `item.<TBD>` to add each one
const statusMeta = [];

// 4. Use the SL ID and the IAD from the LS to add the SL ID and the next
// unassigned SL index to a VC's credential status section.
const {
item: {statusListCredential},
item: {statusListCredential, listNumber: listNumber},
} = this.listShard;
const statusListIndex = _getListIndex({listShard: this.listShard});
this._upsertStatusEntry({
credential, statusListCredential, statusListIndex,
const statusListIndex = _getStatusListIndex({listShard: this.listShard});
const meta = this._upsertStatusEntry({
credential, listNumber, statusListCredential, statusListIndex,
credentialStatus: existingCredentialStatus
});
statusMeta.push(meta);

return statusMeta;
}

async finish() {
Expand Down Expand Up @@ -285,36 +292,68 @@ export class CredentialStatusWriter {
this.listShard = null;
}

async exists({credential} = {}) {
assert.object(credential, 'credential');
const count = await this.documentStore.edvClient.count({
equals: {'content.credentialStatus.id': credential.credentialStatus.id}
});
return count !== 0;
async exists({statusMeta} = {}) {
assert.object(statusMeta, 'statusMeta');
// check every `statusMeta`'s `credentialStatus.id` for duplicates
const counts = await Promise.all(statusMeta.map(
async ({credentialStatus}) => this.documentStore.edvClient.count({
equals: {'meta.credentialStatus.id': credentialStatus.id}
})));
return counts.some(count => count !== 0);
}

_upsertStatusEntry({
credential, statusListCredential, statusListIndex, credentialStatus
credential, listNumber, statusListCredential, statusListIndex,
credentialStatus
}) {
const {type, statusPurpose} = this.statusListConfig;
const {type, statusPurpose, options} = this.statusListConfig;
const existing = !!credentialStatus;
if(!existing) {
// create new credential status
credentialStatus = {};
}

credentialStatus.id = `${statusListCredential}#${statusListIndex}`;
const meta = {
// include all status information in `meta`
credentialStatus: {
id: `${statusListCredential}#${statusListIndex}`,
type,
statusListCredential,
statusListIndex: `${statusListIndex}`,
statusPurpose
}
};
// include `listNumber` if present (used, for example, for terse lists)
if(listNumber !== undefined) {
meta.credentialStatus.listNumber = listNumber;
}

// include all or subset of status information (depending on type)
if(type === 'RevocationList2020') {
credentialStatus.id = meta.credentialStatus.id;
credentialStatus.type = 'RevocationList2020Status';
credentialStatus.revocationListCredential = statusListCredential;
credentialStatus.revocationListIndex = `${statusListIndex}`;
} else {
// assume `StatusList2021`
} else if(type === 'StatusList2021') {
credentialStatus.id = meta.credentialStatus.id;
credentialStatus.type = 'StatusList2021Entry';
credentialStatus.statusListCredential = statusListCredential;
credentialStatus.statusListIndex = `${statusListIndex}`;
credentialStatus.statusPurpose = statusPurpose;
} else if(type === 'BitstringStatusList') {
credentialStatus.id = meta.credentialStatus.id;
credentialStatus.type = 'BitstringStatusList';
credentialStatus.statusListCredential = statusListCredential;
credentialStatus.statusListIndex = `${statusListIndex}`;
credentialStatus.statusPurpose = statusPurpose;
} else {
// assume `TerseBitstringStatusList`
credentialStatus.type = 'TerseBitstringStatusList';
// express status list index as offset into total list index space
const listSize = options.blockCount * options.blockSize;
const offset = listNumber * listSize + statusListIndex;
// use integer not a string
credentialStatus.statusListIndex = offset;
}

// add credential status if it did not already exist
Expand All @@ -330,10 +369,12 @@ export class CredentialStatusWriter {
credential.credentialStatus = credentialStatus;
}
}

return meta;
}
}

function _getListIndex({listShard}) {
function _getStatusListIndex({listShard}) {
const {
blockIndex,
indexAssignmentDoc: {content: {nextLocalIndex}},
Expand Down
1 change: 1 addition & 0 deletions lib/ListManager.js
Original file line number Diff line number Diff line change
Expand Up @@ -526,6 +526,7 @@ export class ListManager {
throw new Error('Not implemented.');
// FIXME: combine other list options to properly generate SLC IDs from
// the chosen list index
// `${this.slcsBaseUrl}/<listIndex>/<statusPurpose>`?
return [listIndex];
}

Expand Down
47 changes: 46 additions & 1 deletion lib/helpers.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
/*!
* Copyright (c) 2020-2023 Digital Bazaar, Inc. All rights reserved.
* Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as bedrock from '@bedrock/core';
import * as vc from '@digitalbazaar/vc';
import {AsymmetricKey, KmsClient} from '@digitalbazaar/webkms-client';
import {didIo} from '@bedrock/did-io';
import {generateId} from 'bnid';
import {
getCredentialStatus as get2020CredentialStatus
} from '@digitalbazaar/vc-revocation-list';
import {getCredentialStatus} from '@digitalbazaar/vc-status-list';
import {getSuiteParams} from './suites.js';
import {httpsAgent} from '@bedrock/https-agent';
import {serviceAgents} from '@bedrock/service-agent';

const {util: {BedrockError}} = bedrock;

export const serviceType = 'vc-issuer';

export async function generateLocalId() {
// 128-bit random number, base58 multibase + multihash encoded
Expand All @@ -18,6 +28,7 @@ export async function generateLocalId() {
});
}

// FIXME: move elsewhere?
export function getCredentialStatusInfo({credential, statusListConfig}) {
const {type, statusPurpose} = statusListConfig;
let credentialStatus;
Expand All @@ -34,9 +45,43 @@ export function getCredentialStatusInfo({credential, statusListConfig}) {
statusListIndex = parseInt(credentialStatus.statusListIndex, 10);
({statusListCredential} = credentialStatus);
}
// FIXME: support other `credentialStatus` types
return {credentialStatus, statusListIndex, statusListCredential};
}

export async function getIssuerAndSuite({
config, suiteName = config.issueOptions.suiteName
}) {
// get suite params for issuing a VC
const {createSuite, referenceId} = getSuiteParams({config, suiteName});

// get assertion method key to use for signing VCs
const {serviceAgent} = await serviceAgents.get({serviceType});
const {
capabilityAgent, zcaps
} = await serviceAgents.getEphemeralAgent({config, serviceAgent});
const invocationSigner = capabilityAgent.getSigner();
const zcap = zcaps[referenceId];
const kmsClient = new KmsClient({httpsAgent});
const assertionMethodKey = await AsymmetricKey.fromCapability(
{capability: zcap, invocationSigner, kmsClient});

// get `issuer` ID by getting key's public controller
let issuer;
try {
const {controller} = await didIo.get({url: assertionMethodKey.id});
issuer = controller;
} catch(e) {
throw new BedrockError(
'Unable to determine credential issuer.', 'AbortError', {
httpStatusCode: 400,
public: true
}, e);
}
const suite = createSuite({signer: assertionMethodKey});
return {issuer, suite};
}

// helpers must export this function and not `issuer` to prevent circular
// dependencies via `CredentialStatusWriter`, `ListManager` and `issuer`
export async function issue({credential, documentLoader, suite}) {
Expand Down
Loading

0 comments on commit bf9d52a

Please sign in to comment.