From ad1345574f86d632a5f9a668f92f47db48301417 Mon Sep 17 00:00:00 2001 From: Dave Longley Date: Fri, 23 Feb 2024 15:22:33 -0500 Subject: [PATCH] Require `indexAllocator` when setting status. --- lib/http.js | 16 +++++----- lib/slcs.js | 34 ++++++++++++++++----- lib/status.js | 35 ++++++++++++++++++++-- schemas/bedrock-vc-status.js | 22 +++++++------- test/mocha/20-status.js | 58 +++++++++++++++++++++++++++++++++++- 5 files changed, 138 insertions(+), 27 deletions(-) diff --git a/lib/http.js b/lib/http.js index 2ff0b9d..b760364 100644 --- a/lib/http.js +++ b/lib/http.js @@ -97,11 +97,12 @@ export async function addRoutes({app, service} = {}) { asyncHandler(async (req, res) => { try { const {config} = req.serviceObject; - // FIXME: require `indexAllocator` if not previously set - const {credentialId, credentialStatus, status = true} = req.body; - - await setStatus({config, credentialId, credentialStatus, status}); - + const { + credentialId, indexAllocator, credentialStatus, status = true + } = req.body; + await setStatus({ + config, credentialId, indexAllocator, credentialStatus, status + }); res.status(200).end(); } catch(error) { logger.error(error.message, {error}); @@ -147,10 +148,11 @@ async function _createOrRefreshStatusList({ res.sendStatus(204); } else { const { - credentialId, type, /*indexAllocator,*/ length, statusPurpose + credentialId, indexAllocator, type, length, statusPurpose } = req.body; await slcs.create({ - config, statusListId, credentialId, type, statusPurpose, length + config, statusListId, credentialId, indexAllocator, + type, statusPurpose, length }); res.status(204).location(statusListId).send(); } diff --git a/lib/slcs.js b/lib/slcs.js index 79c2a8e..482a0cc 100644 --- a/lib/slcs.js +++ b/lib/slcs.js @@ -3,6 +3,11 @@ */ import * as bedrock from '@bedrock/core'; import * as database from '@bedrock/mongodb'; +// FIXME: add bitstring status list support +// import { +// createBitstringStatusList, +// createBitstringStatusListCredential +// } from '@digitalbazaar/vc-bitstring-status-list'; import { createList as createList2021, createCredential as createSlc @@ -39,6 +44,9 @@ bedrock.events.on('bedrock-mongodb.ready', async () => { * @param {object} options - The options to use. * @param {object} options.config - The status instance config. * @param {string} options.statusListId - The ID of the status list. + * @param {string} options.indexAllocator - An unambiguous identifier for + * index allocation state; this must be provided whenever setting the + * status of a VC for the first time on the created status list. * @param {string} options.credentialId - The ID of the status list credential. * @param {string} options.type - The type of status list credential. * @param {string} options.statusPurpose - The status purpose. @@ -47,7 +55,8 @@ bedrock.events.on('bedrock-mongodb.ready', async () => { * @returns {Promise} Settles once the operation completes. */ export async function create({ - config, statusListId, credentialId, type, statusPurpose, length + config, statusListId, indexAllocator, + credentialId, type, statusPurpose, length } = {}) { if(!LIST_TYPE_TO_ENTRY_TYPE.has(type)) { throw new BedrockError( @@ -85,8 +94,8 @@ export async function create({ `This credential expresses status information for some ` + 'other credentials in an encoded and compressed list.'; credential = await issue({config, credential}); - await set({statusListId, credential, sequence: 0}); - return {statusListId, credential}; + await set({statusListId, indexAllocator, credential, sequence: 0}); + return {statusListId, indexAllocator, credential}; } /** @@ -96,6 +105,9 @@ export async function create({ * * @param {object} options - The options to use. * @param {string} options.statusListId - The ID of the status list. + * @param {string} options.indexAllocator - An unambiguous identifier for + * index allocation state; this must be provided whenever setting the + * status of a VC for the first time on a status list. * @param {object} options.credential - The status list credential. * @param {number} options.sequence - The sequence number associated with the * credential; used to ensure only newer versions of the credential are @@ -103,8 +115,11 @@ export async function create({ * * @returns {Promise} Settles once the operation completes. */ -export async function set({statusListId, credential, sequence} = {}) { +export async function set({ + statusListId, indexAllocator, credential, sequence +} = {}) { assert.string(statusListId, 'statusListId'); + assert.string(indexAllocator, 'indexAllocator'); assert.object(credential, 'credential'); assert.number(sequence, 'sequence'); @@ -117,7 +132,7 @@ export async function set({statusListId, credential, sequence} = {}) { 'meta.sequence': sequence === 0 ? null : sequence - 1 }, { $set, - $setOnInsert: {statusListId, 'meta.created': now} + $setOnInsert: {statusListId, indexAllocator, 'meta.created': now} }, {upsert: true}); if(result.result.n > 0) { @@ -214,7 +229,10 @@ export async function refresh({config, statusListId} = {}) { credential = await issue({config, credential}); // set updated SLC - await set({statusListId, credential, sequence: record.meta.sequence + 1}); + await set({ + statusListId, indexAllocator: record.indexAllocator, + credential, sequence: record.meta.sequence + 1 + }); return {credential}; } catch(e) { @@ -233,7 +251,9 @@ async function _getUncachedRecord({statusListId}) { const collection = database.collections[COLLECTION_NAME]; const record = await collection.findOne( {statusListId}, - {projection: {_id: 0, statusListId: 1, credential: 1, meta: 1}}); + {projection: { + _id: 0, statusListId: 1, indexAllocator: 1, credential: 1, meta: 1 + }}); if(!record) { throw new BedrockError( 'Status list credential not found.', { diff --git a/lib/status.js b/lib/status.js index 5379655..6aa162f 100644 --- a/lib/status.js +++ b/lib/status.js @@ -5,6 +5,10 @@ import * as bedrock from '@bedrock/core'; import * as mappings from './mappings.js'; import * as slcs from './slcs.js'; import assert from 'assert-plus'; +// FIXME: add bitstring status list support +// import { +// decodeBitstringStatusList +// } from '@digitalbazaar/vc-bitstring-status-list'; import {decodeList} from '@digitalbazaar/vc-status-list'; import {issue} from './issue.js'; import {LIST_TYPE_TO_ENTRY_TYPE} from './constants.js'; @@ -12,10 +16,11 @@ import {LIST_TYPE_TO_ENTRY_TYPE} from './constants.js'; const {util: {BedrockError}} = bedrock; export async function setStatus({ - config, credentialId, credentialStatus, status + config, credentialId, indexAllocator, credentialStatus, status } = {}) { assert.object(config, 'config'); assert.string(credentialId, 'credentialId'); + assert.optionalString(indexAllocator, 'indexAllocator'); assert.object(credentialStatus, 'credentialStatus'); assert.bool(status, 'status'); @@ -88,7 +93,32 @@ export async function setStatus({ let record = await slcs.get({statusListId, useCache: false}); _assertStatusListMatch({slc: record.credential, credentialStatus}); + // ensure `indexAllocator` value matches if given + if(indexAllocator !== undefined && + record.indexAllocator !== indexAllocator) { + throw new BedrockError( + `"indexAllocator" (${indexAllocator}) ` + + `does not match the expected value (${record.indexAllocator}).`, + 'DataError', { + actual: indexAllocator, + expected: record.indexAllocator, + httpStatusCode: 400, + public: true + }); + } + + // create new mapping... if(!mapping) { + // `indexAllocator` is required when creating a new mapping + if(indexAllocator === undefined) { + throw new BedrockError( + `"indexAllocator" is required when setting the status of a ` + + 'credential the first time.', + 'DataError', { + httpStatusCode: 400, + public: true + }); + } // add new mapping await mappings.set({ configId, @@ -119,7 +149,8 @@ export async function setStatus({ // update SLC await slcs.set({ - statusListId, credential: slc, sequence: record.meta.sequence + 1 + statusListId, indexAllocator: record.indexAllocator, + credential: slc, sequence: record.meta.sequence + 1 }); return; } catch(e) { diff --git a/schemas/bedrock-vc-status.js b/schemas/bedrock-vc-status.js index f13e57e..7f99c7e 100644 --- a/schemas/bedrock-vc-status.js +++ b/schemas/bedrock-vc-status.js @@ -3,17 +3,27 @@ */ import {MAX_LIST_SIZE} from '../lib/constants.js'; +// an ID value required to unambiguously identify index allocation state +const indexAllocator = { + // an ID (URL) referring to an index allocator + type: 'string', + // FIXME: pull in schema from bedrock-validation that uses + // `uri` pattern from ajv-formats once available + pattern: '^(.+):(.+)$' +}; + export const createStatusListBody = { title: 'Create Status List', type: 'object', additionalProperties: false, required: [ - 'credentialId', 'type', 'indexAllocator', 'length', 'statusPurpose' + 'credentialId', 'indexAllocator', 'type', 'length', 'statusPurpose' ], properties: { credentialId: { type: 'string' }, + indexAllocator, type: { type: 'string', // supported types in this version @@ -23,14 +33,6 @@ export const createStatusListBody = { 'StatusList2021' ] }, - // an ID value required to track index allocation - indexAllocator: { - // an ID (URL) referring to an index allocator - type: 'string', - // FIXME: pull in schema from bedrock-validation that uses - // `uri` pattern from ajv-formats once available - pattern: '^(.+):(.+)$' - }, // length of the status list in bits length: { type: 'number', @@ -46,13 +48,13 @@ export const createStatusListBody = { export const updateCredentialStatusBody = { title: 'Update Credential Status', type: 'object', - // FIXME: consider if `indexAllocator` should be required required: ['credentialId', 'credentialStatus'], additionalProperties: false, properties: { credentialId: { type: 'string' }, + indexAllocator, credentialStatus: { type: 'object', required: ['type', 'statusPurpose'], diff --git a/test/mocha/20-status.js b/test/mocha/20-status.js index 8bc748c..7f0c682 100644 --- a/test/mocha/20-status.js +++ b/test/mocha/20-status.js @@ -177,7 +177,7 @@ describe('status APIs', () => { const suffix = `/status-lists/revocation/0`; const statusListId = `${statusInstanceId}${suffix}`; const statusListOptions = { - credentialId: `https://foo.example/anything/111/${suffix}`, + credentialId: `https://foo.example/anything/111${suffix}`, type: 'StatusList2021', indexAllocator: `urn:uuid:${uuid()}`, length: 131072, @@ -282,6 +282,7 @@ describe('status APIs', () => { capability: statusInstanceRootZcap, json: { credentialId, + indexAllocator: statusListOptions.indexAllocator, credentialStatus: { type: 'StatusList2021Entry', statusPurpose: 'revocation', @@ -309,6 +310,60 @@ describe('status APIs', () => { status.should.equal(true); }); + it('fails to set status when no "indexAllocator" given', async () => { + // first create a status list + const statusListId = `${statusInstanceId}/status-lists/${uuid()}`; + const statusListOptions = { + credentialId: statusListId, + type: 'StatusList2021', + indexAllocator: `urn:uuid:${uuid()}`, + length: 131072, + statusPurpose: 'revocation' + }; + const {id: statusListCredential} = await helpers.createStatusList({ + url: statusListId, + capabilityAgent, + capability: statusInstanceRootZcap, + statusListOptions + }); + + // pretend a VC with this `credentialId` has been issued + const credentialId = `urn:uuid:${uuid()}`; + const statusListIndex = '0'; + + // get VC status, should work w/ initialized `false` value + const statusInfo = await helpers.getCredentialStatus({ + statusListCredential, statusListIndex + }); + const {status} = statusInfo; + status.should.equal(false); + + // try to revoke VC w/o `indexAllocator` + const zcapClient = helpers.createZcapClient({capabilityAgent}); + let error; + try { + await zcapClient.write({ + url: `${statusInstanceId}/credentials/status`, + capability: statusInstanceRootZcap, + json: { + credentialId, + credentialStatus: { + type: 'StatusList2021Entry', + statusPurpose: 'revocation', + statusListCredential, + statusListIndex + } + } + }); + } catch(e) { + error = e; + } + should.exist(error); + error.data.message.should.equal( + '"indexAllocator" is required when setting the status of a ' + + 'credential the first time.'); + }); + it('updates a terse "StatusList2021" revocation status', async () => { // first create a terse status list const statusListId = `${statusInstanceId}/status-lists/revocation/0`; @@ -346,6 +401,7 @@ describe('status APIs', () => { capability: statusInstanceRootZcap, json: { credentialId, + indexAllocator: statusListOptions.indexAllocator, credentialStatus: { type: 'StatusList2021Entry', statusPurpose: 'revocation',