/** * A JavaScript implementation of Verifiable Credentials. * * @author Dave Longley * @author David I. Lehn * * @license BSD 3-Clause License * Copyright (c) 2017-2023 Digital Bazaar, Inc. * All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions are met: * * Redistributions of source code must retain the above copyright notice, * this list of conditions and the following disclaimer. * * Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * Neither the name of the Digital Bazaar, Inc. nor the names of its * contributors may be used to endorse or promote products derived from * this software without specific prior written permission. * * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS * IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED * TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A * PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED * TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR * PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF * LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING * NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ import { assertCredentialContext, assertDateString, checkContextVersion, compareTime, getContextForVersion } from './helpers.js'; import {documentLoader as _documentLoader} from './documentLoader.js'; import {CredentialIssuancePurpose} from './CredentialIssuancePurpose.js'; import jsigs from 'jsonld-signatures'; import jsonld from 'jsonld'; const {AssertionProofPurpose, AuthenticationProofPurpose} = jsigs.purposes; export {dateRegex} from './helpers.js'; export const defaultDocumentLoader = jsigs.extendContextLoader(_documentLoader); export {CredentialIssuancePurpose}; /** * @typedef {object} LinkedDataSignature */ /** * @typedef {object} Presentation */ /** * @typedef {object} ProofPurpose */ /** * @typedef {object} VerifiableCredential */ /** * @typedef {object} VerifiablePresentation */ /** * @typedef {object} VerifyPresentationResult * @property {boolean} verified - True if verified, false if not. * @property {object} presentationResult * @property {Array} credentialResults * @property {object} error */ /** * @typedef {object} VerifyCredentialResult * @property {boolean} verified - True if verified, false if not. * @property {object} statusResult * @property {Array} results * @property {object} error */ /** * Issues a verifiable credential (by taking a base credential document, * and adding a digital signature to it). * * @param {object} [options={}] - The options to use. * * @param {object} options.credential - Base credential document. * @param {LinkedDataSignature} options.suite - Signature suite (with private * key material or an API to use it), passed in to sign(). * * @param {ProofPurpose} [options.purpose] - A ProofPurpose. If not specified, * a default purpose will be created. * * Other optional params passed to `sign()`: * @param {object} [options.documentLoader] - A document loader. * @param {string|Date} [options.now] - A string representing date time in * ISO 8601 format or an instance of Date. Defaults to current date time. * @param {number} [options.maxClockSkew=300] - A maximum number of seconds * that clocks may be skewed when checking capability expiration date-times * against `date` and when comparing invocation proof creation time against * delegation proof creation time. * * @throws {Error} If missing required properties. * * @returns {Promise<VerifiableCredential>} Resolves on completion. */ export async function issue({ credential, suite, purpose = new CredentialIssuancePurpose(), documentLoader = defaultDocumentLoader, now, maxClockSkew = 300 } = {}) { // check to make sure the `suite` has required params // Note: verificationMethod defaults to publicKey.id, in suite constructor if(!suite) { throw new TypeError('"suite" parameter is required for issuing.'); } if(!suite.verificationMethod) { throw new TypeError('"suite.verificationMethod" property is required.'); } if(!credential) { throw new TypeError('"credential" parameter is required for issuing.'); } if(checkContextVersion({ credential, version: 1.0 }) && !credential.issuanceDate) { const now = (new Date()).toJSON(); credential.issuanceDate = `${now.slice(0, now.length - 5)}Z`; } // run common credential checks _checkCredential({credential, now, mode: 'issue', maxClockSkew}); return jsigs.sign(credential, {purpose, documentLoader, suite}); } /** * Derives a proof from the given verifiable credential, resulting in a new * verifiable credential. This method is usually used to generate selective * disclosure and / or unlinkable proofs. * * @param {object} [options={}] - The options to use. * * @param {object} options.verifiableCredential - The verifiable credential * containing a base proof to derive another proof from. * @param {LinkedDataSignature} options.suite - Derived proof signature suite. * * Other optional params passed to `derive()`: * @param {object} [options.documentLoader] - A document loader. * * @throws {Error} If missing required properties. * * @returns {Promise<VerifiableCredential>} Resolves on completion. */ export async function derive({ verifiableCredential, suite, documentLoader = defaultDocumentLoader } = {}) { if(!verifiableCredential) { throw new TypeError( '"verifiableCredential" parameter is required for deriving.'); } if(!suite) { throw new TypeError('"suite" parameter is required for deriving.'); } // run common credential checks _checkCredential({credential: verifiableCredential, mode: 'issue'}); return jsigs.derive(verifiableCredential, { purpose: new AssertionProofPurpose(), documentLoader, suite }); } /** * Verifies a verifiable presentation: * - Checks that the presentation is well-formed * - Checks the proofs (for example, checks digital signatures against the * provided public keys). * * @param {object} [options={}] - The options to use. * * @param {VerifiablePresentation} options.presentation - Verifiable * presentation, signed or unsigned, that may contain within it a * verifiable credential. * * @param {LinkedDataSignature|LinkedDataSignature[]} options.suite - One or * more signature suites that are supported by the caller's use case. This is * an explicit design decision -- the calling code must specify which * signature types (ed25519, RSA, etc) are allowed. * Although it is expected that the secure resolution/fetching of the public * key material (to verify against) is to be handled by the documentLoader, * the suite param can optionally include the key directly. * * @param {boolean} [options.unsignedPresentation=false] - By default, this * function assumes that a presentation is signed (and will return an error if * a `proof` section is missing). Set this to `true` if you're using an * unsigned presentation. * * Either pass in a proof purpose, * @param {AuthenticationProofPurpose} [options.presentationPurpose] - Optional * proof purpose (a default one will be created if not passed in). * * or a default purpose will be created with params: * @param {string} [options.challenge] - Required if purpose is not passed in. * @param {string} [options.controller] - A controller. * @param {string} [options.domain] - A domain. * * @param {Function} [options.documentLoader] - A document loader. * @param {Function} [options.checkStatus] - Optional function for checking * credential status if `credentialStatus` is present on the credential. * @param {string|Date} [options.now] - A string representing date time in * ISO 8601 format or an instance of Date. Defaults to current date time. * @param {number} [options.maxClockSkew=300] - A maximum number of seconds * that clocks may be skewed when checking capability expiration date-times * against `date` and when comparing invocation proof creation time against * delegation proof creation time. * * @returns {Promise<VerifyPresentationResult>} The verification result. */ export async function verify(options = {}) { const {presentation} = options; try { if(!presentation) { throw new TypeError( 'A "presentation" property is required for verifying.'); } return _verifyPresentation(options); } catch(error) { return { verified: false, results: [{presentation, verified: false, error}], error }; } } /** * Verifies a verifiable credential: * - Checks that the credential is well-formed * - Checks the proofs (for example, checks digital signatures against the * provided public keys). * * @param {object} [options={}] - The options. * * @param {object} options.credential - Verifiable credential. * * @param {LinkedDataSignature|LinkedDataSignature[]} options.suite - One or * more signature suites that are supported by the caller's use case. This is * an explicit design decision -- the calling code must specify which * signature types (ed25519, RSA, etc) are allowed. * Although it is expected that the secure resolution/fetching of the public * key material (to verify against) is to be handled by the documentLoader, * the suite param can optionally include the key directly. * * @param {CredentialIssuancePurpose} [options.purpose] - Optional * proof purpose (a default one will be created if not passed in). * @param {Function} [options.documentLoader] - A document loader. * @param {Function} [options.checkStatus] - Optional function for checking * credential status if `credentialStatus` is present on the credential. * @param {string|Date} [options.now] - A string representing date time in * ISO 8601 format or an instance of Date. Defaults to current date time. * @param {number} [options.maxClockSkew=300] - A maximum number of seconds * that clocks may be skewed when checking capability expiration date-times * against `date` and when comparing invocation proof creation time against * delegation proof creation time. * * @returns {Promise<VerifyCredentialResult>} The verification result. */ export async function verifyCredential(options = {}) { const {credential} = options; try { if(!credential) { throw new TypeError( 'A "credential" property is required for verifying.'); } return await _verifyCredential(options); } catch(error) { return { verified: false, results: [{credential, verified: false, error}], error }; } } /** * Verifies a verifiable credential. * * @private * @param {object} [options={}] - The options. * * @param {object} options.credential - Verifiable credential. * @param {LinkedDataSignature|LinkedDataSignature[]} options.suite - See the * definition in the `verify()` docstring, for this param. * @param {string|Date} [options.now] - A string representing date time in * ISO 8601 format or an instance of Date. Defaults to current date time. * @param {number} [options.maxClockSkew=300] - A maximum number of seconds * that clocks may be skewed when checking capability expiration date-times * against `date` and when comparing invocation proof creation time against * delegation proof creation time. * * @throws {Error} If required parameters are missing (in `_checkCredential`). * * @param {CredentialIssuancePurpose} [options.purpose] - A purpose. * @param {Function} [options.documentLoader] - A document loader. * @param {Function} [options.checkStatus] - Optional function for checking * credential status if `credentialStatus` is present on the credential. * * @returns {Promise<VerifyCredentialResult>} The verification result. */ async function _verifyCredential(options = {}) { const {credential, checkStatus, now, maxClockSkew} = options; // run common credential checks _checkCredential({credential, now, maxClockSkew}); // if credential status is provided, a `checkStatus` function must be given if(credential.credentialStatus && typeof options.checkStatus !== 'function') { throw new TypeError( 'A "checkStatus" function must be given to verify credentials with ' + '"credentialStatus".'); } const documentLoader = options.documentLoader || defaultDocumentLoader; const {controller} = options; const purpose = options.purpose || new CredentialIssuancePurpose({ controller }); const result = await jsigs.verify( credential, {...options, purpose, documentLoader}); // if verification has already failed, skip status check if(!result.verified) { return result; } if(credential.credentialStatus) { result.statusResult = await checkStatus(options); if(!result.statusResult.verified) { result.verified = false; } } return result; } /** * Creates an unsigned presentation from a given verifiable credential. * * @param {object} options - Options to use. * @param {object|Array<object>} [options.verifiableCredential] - One or more * verifiable credential. * @param {string} [options.id] - Optional VP id. * @param {string} [options.holder] - Optional presentation holder url. * @param {string|Date} [options.now] - A string representing date time in * ISO 8601 format or an instance of Date. Defaults to current date time. * @param {number} [options.maxClockSkew=300] - A maximum number of seconds * that clocks may be skewed when checking capability expiration date-times * against `date` and when comparing invocation proof creation time against * delegation proof creation time. * @param {number} [options.version = 2.0] - The VC context version to use. * * @throws {TypeError} If verifiableCredential param is missing. * @throws {Error} If the credential (or the presentation params) are missing * required properties. * * @returns {Presentation} The credential wrapped inside of a * VerifiablePresentation. */ export function createPresentation({ verifiableCredential, id, holder, now, version = 2.0, maxClockSkew = 300 } = {}) { const initialContext = getContextForVersion({version}); const presentation = { '@context': [initialContext], type: ['VerifiablePresentation'] }; if(verifiableCredential) { const credentials = [].concat(verifiableCredential); // ensure all credentials are valid for(const credential of credentials) { _checkCredential({credential, now, maxClockSkew}); } presentation.verifiableCredential = credentials; } if(id) { presentation.id = id; } if(holder) { presentation.holder = holder; } _checkPresentation(presentation); return presentation; } /** * Signs a given presentation. * * @param {object} [options={}] - Options to use. * * Required: * @param {Presentation} options.presentation - A presentation. * @param {LinkedDataSignature} options.suite - passed in to sign() * * Either pass in a ProofPurpose, or a default one will be created with params: * @param {ProofPurpose} [options.purpose] - A ProofPurpose. If not specified, * a default purpose will be created with the domain and challenge options. * * @param {string} [options.domain] - A domain. * @param {string} options.challenge - A required challenge. * * @param {Function} [options.documentLoader] - A document loader. * * @returns {Promise<{VerifiablePresentation}>} A VerifiablePresentation with * a proof. */ export async function signPresentation(options = {}) { const {presentation, domain, challenge} = options; const purpose = options.purpose || new AuthenticationProofPurpose({ domain, challenge }); const documentLoader = options.documentLoader || defaultDocumentLoader; return jsigs.sign(presentation, {...options, purpose, documentLoader}); } /** * Verifies that the VerifiablePresentation is well formed, and checks the * proof signature if it's present. Also verifies all the VerifiableCredentials * that are present in the presentation, if any. * * @param {object} [options={}] - The options. * @param {VerifiablePresentation} options.presentation - A * VerifiablePresentation. * * @param {LinkedDataSignature|LinkedDataSignature[]} options.suite - See the * definition in the `verify()` docstring, for this param. * * @param {boolean} [options.unsignedPresentation=false] - By default, this * function assumes that a presentation is signed (and will return an error if * a `proof` section is missing). Set this to `true` if you're using an * unsigned presentation. * * Either pass in a proof purpose, * @param {AuthenticationProofPurpose} [options.presentationPurpose] - A * ProofPurpose. If not specified, a default purpose will be created with * the challenge, controller, and domain options. * * @param {string} [options.challenge] - A challenge. Required if purpose is * not passed in. * @param {string} [options.controller] - A controller. Required if purpose is * not passed in. * @param {string} [options.domain] - A domain. Required if purpose is not * passed in. * * @param {Function} [options.documentLoader] - A document loader. * @param {Function} [options.checkStatus] - Optional function for checking * credential status if `credentialStatus` is present on the credential. * @param {string|Date} [options.now] - A string representing date time in * ISO 8601 format or an instance of Date. Defaults to current date time. * @param {number} [options.maxClockSkew=300] - A maximum number of seconds * that clocks may be skewed when checking capability expiration date-times * against `date` and when comparing invocation proof creation time against * delegation proof creation time. * * @throws {Error} If presentation is missing required params. * * @returns {Promise<VerifyPresentationResult>} The verification result. */ async function _verifyPresentation(options = {}) { const {presentation, unsignedPresentation} = options; _checkPresentation(presentation); const documentLoader = options.documentLoader || defaultDocumentLoader; // FIXME: verify presentation first, then each individual credential // only if that proof is verified // if verifiableCredentials are present, verify them, individually let credentialResults; let verified = true; const credentials = jsonld.getValues(presentation, 'verifiableCredential'); if(credentials.length > 0) { // verify every credential in `verifiableCredential` credentialResults = await Promise.all(credentials.map(credential => { return verifyCredential({...options, credential, documentLoader}); })); for(const [i, credentialResult] of credentialResults.entries()) { credentialResult.credentialId = credentials[i].id; } const allCredentialsVerified = credentialResults.every(r => r.verified); if(!allCredentialsVerified) { verified = false; } } if(unsignedPresentation) { // No need to verify the proof section of this presentation return {verified, results: [presentation], credentialResults}; } const {controller, domain, challenge} = options; if(!options.presentationPurpose && !challenge) { throw new Error( 'A "challenge" param is required for AuthenticationProofPurpose.'); } const purpose = options.presentationPurpose || new AuthenticationProofPurpose({controller, domain, challenge}); const presentationResult = await jsigs.verify( presentation, {...options, purpose, documentLoader}); return { presentationResult, verified: verified && presentationResult.verified, credentialResults, error: presentationResult.error }; } /** * @param {string|object} obj - Either an object with an id property * or a string that is an id. * @returns {string|undefined} Either an id or undefined. * @private */ function _getId(obj) { if(typeof obj === 'string') { return obj; } if(!('id' in obj)) { return; } return obj.id; } // export for testing /** * @param {object} presentation - An object that could be a presentation. * * @throws {Error} * @private */ export function _checkPresentation(presentation) { // normalize to an array to allow the common case of context being a string const context = Array.isArray(presentation['@context']) ? presentation['@context'] : [presentation['@context']]; assertCredentialContext({context}); const types = jsonld.getValues(presentation, 'type'); // check type presence if(!types.includes('VerifiablePresentation')) { throw new Error('"type" must include "VerifiablePresentation".'); } } // these props of a VC must be an object with a type // if present in a VC or VP const mustHaveType = [ 'proof', 'credentialStatus', 'termsOfUse', 'evidence' ]; // export for testing /** * @param {object} options - The options. * @param {object} options.credential - An object that could be a * VerifiableCredential. * @param {string|Date} [options.now] - A string representing date time in * ISO 8601 format or an instance of Date. Defaults to current date time. * @param {number} [options.maxClockSkew=300] - A maximum number of seconds * that clocks may be skewed when checking capability expiration date-times * against `date` and when comparing invocation proof creation time against * delegation proof creation time. * @param {string} [options.mode] - The mode of operation for this * validation function, either `issue` or `verify`. * * @throws {Error} * @private */ export function _checkCredential({ credential, now = new Date(), mode = 'verify', maxClockSkew = 300 } = {}) { if(typeof now === 'string') { now = new Date(now); } assertCredentialContext({context: credential['@context']}); // check type presence and cardinality if(!credential.type) { throw new Error('"type" property is required.'); } if(!jsonld.getValues(credential, 'type').includes('VerifiableCredential')) { throw new Error('"type" must include `VerifiableCredential`.'); } _checkCredentialSubjects({credential}); if(!credential.issuer) { throw new Error('"issuer" property is required.'); } if(checkContextVersion({credential, version: 1.0})) { // check issuanceDate exists if(!credential.issuanceDate) { throw new Error('"issuanceDate" property is required.'); } // check issuanceDate format on issue assertDateString({credential, prop: 'issuanceDate'}); // check issuanceDate cardinality if(jsonld.getValues(credential, 'issuanceDate').length > 1) { throw new Error('"issuanceDate" property can only have one value.'); } // optionally check expirationDate if('expirationDate' in credential) { // check if `expirationDate` property is a date assertDateString({credential, prop: 'expirationDate'}); if(mode === 'verify') { // check if `now` is after `expirationDate` const expirationDate = new Date(credential.expirationDate); if(compareTime({t1: now, t2: expirationDate, maxClockSkew}) > 0) { throw new Error('Credential has expired.'); } } } // check if `now` is before `issuanceDate` on verification if(mode === 'verify') { const issuanceDate = new Date(credential.issuanceDate); if(compareTime({t1: issuanceDate, t2: now, maxClockSkew}) > 0) { throw new Error( `The current date time (${now.toISOString()}) is before the ` + `"issuanceDate" (${credential.issuanceDate}).`); } } } if(checkContextVersion({credential, version: 2.0})) { // check if 'validUntil' and 'validFrom' let {validUntil, validFrom} = credential; if(validUntil) { assertDateString({credential, prop: 'validUntil'}); if(mode === 'verify') { validUntil = new Date(credential.validUntil); if(compareTime({t1: now, t2: validUntil, maxClockSkew}) > 0) { throw new Error( `The current date time (${now.toISOString()}) is after ` + `"validUntil" (${credential.validUntil}).`); } } } if(validFrom) { assertDateString({credential, prop: 'validFrom'}); if(mode === 'verify') { // check if `now` is before `validFrom` validFrom = new Date(credential.validFrom); if(compareTime({t1: validFrom, t2: now, maxClockSkew}) > 0) { throw new Error( `The current date time (${now.toISOString()}) is before ` + `"validFrom" (${credential.validFrom}).`); } } } } // check issuer cardinality if(jsonld.getValues(credential, 'issuer').length > 1) { throw new Error('"issuer" property can only have one value.'); } // check issuer is a URL if('issuer' in credential) { const issuer = _getId(credential.issuer); if(!issuer) { throw new Error(`"issuer" id is required.`); } _validateUriId({id: issuer, propertyName: 'issuer'}); } // check credentialStatus jsonld.getValues(credential, 'credentialStatus').forEach(cs => { // check if optional "id" is a URL if('id' in cs) { _validateUriId({id: cs.id, propertyName: 'credentialStatus.id'}); } // check "type" present if(!cs.type) { throw new Error('"credentialStatus" must include a type.'); } }); // check evidences are URLs jsonld.getValues(credential, 'evidence').forEach(evidence => { const evidenceId = _getId(evidence); if(evidenceId) { _validateUriId({id: evidenceId, propertyName: 'evidence'}); } }); // check if properties that require a type are // defined, objects, and objects with types for(const prop of mustHaveType) { if(prop in credential) { const _value = credential[prop]; if(Array.isArray(_value)) { _value.forEach(entry => _checkTypedObject(entry, prop)); continue; } _checkTypedObject(_value, prop); } } } /** * @private * Checks that a property is non-empty object with * property type. * * @param {object} obj - A potential object. * @param {string} name - The name of the property. * * @throws {Error} if the property is not an object with a type. * * @returns {undefined} - Returns on success. */ function _checkTypedObject(obj, name) { if(!isObject(obj)) { throw new Error(`property "${name}" must be an object.`); } if(_emptyObject(obj)) { throw new Error(`property "${name}" can not be an empty object.`); } if(!('type' in obj)) { throw new Error(`property "${name}" must have property type.`); } } /** * @private * Takes in a credential and checks the credentialSubject(s) * * @param {object} options - Options. * @param {object} options.credential - The credential to check. * * @throws {Error} error - Throws on errors in the credential subject. * * @returns {undefined} - Returns on success. */ function _checkCredentialSubjects({credential}) { if(!credential?.credentialSubject) { throw new Error('"credentialSubject" property is required.'); } if(Array.isArray(credential?.credentialSubject)) { return credential?.credentialSubject.map( subject => _checkCredentialSubject({subject})); } return _checkCredentialSubject({subject: credential?.credentialSubject}); } /** * @private * * Checks a credential subject is valid. * * @param {object} options - Options. * @param {object} options.subject - A potential credential subject. * * @throws {Error} If the credentialSubject is not valid. * * @returns {undefined} Returns on success. */ function _checkCredentialSubject({subject}) { if(isObject(subject) === false) { throw new Error('"credentialSubject" must be a non-null object.'); } if(_emptyObject(subject)) { throw new Error('"credentialSubject" must make a claim.'); } // If credentialSubject.id is present and is not a URI, reject it if(subject.id) { _validateUriId({ id: subject.id, propertyName: 'credentialSubject.id' }); } } /** * @private * Checks if parameter is an object. * * @param {object} obj - A potential object. * * @returns {boolean} - Returns false if not an object or null. */ function isObject(obj) { // return false for null even though it has type object if(obj === null) { return false; } // if something has type object and is not null return true if((typeof obj) === 'object') { return true; } // return false for strings, symbols, etc. return false; } /** * @private * Is it an empty object? * * @param {object} obj - A potential object. * * @returns {boolean} - Is it empty? */ function _emptyObject(obj) { // if the parameter is not an object return true // as a non-object is an empty object if(!isObject(obj)) { return true; } return Object.keys(obj).length === 0; } /** * @private * * Validates if an ID is a URL. * * @param {object} options - Options. * @param {string} options.id - the id. * @param {string} options.propertyName - The property name. * * @throws {Error} Throws if an id is not a URL. * * @returns {undefined} Returns on success. */ function _validateUriId({id, propertyName}) { let parsed; try { parsed = new URL(id); } catch(e) { const error = new TypeError(`"${propertyName}" must be a URI: "${id}".`); error.cause = e; throw error; } if(!parsed.protocol) { throw new TypeError(`"${propertyName}" must be a URI: "${id}".`); } }