Skip to content

Commit

Permalink
Add enveloped credential support for status list VCs.
Browse files Browse the repository at this point in the history
  • Loading branch information
dlongley committed May 18, 2024
1 parent 3923ede commit 1a56887
Show file tree
Hide file tree
Showing 8 changed files with 165 additions and 28 deletions.
68 changes: 68 additions & 0 deletions lib/envelopes.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/*!
* Copyright (c) 2020-2024 Digital Bazaar, Inc. All rights reserved.
*/
import * as base64url from 'base64url-universal';
import * as bedrock from '@bedrock/core';

const {util: {BedrockError}} = bedrock;

const TEXT_DECODER = new TextDecoder();

export async function parseEnvelope({
envelopedVerifiableCredential
} = {}) {
const {id: dataURL} = envelopedVerifiableCredential;

// parse media type and encoding from data URL
const commaIndex = dataURL.indexOf(',');
let mediaType = dataURL.slice('data:'.length, commaIndex);
const semicolonIndex = mediaType.indexOf(';');
const encoding = semicolonIndex !== -1 ?
mediaType.slice(semicolonIndex) : undefined;
if(encoding !== undefined) {
mediaType = mediaType.slice(0, -encoding.length);
}

// parse data
const data = dataURL.slice(commaIndex);

try {
// VC-JWT
if(mediaType === 'application/jwt' && encoding === undefined) {
// parse JWT
const split = data.split('.');
const payload = JSON.parse(
TEXT_DECODER.decode(base64url.decode(split[1])));
const envelope = {data, mediaType};
return {envelope, verifiableCredential: payload};
}
} catch(e) {
throw new BedrockError(
`Error when parsing enveloped verifiable credential of "${mediaType}".`, {
name: 'DataError',
details: {
httpStatusCode: 500,
public: true
},
cause: new BedrockError(e.message, {
name: 'DataError',
details: {
httpStatusCode: 500,
public: true
}
})
});
}

// unrecognized media type and encoding combination
const andEncoding = encoding ? `and encoding ${encoding} ` : '';
throw new BedrockError(
`Enveloped credential media type "${mediaType}" ` +
`${andEncoding}is not supported.`, {
name: 'NotSupportedError',
details: {
httpStatusCode: 500,
public: true
}
});
}
21 changes: 19 additions & 2 deletions lib/http.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import cors from 'cors';
import {logger} from './logger.js';
import {setStatus} from './status.js';

const CREDENTIALS_CONTEXT_V2_URL = 'https://www.w3.org/ns/credentials/v2';

const {util: {BedrockError}} = bedrock;

