@arcblock/vc
Version:
Javascript lib to work with ArcBlock Verifiable Credentials
342 lines (289 loc) • 10.5 kB
JavaScript
/**
* @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,
};