@jeswr/vc-cli
Version:
A CLI tool for creating and managing Verifiable Credentials
461 lines (405 loc) • 15.3 kB
JavaScript
import * as bbs2023Cryptosuite from '@digitalbazaar/bbs-2023-cryptosuite';
import * as Bls12381Multikey from '@digitalbazaar/bls12-381-multikey';
import { DataIntegrityProof } from '@digitalbazaar/data-integrity';
import { Ed25519Signature2020 } from '@digitalbazaar/ed25519-signature-2020';
import { Ed25519VerificationKey2020 } from '@digitalbazaar/ed25519-verification-key-2020';
import * as vc from '@digitalbazaar/vc';
import { write } from '@jeswr/pretty-turtle';
import jsigs from 'jsonld-signatures';
import { DataFactory } from 'n3';
import { createHash } from 'node:crypto';
import fs from 'node:fs/promises';
import { URL } from 'node:url';
import dereference from 'rdf-dereference-store';
import { generateCID } from './cid.js';
import { createDocumentLoader } from './documentLoader.js';
import { _createVerifyData } from './lib/verify.js';
export { documentLoader, createDocumentLoader } from './documentLoader.js';
const {
createSignCryptosuite,
createVerifyCryptosuite,
createDiscloseCryptosuite,
} = bbs2023Cryptosuite;
const { purposes: { AssertionProofPurpose } } = jsigs;
// Helper function to sanitize URL by removing fragment
const sanitizeUrl = (url) => {
try {
const parsedUrl = new URL(url);
parsedUrl.hash = '';
return parsedUrl.toString();
} catch (e) {
return url;
}
};
async function sha256digest({string}) {
return new Uint8Array(
createHash('sha256').update(string).digest()
);
}
const concat = (b1, b2) => {
const rval = new Uint8Array(b1.length + b2.length);
rval.set(b1, 0);
rval.set(b2, b1.length);
return rval;
}
// TODO: This is a hack to get the verification method from the CID document
// I think this is actually an upstream bug that should be reported
class MyDataIntegrityProof extends DataIntegrityProof {
async getVerificationMethod({ proof, documentLoader }) {
let verificationMethod = await super.getVerificationMethod({ proof, documentLoader });
if (typeof verificationMethod === 'object' && verificationMethod.type !== 'Multikey' && 'verificationMethod' in verificationMethod && verificationMethod.verificationMethod.some(vm => vm.id === proof.verificationMethod)) {
verificationMethod = verificationMethod.verificationMethod.find(vm => vm.id === proof.verificationMethod);
}
return verificationMethod;
}
}
function getVerificationMethod(cid, document) {
const verificationMethod = cid.verificationMethod.find(vm => vm.id === document.proof.verificationMethod);
if (!verificationMethod) {
throw new Error(`Verification method ${document.proof.verificationMethod} not found in CID document`);
}
return verificationMethod;
}
function cidDocumentLoader(cid, documentLoaderContent = {}) {
const loader = createDocumentLoader(documentLoaderContent);
return async (url) => {
const sanitizedUrl = sanitizeUrl(url);
// If the URL matches the CID document's ID, return the CID document
if (sanitizedUrl === cid.id) {
return {
contextUrl: null,
document: cid,
documentUrl: sanitizedUrl
};
}
// Otherwise use the provided document loader
return loader(sanitizedUrl);
};
}
/**
* Generate a new CID document
* @param {string} controller - Controller DID
* @param {Object} options - Options for CID generation
* @param {boolean} [options.includeEd25519=true] - Whether to include Ed25519 signature type
* @param {boolean} [options.includeBBS=true] - Whether to include BBS+ signature type
* @returns {Promise<{cid: Object, privateKeys: Object}>} The generated CID document and private keys
*/
export async function generateCIDDocument(controller, options = {}) {
const { cid, privateKeys } = await generateCID(controller, {
includeEd25519: options.includeEd25519 !== false,
includeBBS: options.includeBBS !== false
});
return { cid, privateKeys };
}
/**
* Sign a verifiable credential using a CID document and private keys
* @param {Object} options - Options for signing
* @param {string} options.cid - CID document
* @param {Object} options.privateKeys - Private keys object
* @param {Object} options.document - Document to sign
* @param {string} options.keyId - ID of the key to use for signing
* @param {string} [options.credentialId] - ID for the credential (optional)
* @param {string} [options.subjectId] - ID for the credential subject (optional)
* @param {Object} [options.documentLoaderContent] - Document loader content (optional)
* @returns {Promise<Object>} The signed credential
*/
export async function signCredential(options) {
const { cid, privateKeys, document, keyId, credentialId, subjectId, documentLoaderContent } = options;
document.issuer = {
"id": cid.id
};
if (credentialId) {
document.id = credentialId;
}
if (subjectId) {
if (!document.credentialSubject) {
document.credentialSubject = {};
}
document.credentialSubject.id = subjectId;
}
const verificationMethod = cid.verificationMethod.find(vm => vm.id === keyId);
if (!verificationMethod) {
throw new Error(`Key ID ${keyId} not found in CID document`);
}
const privateKey = privateKeys[keyId];
if (!privateKey) {
throw new Error(`Private key for ${keyId} not found`);
}
let suite;
let keyPair;
let signedVC;
if (verificationMethod.publicKeyMultibase.startsWith('zUC7')) {
const algorithm = Bls12381Multikey.ALGORITHMS.BBS_BLS12381_SHA256;
keyPair = await Bls12381Multikey.from({
...verificationMethod,
secretKeyMultibase: privateKey,
}, { algorithm });
const date = new Date().toISOString();
const entryPointers = ['/issuer'];
if (document.validFrom) {
entryPointers.push('/validFrom');
}
if (document.validUntil) {
entryPointers.push('/validUntil');
}
suite = new DataIntegrityProof({
signer: keyPair.signer(),
date,
cryptosuite: createSignCryptosuite({
mandatoryPointers: entryPointers
})
});
try {
signedVC = await jsigs.sign(document, {
suite,
purpose: new AssertionProofPurpose(),
documentLoader: createDocumentLoader(documentLoaderContent)
});
} catch (error) {
throw new Error(`Failed to sign document using BBS Signature: ${error.message} [${JSON.stringify(error, null, 2)}]`);
}
} else {
keyPair = await Ed25519VerificationKey2020.from({
...verificationMethod,
privateKeyMultibase: privateKey
});
suite = new Ed25519Signature2020({
key: keyPair,
verificationMethod: verificationMethod.id
});
try {
signedVC = await vc.issue({
credential: document,
suite,
documentLoader: createDocumentLoader(documentLoaderContent)
});
} catch (error) {
throw new Error(`Failed to sign document using Ed25519 Signature: ${error.message}`);
}
}
return signedVC;
}
/**
* Verify a verifiable credential using a CID document
* @param {Object} options - Options for verification
* @param {Object} options.cid - CID document
* @param {Object} options.document - Verifiable credential to verify
* @param {Object} [options.documentLoaderContent] - Document loader content (optional)
* @returns {Promise<boolean>} Whether the verification was successful
*/
export async function verifyCredential(options) {
const { cid, document, documentLoaderContent } = options;
const documentLoader = cidDocumentLoader(cid, documentLoaderContent);
const verificationMethod = getVerificationMethod(cid, document);
let suite;
let keyPair;
if (verificationMethod.publicKeyMultibase.startsWith('zUC7')) {
keyPair = await Bls12381Multikey.from({
...verificationMethod,
controller: document.issuer.id
});
const cryptosuite = await createVerifyCryptosuite({
mandatoryPointers: ['/issuer']
});
suite = new MyDataIntegrityProof({
verifier: keyPair.verifier(),
cryptosuite,
});
const result = await jsigs.verify(document, {
suite,
purpose: new AssertionProofPurpose({
controller: cid
}),
documentLoader
});
return result.verified;
} else {
keyPair = await Ed25519VerificationKey2020.from({
...verificationMethod,
controller: document.issuer.id
});
suite = new Ed25519Signature2020({
key: keyPair,
verificationMethod: verificationMethod.id
});
const result = await vc.verifyCredential({
credential: document,
suite,
documentLoader,
checkStatus: async (credential) => {
if (!credential.credentialStatus) {
return { verified: true };
}
return { verified: true };
}
});
return result.verified;
}
}
/**
* Create a derived BBS proof from a signed input BBS document
* @param {Object} options - Options for deriving proof
* @param {Object} options.document - Signed BBS document
* @param {string[]} options.revealPointers - Array of JSON pointers to reveal
* @param {Object} [options.documentLoaderContent] - Document loader content (optional)
* @returns {Promise<Object>} The derived document
*/
export async function deriveProof(options) {
const { document, revealPointers, documentLoaderContent } = options;
const cryptosuite = createDiscloseCryptosuite({
selectivePointers: revealPointers
});
const suite = new DataIntegrityProof({ cryptosuite });
const derivedDocument = await jsigs.derive(document, {
suite,
purpose: new AssertionProofPurpose(),
documentLoader: createDocumentLoader(documentLoaderContent)
});
return derivedDocument;
}
/**
* Preprocess BBS verification data from derived credentials
* @param {Object} options - Options for preprocessing
* @param {Object} options.document - Derived BBS document
* @param {Object} options.cid - CID document
* @param {Object} [options.documentLoaderContent] - Document loader content (optional)
* @returns {Promise<Object>} The preprocessed data
*/
export async function preprocessBBSVerification(options) {
const { document, cid, documentLoaderContent } = options;
const verificationMethod = getVerificationMethod(cid, document);
const verifyData = await _createVerifyData({
document,
documentLoader: cidDocumentLoader(cid, documentLoaderContent),
});
const keyPair = await Bls12381Multikey.from({
...verificationMethod,
controller: document.issuer.id
});
const cryptosuite = await createVerifyCryptosuite({
mandatoryPointers: ['/issuer']
});
const suite = new MyDataIntegrityProof({
verifier: keyPair.verifier(),
cryptosuite,
});
const method = await suite.getVerificationMethod({
proof: document.proof,
documentLoader: cidDocumentLoader(cid, documentLoaderContent),
});
return {
verifyData: {
...verifyData,
bbsProof: Buffer.from(verifyData.bbsProof).toString('base64'),
proofHash: Buffer.from(verifyData.proofHash).toString('base64'),
mandatoryHash: Buffer.from(verifyData.mandatoryHash).toString('base64'),
},
verificationMethod: method,
proof: document.proof
};
}
/**
* Preprocess Ed25519 verification data from signed credentials
* @param {Object} options - Options for preprocessing
* @param {Object} options.document - Signed Ed25519 document
* @param {Object} options.cid - CID document
* @param {Object} [options.documentLoaderContent] - Document loader content (optional)
* @returns {Promise<Object>} The preprocessed data
*/
export async function preprocessEd25519Verification(options) {
const { document, cid, documentLoaderContent } = options;
const verificationMethod = getVerificationMethod(cid, document);
const keyPair = await Ed25519VerificationKey2020.from({
...verificationMethod,
controller: document.issuer.id
});
const suite = new Ed25519Signature2020({
key: keyPair,
verificationMethod: verificationMethod.id
});
const canonizedDocument = await suite.canonize({ ...document, proof: null }, {documentLoader: cidDocumentLoader(cid, documentLoaderContent)});
const canonizedProof = await suite.canonizeProof(document.proof, {document, documentLoader: cidDocumentLoader(cid, documentLoaderContent)});
const proofHash = await sha256digest({string: canonizedProof});
const docHash = await sha256digest({string: canonizedDocument});
const concatHash = concat(proofHash, docHash);
const verified = await suite.verifySignature({
verifyData: concatHash,
proof: document.proof,
verificationMethod: verificationMethod,
});
if (!verified) {
throw new Error('Signature verification failed');
}
return {
verifyData: {
proofHash: Buffer.from(proofHash).toString('hex'),
docHash: Buffer.from(docHash).toString('hex'),
concatHash: Buffer.from(concatHash).toString('hex'),
canonicalProof: canonizedProof,
canonicalDocument: canonizedDocument
},
verificationMethod: verificationMethod,
proof: document.proof
};
}
/**
* Collect JSON-LD documents into a single Turtle file, excluding proofs
* @param {Object} options - Options for collection
* @param {string[]} options.documents - Array of JSON-LD document paths
* @param {string} options.outputPath - Output path for Turtle file
* @param {Object} [options.documentLoaderContent] - Document loader content (optional)
*
* @returns {Promise<void>}
*/
export async function collectDocuments(options = {}) {
const { documents, outputPath, documentLoaderContent } = options;
const documentLoader = createDocumentLoader(documentLoaderContent || {});
if (!outputPath.endsWith('.ttl')) {
throw new Error('Output file must have .ttl extension');
}
const data = await dereference.default(documents, {
fetch: async (url) => {
let res = await documentLoader(url);
if (!('@context' in res) && 'document' in res) {
res = res.document;
}
const str = JSON.stringify(res, null, 2);
return new Response(str, {
headers: {
'Content-Type': 'application/ld+json'
}
});
},
localFiles: true
});
const prefixes = {
...data.prefixes,
schema: 'https://schema.org/',
vdl: 'https://w3id.org/vdl#',
ob: 'https://purl.imsglobal.org/spec/vc/ob/vocab.html#',
citizenship: 'https://w3id.org/citizenship#',
credentials: 'https://www.w3.org/2018/credentials#',
ex: 'https://example.org/',
exg: 'https://example.gov/',
gov: 'https://example.gov/test#',
xsd: 'http://www.w3.org/2001/XMLSchema#',
status: 'https://example.gov/status/',
lic: 'https://example.gov/drivers-license/',
aamva: 'https://w3id.org/vdl/aamva#'
};
const turtle = await write([...data.store.match(null, null, null, DataFactory.defaultGraph())].filter(quad => !quad.predicate.equals(DataFactory.namedNode('https://w3id.org/security#proof'))), {
prefixes
});
await fs.writeFile(outputPath, turtle);
}
export async function getDocumentLoaderContent(options) {
let documentLoaderContent = {};
if (options.documentLoaderContent) {
try {
documentLoaderContent = JSON.parse(await fs.readFile(options.documentLoaderContent, 'utf8'));
} catch (error) {
console.error('Error:', error.message);
}
}
return documentLoaderContent;
}