UNPKG

bedrock-web-vc-store

Version:

A Javascript library for storing Verifiable Credentials for Bedrock web apps.

805 lines (731 loc) 27.6 kB
/*! * Copyright (c) 2019-2022 Digital Bazaar, Inc. All rights reserved. */ import * as assert from './assert.js'; import canonicalize from 'canonicalize'; import pAll from 'p-all'; // `10` is chosen as the concurrency value because it is the lowest power of // ten that is an acceptable number of concurrent web requests when pipelining // may not be available (for remote credential stores) const OPS_CONCURRENCY = 10; /** * Each instance of this API is associated with a single EDV client and * performs initialization (ensures required indexes are created). */ export class VerifiableCredentialStore { /** * Creates a `VerifiableCredentialStore` interface for accessing a VC store * in an EDV (Encrypted Data Vault). * * @param {object} options - The options to use. * @param {object} options.edvClient - An `EdvClient` instance to use. */ constructor({edv, edvClient, capability, invocationSigner}) { // throw on old parameters if(edv !== undefined) { throw new Error( '"edv" is no longer supported, pass "edvClient" instead.'); } if(capability !== undefined) { throw new Error( '"capability" is no longer supported, pass an "edvClient" instance ' + 'that internalizes zcap processing instead.'); } if(invocationSigner !== undefined) { throw new Error( '"invocationSigner" is no longer supported, pass "edvClient" ' + 'instance that internalizes zcap processing instead.'); } assert.object(edvClient, 'edvClient'); this.edvClient = edvClient; // setup EDV indexes... // index to find by VC ID edvClient.ensureIndex({attribute: 'content.id', unique: true}); // index to find by issuer edvClient.ensureIndex({ attribute: ['meta.issuer', 'meta.displayable', 'content.type'] }); // index to find displayable VCs edvClient.ensureIndex({attribute: 'meta.displayable'}); // index to find the VCs that *directly* bundled a credential; bundles // may reference other VCs that are themselves bundles, but indirect // bundle membership is not tracked here edvClient.ensureIndex({attribute: 'meta.bundledBy'}); // index to find by type edvClient.ensureIndex({attribute: ['content.type', 'meta.issuer']}); } /** * Gets a verifiable credential by its ID. * * @param {string} id - The ID of the credential. * * @returns {Promise<object>} The EDV document for the stored VC. */ async get({id} = {}) { const {documents: [doc]} = await this.edvClient.find({ equals: {'content.id': id} }); if(!doc) { const err = new Error('Verifiable Credential not found.'); err.name = 'NotFoundError'; throw err; } return doc; } /** * Gets a bundle associated with a verifiable credential. * * @param {string} [id] - The ID of the credential. * @param {object} [doc] - The decrypted EDV document for the credential. * * @returns {Promise<object>} The result: `{doc, bundle, allSubDocuments}`. */ async getBundle({id, doc} = {}) { if(id && doc) { throw new Error('Only one of "id" or "docId" may be given.'); } if(!(id || doc)) { throw new Error('Either "id" or "doc" must be given.'); } if(id) { doc = await this.get({id}); } if(!(doc.content.id && doc.meta.bundle)) { // no bundle associated with the doc return {doc, bundle: false, allSubDocuments: []}; } const bundleResult = await this._getBundle({id}); return {doc, ...bundleResult}; } /** * Gets all verifiable credential instances that match the given parameters. * * @param {object|Array} query - One or more query objects with `type`, * `issuer`, `displayable`, and `bundledBy` filters. * @param {object} [options] - Query options such as `limit`. * * @returns {Promise<object>} The matching EDV documents as an array in * `documents`. */ async find({query, options = {}} = {}) { assert.objectOrArrayOfObjects(query, 'query'); assert.object(options, 'options'); // normalize query to an array of queries const queries = Array.isArray(query) ? query : [query]; // build `equals` EDV query const equals = []; for(const q of queries) { assert.query(q, 'query'); const {type, issuer, displayable, bundledBy} = q; const entry = {}; if(type) { entry['content.type'] = type; } if(issuer) { entry['meta.issuer'] = issuer; } if(displayable) { entry['meta.displayable'] = displayable; } if(bundledBy) { entry['meta.bundledBy'] = bundledBy; } equals.push(entry); } const q = {equals}; if(options.limit !== undefined) { q.limit = options.limit; } if(options.count !== undefined) { q.count = options.count; } return this.edvClient.find(q); } /** * Converts a VPR query into a local query to run against `find()`. * * @param {object} vprQuery - A Verifiable Presentation Request query * (e.g. `{type: "QueryByExample", ...}`). * * @returns {Promise<object>} An object with `queries` set to an array where * each element is a separate query to be passed to `find()`; the * queries will be ordered such that they match the VPR query order; future * versions may add additional properties to the returned object such as * trusted issuer VCs. */ async convertVPRQuery({vprQuery} = {}) { assert.object(vprQuery, 'vprQuery'); const {type} = vprQuery; if(type === 'QueryByExample') { const {credentialQuery} = vprQuery; return this._convertQueryByExample({credentialQuery}); } throw new Error(`Unsupported query type: "${type}"`); } /** * Stores a verifiable credential in EDV storage as the content an EDV * document. If `bundleContents` is passed, the credential will be marked * as a bundle and all sub-credentials will be upserted. * * @param {object} credential - The credential to insert; it will be set as * the `content` of the EDV document. * @param {object} [meta={}] - Custom meta data to set; the `issuer` field * will be auto-populated if not set in the custom `meta`. * @param {array} [bundleContents=[]] - Optional bundle contents if the * credential is a bundle of other credentials; each element is an object: * `{credential, meta, [bundleContents], [dependent=true]}` where * `bundleContents` is an optional set of sub-bundle contents and * `dependent` specifies whether the sub-credential will be deleted when * all of its parent bundles are deleted (however, if a sub-credential was * already in storage, it will not be changed to `dependent`). * * @returns {Promise<object>} - The stored EDV document. */ async insert({credential, meta = {}, bundleContents}) { assert.object(credential, 'credential'); assert.object(meta, 'meta'); meta = {...meta}; if(bundleContents !== undefined) { assert.bundleContents(bundleContents, 'bundleContents'); // a VC without an ID cannot be a bundle if(!credential.id) { throw new Error( '"credential.id" must be a string to define a bundle.'); } if(bundleContents.length > 0) { // VC is a bundle meta.bundle = true; } } if(!meta.issuer) { meta.issuer = _getIssuer({credential}); } // insert the credential first (before any bundle contents), to help // ensure its contents can be deleted if adding the bundle contents // partially fails const result = await this.edvClient.insert({ doc: {meta, content: credential} }); // now add any bundle contents if(bundleContents && bundleContents.length > 0) { await this._addBundleContents({bundleId: credential.id, bundleContents}); } return result; } /** * Upserts a verifiable credential in EDV storage, overwriting any previous * version if one exists. If `bundleContents` is passed, the credential will * be marked as a bundle and all sub-credentials will be upserted. * * @param {object} credential - The credential to upsert; it will be set as * the `content` of the EDV document; "credential.id" must be a string. * @param {object} [meta={}] - Custom meta data to set; the `issuer` field * will be auto-populated if not set in the custom `meta`. * @param {function} [mutator({doc, credential, meta})] - A function that is * called if an existing credential is found and that must return the * document to use to update the existing document; the default function * will not overwrite an existing `credential` and will try to safely * merge `meta` fields, this can be disabled by passing `false` to * overwrite both `credential` and `meta` completely or a custom function * can be passed. * @param {array} [bundleContents=[]] - Optional bundle contents if the * credential is a bundle of other credentials; each element is an object: * `{credential, meta, [bundleContents], [dependent=true]}` where * `bundleContents` is an optional set of sub-bundle contents and * `dependent` specifies whether the sub-credential will be deleted when * all of its parent bundles are deleted (however, if a sub-credential was * already in storage, it will not be changed to `dependent`). * * @returns {Promise<object>} - The stored EDV document. */ async upsert({ credential, meta = {}, mutator = defaultMutator, bundleContents } = {}) { assert.object(credential, 'credential'); assert.object(meta, 'meta'); if(mutator !== undefined) { // mutator may be false or a function if(!(mutator === false || typeof mutator === 'function')) { throw new TypeError('"mutator" must be false or a function.'); } } meta = {...meta}; // `id` required to use `upsert` if(typeof credential.id !== 'string') { throw new TypeError('"credential.id" must be a string.'); } if(bundleContents !== undefined) { assert.bundleContents(bundleContents, 'bundleContents'); if(bundleContents.length > 0) { // VC is a bundle meta.bundle = true; } } if(!meta.issuer) { meta.issuer = _getIssuer({credential}); } // upsert the credential first (before any bundle contents), to help // ensure its contents can be deleted if adding the bundle contents // partially fails // get previous document and overwrite if it exists; loop to handle // concurrent updates let result; while(true) { let doc; let isNew = false; try { doc = await this.get({id: credential.id}); if(mutator) { doc = await mutator({doc, credential, meta}); } else { // just overwrite directly doc.meta = meta; doc.content = credential; } } catch(e) { if(e.name !== 'NotFoundError') { throw e; } isNew = true; doc = { id: await this.edvClient.generateId(), content: credential, meta, sequence: 0 }; } try { result = await this.edvClient.update({doc}); break; } catch(e) { // see if the duplication happened because of `content.id`, if so, // try again if(e.name === 'DuplicateError' && isNew) { await this.get({id: credential.id}); // no exception, so credential was created while we were trying to // update, so loop to try again to update the existing doc instead continue; } if(e.name !== 'InvalidStateError') { throw e; } // loop to try again } } // now add any bundle contents if(bundleContents && bundleContents.length > 0) { await this._addBundleContents({bundleId: credential.id, bundleContents}); } return result; } /** * Removes a verifiable credential identified by its ID or EDV doc ID (for * VCs that do not have IDs). If the credential is bundled by any other * credential or if the credential is a bundle and `deleteBundle=false`, * then an error will be thrown unless `force` is set to true. * * @param {string} [id] - The ID of the credential. * @param {string} [docId] - The ID of the EDV document storing the * credential. * @param {boolean} [deleteBundle=true] - If `true` and credential is a * bundle, then any credentials that are part of it and depend on its * existence (`meta.dependent=true`) will also be deleted. * @param {boolean} [force=false] - If `true` the credential will be forcibly * deleted whether or not it is part of a bundle. * * @returns {Promise<object>} - An object with * `{deleted: boolean, doc, bundle}` where `deleted` is set to true if * anything was deleted; `doc` is only set if the deleted document was * found and `bundle` is only set if a bundle was updated (an update will * include unlinking the parent bundle from sub-bundles and, if any * sub-credentials are dependent on parent bundles and no longer have any * parents, they will be deleted as well). */ async delete({id, docId, deleteBundle = true, force = false} = {}) { if(!(id || docId)) { throw new TypeError('Either "id" or "docId" must be a string.'); } if(id && docId) { throw new Error('Only one of "id" or "docId" may be given.'); } // loop to handle concurrent updates while(true) { try { return await this._delete({id, docId, deleteBundle, force}); } catch(e) { if(e.name !== 'InvalidStateError') { throw e; } // loop to try again } } } // called from `insert` and `upsert` to add bundle contents async _addBundleContents({bundleId, bundleContents}) { // upsert all same-level bundle contents concurrently const actions = bundleContents.map(entry => { const {credential, meta = {}, bundleContents, dependent = true} = entry; return () => { const m = {...meta}; if(dependent) { m.dependent = dependent; } // record that this VC is bundled by `bundleId` const set = new Set(m.bundledBy || []); set.add(bundleId); m.bundledBy = [...set]; return this.upsert({credential, meta: m, bundleContents}); }; }); await pAll(actions, {concurrency: OPS_CONCURRENCY, stopOnError: true}); } // called from `delete` as a helper within a concurrent ops handling loop async _delete({id, docId, deleteBundle, force}) { let doc; let bundle; try { if(docId) { // fetch doc by `docId` try { doc = await this.edvClient.get({id: docId}); id = doc.content.id; } catch(e) { if(e.name === 'NotFoundError') { return {deleted: false, doc, bundle}; } throw e; } } // start fetching bundle let bundleResult; if(id) { bundleResult = this._getBundle({id}).catch(e => e); } if(!doc) { // fetch doc by `content.id` ({documents: [doc]} = await this.edvClient.find({ equals: {'content.id': id} })); } // wait for bundle to be loaded bundleResult = await bundleResult; if(bundleResult instanceof Error) { throw bundleResult; } // if another VC bundles the VC to be deleted, then throw if(!force && doc && doc.meta.bundledBy && doc.meta.bundledBy.length > 0) { const error = new Error( 'Cannot delete credential; all other credentials that bundle it ' + 'must be deleted first.'); error.name = 'ConstraintError'; throw error; } if(bundleResult.allSubDocuments.length > 0) { if(deleteBundle) { // delete / unlink the bundled docs first so if this operation // fails, a delete can be attempted again later ({bundle} = await this._deleteBundledDocs(bundleResult)); } else if(!force) { const error = new Error( 'Cannot delete credential; other credentials are bundled by it.'); error.name = 'ConstraintError'; throw error; } } if(!doc) { // no doc found return {deleted: !!bundle, doc, bundle}; } await this.edvClient.delete({doc}); return {deleted: true, doc, bundle}; } catch(e) { if(e.name === 'NotFoundError') { return {deleted: !!bundle, doc, bundle}; } throw e; } } // get all docs involved in a bundle async _getBundle({id} = {}) { // first, recursively load docs in bundles const bundle = {id, contents: []}; const retrievedDocs = new Map(); const bundles = new Map([[id, bundle]]); let bundleIds = [id]; while(bundleIds.length > 0) { /* Note: Since this is an asynchronous and partitioned system, it is possible for two different EDV documents to be returned that have the same `content.id`. This does not mean that both documents had the same value in the database at the *same time* -- as uniqueness contraints prevents this. Rather, multiple queries for `content.id === X` run at different times may produce different document results if the documents containing those values are changing *concurrently*. For example, an operation to delete a credential bundle that contains `X` could be run concurrently with another operation to insert a bundle that contains `X` and yet another operation to delete a bundle containing `X`, etc. Under these conditions, the database may reach a less than ideal state, but it should be recoverable by finding and deleting the top-level bundle(s) connecting VCs together and finding and deleting any orphaned, dependent VCs. Code to do this automatically is not provided in the current version of this module. */ // load all bundled docs with a single query const query = bundleIds.map(id => ({bundledBy: id})); const {documents: docs} = await this.find({query}); bundleIds = []; for(const doc of docs) { // skip root bundle VC and any already retrieved docs if(doc.content.id === id || retrievedDocs.has(doc.id)) { continue; } // mark doc as retrieved retrievedDocs.set(doc.id, doc); // if the document is not itself a bundle, continue if(!(doc.content.id && doc.meta.bundle)) { continue; } // if a sub-bundle has already been created, continue if(bundles.has(doc.content.id)) { continue; } // add empty bundle and add bundle ID to fetch its contents bundles.set(doc.content.id, {id: doc.content.id, contents: []}); bundleIds.push(doc.content.id); } } // second, now that all docs have been fetched, populate all bundles with // document references const allSubDocuments = [...retrievedDocs.values()]; for(const doc of allSubDocuments) { // build reference to this document and any sub-bundle const ref = {doc}; if(doc.content.id && doc.meta.bundle) { ref.bundle = bundles.get(doc.content.id); } // for every bundle in which this doc appears, add the reference const {meta: {bundledBy = []}} = doc; for(const bundleId of bundledBy) { const bundle = bundles.get(bundleId); // it is possible that the bundling VC is not part of the current // bundle (or erroneously does not exist in the database) if(bundle) { bundle.contents.push(ref); } } } return {bundle, allSubDocuments}; } // delete or unlink bundled docs from the given bundle async _deleteBundledDocs({bundle} = {}) { // recursively iterate through bundle contents, stopping recursion at any // independent VCs let next = [bundle]; const operationMap = new Map(); const processedBundles = new Set(); const bundleOps = new Set(); const nonBundleOps = new Set(); while(next.length > 0) { const current = next; next = []; for(const {id: bundleId, contents} of current) { processedBundles.add(bundleId); for(const {doc, bundle: subBundle} of contents) { // unlink `bundleId` from doc const set = new Set(doc.meta.bundledBy); set.delete(bundleId); if(set.size > 0) { // other VCs bundle this doc, so do not recurse into any // sub-bundles doc.meta.bundledBy = [...set]; } else { // doc no longer bundled by any other VCs delete doc.meta.bundledBy; } // create op to update doc let op = operationMap.get(doc); if(!op) { operationMap.set(doc, op = { doc, type: 'update', before: new Set([bundleId]) }); // filter op into bundle / non-bundle ops set if(doc.meta.bundle) { bundleOps.add(op); } else { nonBundleOps.add(op); } } else { op.before.add(bundleId); } // if doc is either bundled by another VC or is not a dependent, // do not mark for deletion and do not recurse; note: an independent // VC is not to be deleted when all VCs that bundled it are deleted if(set.size > 0 || !doc.meta.dependent) { continue; } // mark doc as to be deleted instead of updated op.type = 'delete'; // VC is dependent; if it has been processed yet and has a // sub-bundle, recurse if(subBundle && !processedBundles.has(doc.content.id)) { next.push(subBundle); } } } } // process all non-bundle ops in parallel try { await this._runOps({ops: [...nonBundleOps]}); } catch(e) { if(e.name !== 'AggregateError') { throw e; } // if all of the errors are `InvalidStateError`, throw the first one if(e.errors.every(({name}) => name === 'InvalidStateError')) { throw e.errors[0]; } // some errors were not invalid state, so throw the aggregate error // to stop the deletion process throw e; } /* Note: This could be further optimized by sorting bundle VCs into groups that can be deleted in parallel. A new version could add this optimization in the future. */ // determine bundle deletion order const sorted = [...bundleOps].sort((a, b) => { // if `a` is before `b`, it comes first and vice versa if(a.before.has(b.doc.content.id)) { return -1; } if(b.before.has(a.doc.content.id)) { return 1; } return 0; }); // run bundle ops in serial await this._runOps({ops: sorted, concurrency: 1, stopOnError: true}); return {bundle}; } async _convertQueryByExample({credentialQuery} = {}) { assert.objectOrArrayOfObjects(credentialQuery, 'credentialQuery'); // normalize query to be an array const query = Array.isArray(credentialQuery) ? credentialQuery : [credentialQuery]; // build local queries const queries = []; for(const q of query) { const {example, trustedIssuer = []} = q; assert.object(example, 'credentialQuery.example'); const {type} = example; if(!(Array.isArray(type) || typeof type === 'string')) { throw new Error( '"credentialQuery.example" without "type" is not supported.'); } // normalize to arrays const trustedIssuers = Array.isArray(trustedIssuer) ? trustedIssuer : [trustedIssuer]; const types = Array.isArray(type) ? type : [type]; // build query to find all VCs that match any combination of type+issuer const query = []; // get issuer IDs const issuers = trustedIssuers.map(({id}) => { if(id) { return id; } // future version could do internal queries to find trusted issuer IDs // and return additional information along with `queries` below const error = new Error( '"credentialQuery.trustedIssuer" without an "id" is not supported.'); error.name = 'NotSupportedError'; throw error; }); for(const type of types) { if(issuers.length === 0) { query.push({type}); continue; } for(const issuer of issuers) { query.push({type, issuer}); } } queries.push(query); } return {queries}; } async _runOps({ops, concurrency = OPS_CONCURRENCY, stopOnError = false}) { // try to run all operations to completion const actions = []; for(const op of ops) { const {doc} = op; if(op.type === 'update') { actions.push(() => this.edvClient.update({doc})); } else if(op.type === 'delete') { actions.push(() => this.edvClient.delete({doc})); } else { throw new Error(`Invalid operation type "${op.type}".`); } } await pAll(actions, {concurrency, stopOnError}); } } // called when bundle contents are being upserted; must ensure that the bundle // IDs from `meta.bundledBy` are added export function defaultMutator({doc, credential, meta}) { // if credential is different, log a duplicate error, but do not overwrite; // preserve the original content // note: if a VC was concurrently deleted, this code path should not be hit // because it would trigger a conflict error and a retry -- which would not // find the same doc again by `content.id` if the VC was still deleted during // the retry if(canonicalize(doc.content) !== canonicalize(credential)) { console.error( `Credential ID "${credential.id}" is a duplicate and the previously ` + 'stored credential does not match.', { old: doc.content, new: credential }); /*const error = new Error('Duplicate credential.'); error.name = 'DuplicateError'; error.credentialId = credential.id; throw error;*/ } // only update `doc.meta`, not `doc.content` const newMeta = {...doc.meta}; for(const key in meta) { // skip certain meta keys for special handling if(key === 'bundledBy' || key === 'dependent') { continue; } newMeta[key] = meta[key]; } // union `bundledBy` if(meta.bundledBy) { newMeta.bundledBy = _union(doc.meta.bundledBy, meta.bundledBy); } // only use the new `dependent` value if it is explicitly set to false; we // leave an existing value alone in the `true` case to preserve previously // set independence if(meta.dependent === false) { delete newMeta.dependent; } doc.meta = newMeta; return doc; } function _getIssuer({credential}) { const {issuer} = credential; if(!issuer) { throw new Error('"credential.issuer" must be an object or string.'); } if(!(typeof issuer === 'string' || typeof issuer.id === 'string')) { throw new Error( '"credential.issuer" must be a URI or an object containing an ' + '"id" property.'); } return issuer.id || issuer; } function _union(a1, a2) { if(!a1 || !a2) { return a1 || a2; } const s = new Set(a1); a2.forEach(s.add, s); return [...s]; }