UNPKG

@arcblock/vc

Version:

Javascript lib to work with ArcBlock Verifiable Credentials

342 lines (289 loc) 10.5 kB
/** * @fileOverview Utility functions to create/verify vc * * @module @arcblock/vc * @requires @arcblock/did * @requires @ocap/util */ const isAbsoluteUrl = require('is-absolute-url'); const stringify = require('json-stable-stringify'); const cloneDeep = require('lodash/cloneDeep'); const { types } = require('@ocap/mcrypto'); const { fromPublicKey } = require('@ocap/wallet'); const { toTypeInfo, isValid, isFromPublicKey, fromPublicKeyHash } = require('@arcblock/did'); const { toBase58, toBase64, fromBase64, fromBase58 } = require('@ocap/util'); // eslint-disable-next-line const debug = require('debug')(require('../package.json').name); const proofTypes = { [types.KeyType.ED25519]: 'Ed25519Signature', [types.KeyType.SECP256K1]: 'Secp256k1Signature', [types.KeyType.ETHEREUM]: 'EthereumSignature', }; /** * Create a valid verifiable credential * * @param {object} params * @param {string} params.type - The type of credential * @param {object} params.subject - The content of credential * @param {object} params.issuer - The issuer name and wallet * @param {Date} params.issuanceDate * @param {Date} params.expirationDate * @param {String} params.endpoint - Status endpoint url * @param {String} params.endpointScope - Endpoint scope, either be public or private * @returns {Promise<object>} */ async function create({ type, subject, issuer, issuanceDate, expirationDate, tag = '', endpoint = '', endpointScope = 'public', }) { if (!type) { throw new Error('Can not create verifiable credential without empty type'); } if (!subject) { throw new Error('Can not create verifiable credential from empty subject'); } // Should have an owner if (!subject.id) { throw new Error('Can not create verifiable credential without holder'); } if (!isValid(subject.id)) { throw new Error('Can not create verifiable credential invalid holder did'); } if (endpoint && isAbsoluteUrl(endpoint) === false) { throw new Error('VC Endpoint must be absolute url'); } if (endpointScope && ['public', 'private'].includes(endpointScope) === false) { throw new Error('VC Endpoint scope must be either public or private'); } const { wallet, name: issuerName } = issuer; const issuerDid = wallet.address; const typeInfo = toTypeInfo(issuerDid); // The { pk, hash } type should be same as issuer, role type must be `ROLE_VC` const vcType = { ...typeInfo, role: types.RoleType.ROLE_VC }; const vcDid = fromPublicKeyHash(wallet.hash(stringify(subject)), vcType); // eslint-disable-next-line no-param-reassign issuanceDate = issuanceDate || new Date().toISOString(); const vcObj = { '@context': 'https://schema.arcblock.io/v0.1/context.jsonld', id: vcDid, type, issuer: { id: issuerDid, pk: toBase58(wallet.publicKey), name: issuerName || issuerDid, }, issuanceDate, expirationDate, credentialSubject: subject, }; if (tag) { vcObj.tag = tag; } if (endpoint) { vcObj.credentialStatus = { id: endpoint, type: 'NFTStatusList2021', scope: endpointScope || 'public', }; } if (!proofTypes[typeInfo.pk]) { throw new Error('Unsupported signer type when create verifiable credential'); } const signature = wallet.sign(stringify(vcObj)); const proof = { type: proofTypes[typeInfo.pk], created: issuanceDate, proofPurpose: 'assertionMethod', jws: toBase64(signature), }; // NOTE: we should be able to verify the vc before return const result = { proof, ...vcObj }; debug('create', result); if (await verify({ vc: result, ownerDid: subject.id, trustedIssuers: [issuerDid] })) { return result; } return null; } /** * Verify that the verifiable credential is valid * - It is signed by a whitelist of issuers * - It is owned by the vc.subject.id * - It has valid signature by the issuer * - It is not expired * * @param {object} vc - the verifiable credential object * @param {string} ownerDid - vc holder/owner did * @param {Array} trustedIssuers - list of issuer did * @throws {Error} * @returns {Promise<boolean>} */ async function verify({ vc, ownerDid, trustedIssuers, ignoreExpired = false }) { // Integrity check if (!vc) { throw new Error('Empty verifiable credential object'); } if (!vc.issuer || !vc.issuer.id || !vc.issuer.pk || !isValid(vc.issuer.id)) { throw new Error('Invalid verifiable credential issuer'); } if (!vc.credentialSubject || !vc.credentialSubject.id || !isValid(vc.credentialSubject.id)) { throw new Error('Invalid verifiable credential subject'); } if (!vc.proof || !vc.proof.jws) { throw new Error('Invalid verifiable credential proof'); } // Verify dates if (vc.issuanceDate === undefined) { throw Error('Invalid verifiable credential issue date'); } if (new Date(vc.issuanceDate).getTime() > Date.now()) { throw Error('Verifiable credential has not take effect'); } if (!ignoreExpired && vc.expirationDate !== undefined && new Date(vc.expirationDate).getTime() < Date.now()) { throw Error('Verifiable credential has expired'); } // Verify issuer const issuers = Array.isArray(trustedIssuers) ? trustedIssuers : [trustedIssuers]; const issuerDid = issuers.find((x) => x === vc.issuer.id); if (!issuerDid) { throw new Error('Verifiable credential not issued by trusted issuers'); } if (!isFromPublicKey(issuerDid, vc.issuer.pk)) { throw new Error('Verifiable credential not issuer pk not match with issuer did'); } // Verify owner if (ownerDid !== vc.credentialSubject.id) { throw new Error('Verifiable credential not owned by specified owner did'); } // Construct the issuer wallet const issuer = fromPublicKey(vc.issuer.pk, toTypeInfo(issuerDid)); // NOTE: we are ignoring other fields of the proof const clone = cloneDeep(vc); const signature = clone.proof.jws; delete clone.proof; delete clone.signature; // Verify signature if ((await issuer.verify(stringify(clone), fromBase64(signature))) !== true) { throw Error('Verifiable credential signature not valid'); } // TODO: support verify revoked from endpoint return true; } /** * Verify that the Presentation is valid * - It is signed by VC's owner * - It contain challenge * - It has valid signature by the issuer * - It is not expired * * @param {object} presentation - the presentation object * @param {Array} trustedIssuers - list of issuer did * @param {String} challenge - Random byte you want * @throws {Error} * @returns {Promise<boolean>} */ async function verifyPresentation({ presentation, trustedIssuers, challenge, ignoreExpired = false }) { if (!presentation.challenge || challenge !== presentation.challenge) { throw Error('Invalid challenge included on vc presentation'); } const vcList = Array.isArray(presentation.verifiableCredential) ? presentation.verifiableCredential : [presentation.verifiableCredential]; const proofList = Array.isArray(presentation.proof) ? presentation.proof : [presentation.proof]; const clone = cloneDeep(presentation); delete clone.proof; await Promise.all( vcList.map(async (vcStr) => { const vcObj = JSON.parse(vcStr); const proof = proofList.find((x) => isFromPublicKey(vcObj.credentialSubject.id, x.pk)); if (!proof) { throw Error(`VC does not have corresponding proof: ${vcStr}`); } const signature = proof.jws; const holder = fromPublicKey(fromBase58(proof.pk), toTypeInfo(vcObj.credentialSubject.id)); if ((await holder.verify(stringify(clone), fromBase64(signature))) !== true) { throw Error('Presentation signature invalid'); } await verify({ vc: vcObj, ownerDid: vcObj.credentialSubject.id, trustedIssuers, ignoreExpired }); }) ); return true; } function createCredentialList({ claims, issuer, issuanceDate }) { if (!claims || !Array.isArray(claims)) { throw new Error('Can not create credential list with empty claim list'); } if (!issuer || !issuer.wallet || !issuer.name) { throw new Error('Can not create credential list with empty issuer name or wallet'); } if (typeof issuer.wallet.sign !== 'function') { throw new Error('Can not create credential list with invalid issuer wallet'); } const { wallet, name } = issuer; const issuerDid = wallet.address; const typeInfo = toTypeInfo(issuerDid); const vcType = { ...typeInfo, role: types.RoleType.ROLE_VC }; const issued = issuanceDate || new Date().toISOString(); return claims.map((x) => { const vc = { claim: x }; vc.id = fromPublicKeyHash(wallet.hash(stringify(vc.claim)), vcType); vc.issued = issued; vc.issuer = { id: issuerDid, pk: toBase58(wallet.publicKey), name: name || issuerDid, }; const signature = wallet.sign(stringify(vc)); vc.proof = { type: proofTypes[typeInfo.pk], created: issued, proofPurpose: 'assertionMethod', jws: toBase64(signature), }; return vc; }); } // eslint-disable-next-line require-await async function verifyCredentialList({ credentials, trustedIssuers }) { if (!credentials || !Array.isArray(credentials)) { throw new Error('Can not verify with empty credentials list'); } return Promise.all( credentials.map(async (x) => { // Verify issuer const issuers = Array.isArray(trustedIssuers) ? trustedIssuers : [trustedIssuers]; const issuerDid = issuers.find((d) => d === x.issuer.id); if (!issuerDid) { throw new Error('Credential not issued by trusted issuers'); } if (!isFromPublicKey(issuerDid, x.issuer.pk)) { throw new Error('Credential not issuer pk not match with issuer did'); } // Construct the issuer wallet const issuer = fromPublicKey(x.issuer.pk, toTypeInfo(issuerDid)); // NOTE: we are ignoring other fields of the proof const clone = cloneDeep(x); const signature = clone.proof.jws; delete clone.proof; // Verify signature if ((await issuer.verify(stringify(clone), fromBase64(signature))) !== true) { throw Error('Status credential signature not valid'); } return x.claim; }) ); } module.exports = { create, verify, verifyPresentation, stableStringify: stringify, proofTypes, createCredentialList, verifyCredentialList, };