diff --git a/lib/http.js b/lib/http.js index 01df7f54..4a5312d0 100644 --- a/lib/http.js +++ b/lib/http.js @@ -3,7 +3,6 @@ */ import * as bedrock from '@bedrock/core'; import * as slcs from './slcs.js'; -import {issue, setStatus} from './issuer.js'; import { issueCredentialBody, publishSlcBody, @@ -13,6 +12,7 @@ import {metering, middleware} from '@bedrock/service-core'; import {asyncHandler} from '@bedrock/express'; import bodyParser from 'body-parser'; import cors from 'cors'; +import {issue} from './issuer.js'; import {logger} from './logger.js'; import {createValidateMiddleware as validate} from '@bedrock/validation'; @@ -92,7 +92,7 @@ export async function addRoutes({app, service} = {}) { const {credentialId, credentialStatus} = req.body; // FIXME: support client requesting `status=false` as well - await setStatus({ + await slcs.setStatus({ id: credentialId, config, credentialStatus, status: true }); diff --git a/lib/issuer.js b/lib/issuer.js index 6de87cf0..11275ebb 100644 --- a/lib/issuer.js +++ b/lib/issuer.js @@ -4,14 +4,12 @@ import * as bedrock from '@bedrock/core'; import { issue as _issue, - assertSlcDoc, getDocumentStore, getIssuerAndSuite } from './helpers.js'; import assert from 'assert-plus'; import {createDocumentLoader} from './documentLoader.js'; import {CredentialStatusIssuer} from './CredentialStatusIssuer.js'; import {CredentialStatusWriter} from './CredentialStatusWriter.js'; -import {decodeList} from '@digitalbazaar/vc-status-list'; const {util: {BedrockError}} = bedrock; @@ -122,105 +120,3 @@ export async function issue({credential, config} = {}) { return verifiableCredential; } - -// FIXME: move to `slcs.js` -// FIXME: call out to status service -export async function setStatus({id, config, credentialStatus, status} = {}) { - assert.string(id, 'id'); - assert.object(config, 'config'); - assert.object(credentialStatus, 'credentialStatus'); - assert.bool(status, 'status'); - - const [documentLoader, documentStore] = await Promise.all([ - createDocumentLoader({config}), - getDocumentStore({config}) - ]); - - // check `credentialStatus` against credential meta information - const {meta} = await documentStore.get({id}); - const { - statusListCredential, statusListIndex - } = getMatchingStatusListCredentialMeta({ - meta, credentialStatus - }); - - // get SLC document; do not use cache to ensure latest doc is retrieved - let slcDoc = await documentStore.get( - {id: statusListCredential, useCache: false}); - assertSlcDoc({slcDoc, id: statusListCredential}); - - // TODO: use `documentStore.upsert` and `mutator` feature - const {edvClient} = documentStore; - - while(true) { - try { - // check if `credential` status is already set, if so, done - const slc = slcDoc.content; - const {credentialSubject: {encodedList}} = slc; - const list = await decodeList({encodedList}); - if(list.getStatus(statusListIndex) === status) { - return; - } - - // update issuer - const {meta: {statusListConfig}} = slcDoc; - const {issuer, suite} = await getIssuerAndSuite({ - config, suiteName: statusListConfig.suiteName - }); - slc.issuer = issuer; - - // use index to set status - list.setStatus(statusListIndex, status); - slc.credentialSubject.encodedList = await list.encode(); - - // express date without milliseconds - const date = new Date(); - // TODO: use `validFrom` and `validUntil` for v2 VCs - slc.issuanceDate = `${date.toISOString().slice(0, -5)}Z`; - // FIXME: get validity period via status service instance config - date.setDate(date.getDate() + 1); - slc.expirationDate = `${date.toISOString().slice(0, -5)}Z`; - // delete existing proof and reissue SLC VC - delete slc.proof; - slcDoc.content = await _issue({credential: slc, documentLoader, suite}); - - // update SLC doc - await edvClient.update({doc: slcDoc}); - return; - } catch(e) { - if(e.name !== 'InvalidStateError') { - throw e; - } - // ignore conflict, read and try again - slcDoc = await edvClient.get({id: slcDoc.id}); - } - } -} - -export function getMatchingStatusListCredentialMeta({ - meta, credentialStatus -} = {}) { - // return match against `meta.credentialStatus` where the status entry - // type and status purpose match - const candidates = meta.credentialStatus || []; - for(const c of candidates) { - if(c.type === credentialStatus.type && - (credentialStatus.type === 'RevocationList2020Status' || - c.statusPurpose === credentialStatus.statusPurpose)) { - return c; - } - } - - let purposeMessage = ''; - if(credentialStatus.statusPurpose) { - purposeMessage = - `with status purpose "${credentialStatus.statusPurpose}" `; - } - - throw new BedrockError( - `Credential status type "${credentialStatus.type}" ${purposeMessage}` + - 'is not supported by this issuer instance.', 'NotSupportedError', { - httpStatusCode: 400, - public: true - }); -} diff --git a/lib/slcs.js b/lib/slcs.js index 5b454eed..cc99bd3f 100644 --- a/lib/slcs.js +++ b/lib/slcs.js @@ -8,6 +8,7 @@ import { } from './helpers.js'; import assert from 'assert-plus'; import {createDocumentLoader} from './documentLoader.js'; +import {decodeList} from '@digitalbazaar/vc-status-list'; import {LruCache} from '@digitalbazaar/lru-memoize'; const {util: {BedrockError}} = bedrock; @@ -203,6 +204,106 @@ export async function refresh({id, config} = {}) { } } +export async function setStatus({id, config, credentialStatus, status} = {}) { + assert.string(id, 'id'); + assert.object(config, 'config'); + assert.object(credentialStatus, 'credentialStatus'); + assert.bool(status, 'status'); + + const [documentLoader, documentStore] = await Promise.all([ + createDocumentLoader({config}), + getDocumentStore({config}) + ]); + + // check `credentialStatus` against credential meta information + const {meta} = await documentStore.get({id}); + const { + statusListCredential, statusListIndex + } = _getMatchingStatusListCredentialMeta({ + meta, credentialStatus + }); + + // get SLC document; do not use cache to ensure latest doc is retrieved + let slcDoc = await documentStore.get( + {id: statusListCredential, useCache: false}); + assertSlcDoc({slcDoc, id: statusListCredential}); + + // TODO: use `documentStore.upsert` and `mutator` feature + const {edvClient} = documentStore; + + while(true) { + try { + // check if `credential` status is already set, if so, done + const slc = slcDoc.content; + const {credentialSubject: {encodedList}} = slc; + const list = await decodeList({encodedList}); + if(list.getStatus(statusListIndex) === status) { + return; + } + + // update issuer + const {meta: {statusListConfig}} = slcDoc; + const {issuer, suite} = await getIssuerAndSuite({ + config, suiteName: statusListConfig.suiteName + }); + slc.issuer = issuer; + + // use index to set status + list.setStatus(statusListIndex, status); + slc.credentialSubject.encodedList = await list.encode(); + + // express date without milliseconds + const date = new Date(); + // TODO: use `validFrom` and `validUntil` for v2 VCs + slc.issuanceDate = `${date.toISOString().slice(0, -5)}Z`; + // FIXME: get validity period via status service instance config + date.setDate(date.getDate() + 1); + slc.expirationDate = `${date.toISOString().slice(0, -5)}Z`; + // delete existing proof and reissue SLC VC + delete slc.proof; + slcDoc.content = await issue({credential: slc, documentLoader, suite}); + + // update SLC doc + await edvClient.update({doc: slcDoc}); + return; + } catch(e) { + if(e.name !== 'InvalidStateError') { + throw e; + } + // ignore conflict, read and try again + slcDoc = await edvClient.get({id: slcDoc.id}); + } + } +} + +export function _getMatchingStatusListCredentialMeta({ + meta, credentialStatus +} = {}) { + // return match against `meta.credentialStatus` where the status entry + // type and status purpose match + const candidates = meta.credentialStatus || []; + for(const c of candidates) { + if(c.type === credentialStatus.type && + (credentialStatus.type === 'RevocationList2020Status' || + c.statusPurpose === credentialStatus.statusPurpose)) { + return c; + } + } + + let purposeMessage = ''; + if(credentialStatus.statusPurpose) { + purposeMessage = + `with status purpose "${credentialStatus.statusPurpose}" `; + } + + throw new BedrockError( + `Credential status type "${credentialStatus.type}" ${purposeMessage}` + + 'is not supported by this issuer instance.', 'NotSupportedError', { + httpStatusCode: 400, + public: true + }); +} + async function _getUncachedRecord({id}) { const collection = database.collections[COLLECTION_NAME]; const record = await collection.findOne( diff --git a/test/mocha/20-credentials.js b/test/mocha/20-credentials.js index ba493ae1..33f4a786 100644 --- a/test/mocha/20-credentials.js +++ b/test/mocha/20-credentials.js @@ -25,21 +25,21 @@ const mockCredential = require('./mock-credential.json'); describe('issue APIs', () => { const suiteNames = { - Ed25519Signature2018: { - algorithm: 'Ed25519' - }, - Ed25519Signature2020: { - algorithm: 'Ed25519' - }, + // Ed25519Signature2018: { + // algorithm: 'Ed25519' + // }, + // Ed25519Signature2020: { + // algorithm: 'Ed25519' + // }, 'eddsa-rdfc-2022': { algorithm: 'Ed25519' }, - 'ecdsa-rdfc-2019': { - algorithm: ['P-256', 'P-384'] - }, - 'ecdsa-sd-2023': { - algorithm: ['P-256'] - } + // 'ecdsa-rdfc-2019': { + // algorithm: ['P-256', 'P-384'] + // }, + // 'ecdsa-sd-2023': { + // algorithm: ['P-256'] + // } }; for(const suiteName in suiteNames) { const suiteInfo = suiteNames[suiteName]; @@ -65,6 +65,8 @@ describe('issue APIs', () => { let sl2021SuspensionRootZcap; let smallStatusListIssuerId; let smallStatusListRootZcap; + let smallTerseStatusListIssuerId; + let smallTerseStatusListRootZcap; let oauth2IssuerConfig; const zcaps = {}; beforeEach(async () => { @@ -202,6 +204,26 @@ describe('issue APIs', () => { `urn:zcap:root:${encodeURIComponent(issuerConfig.id)}`; } + // create issuer instance w/ small terse status list + { + const statusListOptions = [{ + // FIXME: `TerseBitstringStatusList` + type: 'StatusList2021', + statusPurpose: 'revocation', + suiteName, + options: { + blockSize: 8, + blockCount: 1, + listCount: 1 + } + }]; + const issuerConfig = await helpers.createConfig( + {capabilityAgent, zcaps, statusListOptions, suiteName}); + smallTerseStatusListIssuerId = issuerConfig.id; + smallTerseStatusListRootZcap = + `urn:zcap:root:${encodeURIComponent(issuerConfig.id)}`; + } + // create issuer instance w/ oauth2-based authz oauth2IssuerConfig = await helpers.createConfig( {capabilityAgent, zcaps, oauth2: true, suiteName}); @@ -723,9 +745,68 @@ describe('issue APIs', () => { status.should.equal(true); } }); + + it.skip('issues VCs with limited lists', async function() { + // two minutes to issue and rollover lists + this.timeout(1000 * 60 * 2); + + // list size is 8, do two rollovers + const listSize = 8; + for(let i = 0; i < (listSize * 2 + 1); ++i) { + // first issue VC + const credential = klona(mockCredential); + credential.id = `urn:uuid:${uuid()}`; + const zcapClient = helpers.createZcapClient({capabilityAgent}); + const {data: {verifiableCredential}} = await zcapClient.write({ + url: `${smallTerseStatusListIssuerId}/credentials/issue`, + capability: smallTerseStatusListRootZcap, + json: {credential} + }); + + // get VC status + // FIXME: needs to include `indexAllocator` as TBD property + const statusInfo = await helpers.getCredentialStatus( + {verifiableCredential}); + let {status} = statusInfo; + status.should.equal(false); + + // then revoke VC + let error; + try { + await zcapClient.write({ + url: `${smallTerseStatusListIssuerId}/credentials/status`, + capability: smallTerseStatusListRootZcap, + json: { + credentialId: verifiableCredential.id, + // FIXME: needs to include `indexAllocator` as TBD property + credentialStatus: { + // FIXME: `BitstringStatusListEntry` + type: 'StatusList2021Entry', + statusPurpose: 'revocation' + } + } + }); + } catch(e) { + error = e; + } + assertNoError(error); + + // force publication of new SLC + await zcapClient.write({ + url: `${statusInfo.statusListCredential}/publish`, + capability: smallTerseStatusListRootZcap, + json: {} + }); + + // check status of VC has changed + ({status} = await helpers.getCredentialStatus( + {verifiableCredential})); + status.should.equal(true); + } + }); }); - describe('/credential/issue crash recovery', () => { + describe.only('/credential/issue crash recovery', () => { // stub modules in order to simulate failure conditions let credentialStatusWriterStub; let mathRandomStub; @@ -746,6 +827,10 @@ describe('issue APIs', () => { credentialStatusWriterStub.restore(); }); + // FIXME: add a test that finishes one credential writer but not + // another, resulting in a duplicate being detected for one status + // but not another -- and a successful recovery from this condition + it('successfully recovers from a simulated crash', async () => { const zcapClient = helpers.createZcapClient({capabilityAgent});