UNPKG

@arcblock/vc

Version:

TypeScript lib to work with ArcBlock Verifiable Credentials

283 lines (281 loc) 12.6 kB
import { name } from "./package.mjs"; import { fromPublicKeyHash, isFromPublicKey, isValid, toTypeInfo } from "@arcblock/did"; import { types } from "@ocap/mcrypto"; import { fromBase58, fromBase64, toBase58, toBase64 } from "@ocap/util"; import { fromPublicKey } from "@ocap/wallet"; import Debug from "debug"; import isAbsoluteUrl from "is-absolute-url"; import stringify from "json-stable-stringify"; import cloneDeep from "lodash/cloneDeep.js"; //#region src/index.ts /** * @fileOverview Utility functions to create/verify vc * * @module @arcblock/vc * @requires @arcblock/did * @requires @ocap/util */ const debug = Debug(name); const proofTypes = { [types.KeyType.ED25519]: "Ed25519Signature", [types.KeyType.SECP256K1]: "Secp256k1Signature", [types.KeyType.ETHEREUM]: "EthereumSignature" }; /** * Create a valid verifiable credential * * @param params * @param params.type - The type of credential * @param params.subject - The content of credential * @param params.issuer - The issuer name and wallet * @param params.issuanceDate * @param params.expirationDate * @param params.endpoint - Status endpoint url * @param params.endpointScope - Endpoint scope, either be public or private * @returns Promise<object> */ async function create({ type, subject, issuer, issuanceDate, expirationDate, tag = "", endpoint = "", endpointScope = "public" }) { const typeArray = Array.isArray(type) ? [...type] : type ? [type] : []; if (typeArray.length === 0 || typeArray.some((t) => !t)) throw new Error("Can not create verifiable credential without empty type"); if (!typeArray.includes("VerifiableCredential")) typeArray.unshift("VerifiableCredential"); if (!subject) throw new Error("Can not create verifiable credential from empty subject"); 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); const vcType = { ...typeInfo, role: types.RoleType.ROLE_VC }; const vcDid = fromPublicKeyHash(wallet.hash(stringify(subject)), vcType); const pkType = typeInfo.pk; if (!proofTypes[pkType]) throw new Error("Unsupported signer type when create verifiable credential"); const issuanceDateValue = issuanceDate || (/* @__PURE__ */ new Date()).toISOString(); const issuerPk = toBase58(wallet.publicKey); const vcObj = { "@context": ["https://www.w3.org/2018/credentials/v1", "https://schema.arcblock.io/v0.1/context.jsonld"], id: vcDid, type: typeArray, issuer: { id: issuerDid, pk: issuerPk, name: issuerName || issuerDid }, issuanceDate: issuanceDateValue, expirationDate, credentialSubject: subject }; if (tag) vcObj.tag = tag; if (endpoint) vcObj.credentialStatus = { id: endpoint, type: "NFTStatusList2021", scope: endpointScope || "public" }; const signature = await wallet.sign(stringify(vcObj)); const result = { proof: { type: proofTypes[pkType], created: issuanceDateValue, proofPurpose: "assertionMethod", id: crypto.randomUUID(), signer: issuerDid, pk: issuerPk, jws: toBase64(signature) }, ...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 vc - the verifiable credential object * @param ownerDid - vc holder/owner did * @param trustedIssuers - list of issuer did * @throws Error * @returns Promise<boolean> */ async function verify({ vc, ownerDid, trustedIssuers, ignoreExpired = false }) { 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"); const proofList = Array.isArray(vc.proof) ? vc.proof : vc.proof ? [vc.proof] : []; if (proofList.length === 0 || proofList.some((p) => !p || !p.jws)) throw new Error("Invalid verifiable credential proof"); if (vc.issuanceDate === void 0) 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 !== void 0 && new Date(vc.expirationDate).getTime() < Date.now()) throw Error("Verifiable credential has expired"); const issuers = Array.isArray(trustedIssuers) ? trustedIssuers : [trustedIssuers]; if (!isFromPublicKey(vc.issuer.id, vc.issuer.pk)) throw new Error("Verifiable credential not issuer pk not match with issuer did"); if (ownerDid !== vc.credentialSubject.id) throw new Error("Verifiable credential not owned by specified owner did"); for (const proof of proofList) { const proofPk = proof.pk || vc.issuer.pk; const proofSigner = proof.signer || vc.issuer.id; if (!issuers.includes(proofSigner)) throw new Error("Proof signer not in trusted issuers"); if (proof.signer && proof.pk && !isFromPublicKey(proof.signer, proof.pk)) throw new Error("Proof pk does not match signer"); const clone = cloneDeep(vc); delete clone.signature; const prevProofRefs = proof.previousProof; if (prevProofRefs && (typeof prevProofRefs === "string" || Array.isArray(prevProofRefs) && prevProofRefs.length > 0)) { const prevIds = Array.isArray(prevProofRefs) ? prevProofRefs : [prevProofRefs]; const matchingProofs = proofList.filter((p) => p.id && prevIds.includes(p.id)); if (matchingProofs.length !== prevIds.length) throw new Error("Referenced previous proof not found"); clone.proof = matchingProofs; } else delete clone.proof; const signedContent = stringify(clone); if (await fromPublicKey(proofPk, toTypeInfo(proofSigner)).verify(signedContent, fromBase64(proof.jws)) !== true) throw Error("Verifiable credential signature not valid"); } return true; } /** * Counter-sign an existing verifiable credential * Adds a new proof from a different signer to the VC * * @param params * @param params.vc - The already-signed verifiable credential * @param params.wallet - The counter-signer's wallet * @param params.mode - 'set' (independent proof) or 'chain' (references previous proofs) * @returns Promise<VerifiableCredential> */ async function counterSign({ vc, wallet, mode = "set" }) { if (!vc) throw new Error("Cannot counter-sign empty verifiable credential"); const existingProofs = Array.isArray(vc.proof) ? vc.proof : vc.proof ? [vc.proof] : []; if (existingProofs.length === 0) throw new Error("Cannot counter-sign verifiable credential without existing proof"); const signerDid = wallet.address; const pkType = toTypeInfo(signerDid).pk; if (!proofTypes[pkType]) throw new Error("Unsupported signer type when counter-signing verifiable credential"); const signerPk = toBase58(wallet.publicKey); const proofsWithIds = existingProofs.map((p) => p.id ? p : { ...p, id: crypto.randomUUID() }); const clone = cloneDeep(vc); delete clone.signature; const newProof = { type: proofTypes[pkType], created: (/* @__PURE__ */ new Date()).toISOString(), proofPurpose: "assertionMethod", id: crypto.randomUUID(), signer: signerDid, pk: signerPk, jws: "" }; if (mode === "chain") { clone.proof = proofsWithIds; const prevIds = proofsWithIds.map((p) => p.id); newProof.previousProof = prevIds.length === 1 ? prevIds[0] : prevIds; } else delete clone.proof; const signedContent = stringify(clone); newProof.jws = toBase64(await wallet.sign(signedContent)); return { ...vc, proof: [...proofsWithIds, newProof] }; } /** * 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 presentation - the presentation object * @param trustedIssuers - list of issuer did * @param 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 signatureStr = proof.jws; if (await fromPublicKey(fromBase58(proof.pk), toTypeInfo(vcObj.credentialSubject.id)).verify(stringify(clone), fromBase64(signatureStr)) !== true) throw Error("Presentation signature invalid"); await verify({ vc: vcObj, ownerDid: vcObj.credentialSubject.id, trustedIssuers, ignoreExpired }); })); return true; } async 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: name$1 } = issuer; const issuerDid = wallet.address; const typeInfo = toTypeInfo(issuerDid); const vcType = { ...typeInfo, role: types.RoleType.ROLE_VC }; const issued = issuanceDate || (/* @__PURE__ */ new Date()).toISOString(); const pkType = typeInfo.pk; const issuerPk = toBase58(wallet.publicKey); return await Promise.all(claims.map(async (x) => { const vc = { claim: x }; vc.id = fromPublicKeyHash(wallet.hash(stringify(vc.claim)), vcType); vc.issued = issued; vc.issuer = { id: issuerDid, pk: issuerPk, name: name$1 || issuerDid }; const signature = await wallet.sign(stringify(vc)); vc.proof = { type: proofTypes[pkType], created: issued, proofPurpose: "assertionMethod", id: crypto.randomUUID(), signer: issuerDid, pk: issuerPk, jws: toBase64(signature) }; return vc; })); } 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) => { 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"); const proofList = Array.isArray(x.proof) ? x.proof : x.proof ? [x.proof] : []; if (proofList.length === 0 || proofList.some((p) => !p || !p.jws)) throw new Error("Invalid credential proof"); const clone = cloneDeep(x); delete clone.proof; const signedContent = stringify(clone); for (const proof of proofList) { const proofPk = proof.pk || x.issuer.pk; const proofSigner = proof.signer || x.issuer.id; if (!issuers.includes(proofSigner)) throw new Error("Proof signer not in trusted issuers"); if (proof.signer && proof.pk && !isFromPublicKey(proof.signer, proof.pk)) throw new Error("Proof pk does not match signer"); if (await fromPublicKey(proofPk, toTypeInfo(proofSigner)).verify(signedContent, fromBase64(proof.jws)) !== true) throw Error("Status credential signature not valid"); } return x.claim; })); } const stableStringify = stringify; //#endregion export { counterSign, create, createCredentialList, proofTypes, stableStringify, verify, verifyCredentialList, verifyPresentation };