export async function addRoutes({app, service} = {}) {
Expand Down Expand Up @@ -59,8 +61,23 @@ export async function addRoutes({app, service} = {}) {
asyncHandler(async (req, res) => {
const {config} = req.serviceObject;
const statusListId = _getStatusListId({req});
const {credential} = await slcs.getFresh({config, statusListId});
res.json(credential);
const {credential, envelope} = await slcs.getFresh({
config, statusListId
});
if(!envelope) {
res.json(credential);
} else {
// send enveloped VC
const {data, mediaType, encoding} = envelope;
const dataURL =
`data:${mediaType}${encoding ? ';base64,' : ','}${data}`;
const envelopedVerifiableCredential = {
'@context': CREDENTIALS_CONTEXT_V2_URL,
id: dataURL,
type: 'EnvelopedVerifiableCredential'
};
res.json(envelopedVerifiableCredential);
}
}));

// create a namespaced status list / force refresh of an existing one
Expand Down
23 changes: 20 additions & 3 deletions lib/issue.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
* Copyright (c) 2022-2024 Digital Bazaar, Inc. All rights reserved.
*/
import {getZcapClient} from './helpers.js';
import {parseEnvelope} from './envelopes.js';

const CREDENTIALS_CONTEXT_V1_URL = 'https://www.w3.org/2018/credentials/v1';
const CREDENTIALS_CONTEXT_V2_URL = 'https://www.w3.org/ns/credentials/v2';

export async function issue({config, credential, updateValidity = true} = {}) {
if(updateValidity) {
Expand All @@ -21,7 +23,7 @@ export async function issue({config, credential, updateValidity = true} = {}) {
credential.validUntil = validUntil;
}

// delete existing proof
// delete any existing proof
delete credential.proof;
}

Expand All @@ -40,9 +42,24 @@ export async function issue({config, credential, updateValidity = true} = {}) {
url += '/issue';
}
}
const {
let {
data: {verifiableCredential}
} = await zcapClient.write({url, capability, json: {credential}});

return verifiableCredential;
// parse enveloped verifiable credential as needed
let envelope = undefined;
if(_isEnvelopedCredential({verifiableCredential})) {
({envelope, verifiableCredential} = await parseEnvelope({
envelopedVerifiableCredential: verifiableCredential
}));
}

return {verifiableCredential, envelope};
}

function _isEnvelopedCredential({verifiableCredential} = {}) {
return (verifiableCredential['@context'] === CREDENTIALS_CONTEXT_V2_URL &&
verifiableCredential.type === 'EnvelopedCredential' &&
verifiableCredential.id?.startsWith('data:') &&
verifiableCredential.id.includes(','));
}
42 changes: 29 additions & 13 deletions lib/slcs.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,13 @@ export async function create({
credential.description =
`This credential expresses status information for some ` +
'other credentials in an encoded and compressed list.';
credential = await issue({config, credential});
await set({statusListId, indexAllocator, credential, sequence: 0});
return {statusListId, indexAllocator, credential};
let envelope = undefined;
({verifiableCredential: credential, envelope} = await issue({
config, credential
}));

await set({statusListId, indexAllocator, credential, envelope, sequence: 0});
return {statusListId, indexAllocator, credential, envelope};
}

/**
Expand All @@ -116,24 +120,33 @@ export async function create({
* 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 {object} [options.envelope] - An optional security envelope that
* wraps the credential (for envelope-based security); if present, this must
* be an object including `data` and `mediaType` and, optionally, `encoding`.
* @param {number} options.sequence - The sequence number associated with the
* credential; used to ensure only newer versions of the credential are
* stored.
*
* @returns {Promise<object>} Settles once the operation completes.
*/
export async function set({
statusListId, indexAllocator, credential, sequence
statusListId, indexAllocator, credential, envelope, sequence
} = {}) {
assert.string(statusListId, 'statusListId');
assert.string(indexAllocator, 'indexAllocator');
assert.object(credential, 'credential');
assert.optionalObject(envelope, 'envelope');
assert.number(sequence, 'sequence');

try {
const collection = database.collections[COLLECTION_NAME];
const now = Date.now();
const $set = {credential, 'meta.updated': now, 'meta.sequence': sequence};
const $set = {
credential,
'meta.updated': now,
'meta.sequence': sequence,
'meta.envelope': envelope ?? null
};
const result = await collection.updateOne({
statusListId,
'meta.sequence': sequence === 0 ? null : sequence - 1
Expand Down Expand Up @@ -208,11 +221,11 @@ export async function getFresh({config, statusListId} = {}) {
record.credential.expirationDate);
if(now <= validUntil) {
// SLC not expired
return {credential: record.credential};
return {credential: record.credential, envelope: record.meta.envelope};
}
// refresh SLC
const {credential} = await refresh({config, statusListId});
return {credential};
const {credential, envelope} = await refresh({config, statusListId});
return {credential, envelope};
}

/**
Expand All @@ -231,27 +244,30 @@ export async function refresh({config, statusListId} = {}) {

const record = await get({statusListId, useCache: false});
let {credential} = record;
let envelope;

try {
// reissue SLC
credential = await issue({config, credential});
({verifiableCredential: credential, envelope} = await issue({
config, credential
}));

// set updated SLC
await set({
statusListId, indexAllocator: record.indexAllocator,
credential, sequence: record.meta.sequence + 1
credential, envelope, sequence: record.meta.sequence + 1
});

return {credential};
return {credential, envelope};
} catch(e) {
if(e.name !== 'InvalidStateError') {
throw e;
}
// ignore conflict; SLC was concurrently updated, just ensure cache is
// cleared
SLC_CACHE.delete(statusListId);
({credential} = await get({statusListId}));
return {credential};
({credential, meta: {envelope}} = await get({statusListId}));
return {credential, envelope};
}
}

Expand Down
7 changes: 5 additions & 2 deletions lib/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,12 +148,15 @@ export async function setStatus({
slc.credentialSubject.encodedList = await list.encode();

// reissue SLC
slc = await issue({config, credential: slc});
let envelope = undefined;
({verifiableCredential: slc, envelope} = await issue({
config, credential: slc
}));

// update SLC
await slcs.set({
statusListId, indexAllocator: record.indexAllocator,
credential: slc, sequence: record.meta.sequence + 1
credential: slc, envelope, sequence: record.meta.sequence + 1
});
return;
} catch(e) {
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@digitalbazaar/vc-bitstring-status-list": "digitalbazaar/vc-bitstring-status-list#main",
"@digitalbazaar/vc-status-list": "^7.0.0",
"assert-plus": "^1.0.0",
"base64url-universal": "^2.0.0",
"bnid": "^3.0.0",
"cors": "^2.8.5"
},
Expand Down
19 changes: 17 additions & 2 deletions test/mocha/helpers.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {EdvClient} from '@digitalbazaar/edv-client';
import {getAppIdentity} from '@bedrock/app-identity';
import {httpClient} from '@digitalbazaar/http-client';
import {httpsAgent} from '@bedrock/https-agent';
import {parseEnvelope} from '@bedrock/vc-status/lib/envelopes.js';
import {ZcapClient} from '@digitalbazaar/ezcap';

import {mockData} from './mock.data.js';
Expand Down Expand Up @@ -289,8 +290,16 @@ export async function delegate({
export async function getCredentialStatus({
statusListCredential, statusListIndex
}) {
const {data: slc} = await httpClient.get(
let {data: slc} = await httpClient.get(
statusListCredential, {agent: httpsAgent});

// parse enveloped VC as needed
if(slc.type === 'EnvelopedVerifiableCredential') {
({verifiableCredential: slc} = await parseEnvelope({
envelopedVerifiableCredential: slc
}));
}

const {encodedList} = slc.credentialSubject;
let list;
if(slc.type.includes('BitstringStatusListCredential')) {
Expand All @@ -304,7 +313,13 @@ export async function getCredentialStatus({
}

export async function getStatusListCredential({statusListId}) {
const {data: slc} = await httpClient.get(statusListId, {agent: httpsAgent});
let {data: slc} = await httpClient.get(statusListId, {agent: httpsAgent});
// parse enveloped VC as needed
if(slc.type === 'EnvelopedVerifiableCredential') {
({verifiableCredential: slc} = await parseEnvelope({
envelopedVerifiableCredential: slc
}));
}
return slc;
}

Expand Down
12 changes: 6 additions & 6 deletions test/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@
"@bedrock/express": "^8.0.0",
"@bedrock/https-agent": "^4.0.0",
"@bedrock/jsonld-document-loader": "^4.0.0",
"@bedrock/kms": "^14.0.0",
"@bedrock/kms-http": "^18.0.0",
"@bedrock/kms": "^15.0.0",
"@bedrock/kms-http": "^20.0.0",
"@bedrock/ledger-context": "^24.0.0",
"@bedrock/meter": "^5.0.0",
"@bedrock/meter-http": "^12.0.0",
Expand All @@ -36,7 +36,7 @@
"@bedrock/service-agent": "^8.0.0",
"@bedrock/service-context-store": "^11.0.0",
"@bedrock/service-core": "^9.0.0",
"@bedrock/ssm-mongodb": "^10.1.2",
"@bedrock/ssm-mongodb": "^11.2.1",
"@bedrock/test": "^8.0.5",
"@bedrock/validation": "^7.0.0",
"@bedrock/vc-issuer": "digitalbazaar/bedrock-vc-issuer#vc-v2-latest",
Expand All @@ -48,10 +48,10 @@
"@digitalbazaar/edv-client": "^16.0.0",
"@digitalbazaar/ezcap": "^4.0.0",
"@digitalbazaar/http-client": "^4.0.0",
"@digitalbazaar/vc-status-list": "^7.0.0",
"@digitalbazaar/vc-bitstring-status-list": "digitalbazaar/vc-bitstring-status-list#main",
"@digitalbazaar/webkms-client": "^13.0.0",
"c8": "^7.11.3",
"@digitalbazaar/vc-status-list": "^7.0.0",
"@digitalbazaar/webkms-client": "^14.1.0",
"c8": "^9.1.0",
"cross-env": "^7.0.3",
"jose": "^4.8.3",
"klona": "^2.0.5",
Expand Down

0 comments on commit 1a56887

Please sign in to comment.