/** * Verifiable Credential & DID functions, specifically for EndorserSearch.org tools * * The goal is to make this folder similar across projects, then move it to a library. * Other projects: endorser-ch, image-api * */ import { Buffer } from "buffer/"; import * as didJwt from "did-jwt"; import { JWTVerified } from "did-jwt"; import { Resolver } from "did-resolver"; import { IIdentifier } from "@veramo/core"; import * as u8a from "uint8arrays"; import { didEthLocalResolver } from "./did-eth-local-resolver"; import { PEER_DID_PREFIX, verifyPeerSignature } from "./didPeer"; import { base64urlDecodeString, createDidPeerJwt } from "./passkeyDidPeer"; import { urlBase64ToUint8Array } from "./util"; export const ETHR_DID_PREFIX = "did:ethr:"; export const JWT_VERIFY_FAILED_CODE = "JWT_VERIFY_FAILED"; export const UNSUPPORTED_DID_METHOD_CODE = "UNSUPPORTED_DID_METHOD"; /** * Meta info about a key */ export interface KeyMeta { /** * Decentralized ID for the key */ did: string; /** * Stringified IIDentifier object from Veramo */ identity?: string; /** * The Webauthn credential ID in hex, if this is from a passkey */ passkeyCredIdHex?: string; } const ethLocalResolver = new Resolver({ ethr: didEthLocalResolver }); /** * Tell whether a key is from a passkey * @param keyMeta contains info about the key, whose passkeyCredIdHex determines if the key is from a passkey */ export function isFromPasskey(keyMeta?: KeyMeta): boolean { return !!keyMeta?.passkeyCredIdHex; } export async function createEndorserJwtForKey( account: KeyMeta, payload: object, expiresIn?: number, ) { if (account?.identity) { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const identity: IIdentifier = JSON.parse(account.identity!); const privateKeyHex = identity.keys[0].privateKeyHex; const signer = await SimpleSigner(privateKeyHex as string); const options = { // alg: "ES256K", // "K" is the default, "K-R" is used by the server in tests issuer: account.did, signer: signer, expiresIn: undefined as number | undefined, }; if (expiresIn) { options.expiresIn = expiresIn; } return didJwt.createJWT(payload, options); } else if (account?.passkeyCredIdHex) { return createDidPeerJwt(account.did, account.passkeyCredIdHex, payload); } else { throw new Error("No identity data found to sign for DID " + account.did); } } /** * Copied out of did-jwt since it's deprecated in that library. * * The SimpleSigner returns a configured function for signing data. * * @example * const signer = SimpleSigner(privateKeyHexString) * signer(data, (err, signature) => { * ... * }) * * @param {String} hexPrivateKey a hex encoded private key * @return {Function} a configured signer function */ function SimpleSigner(hexPrivateKey: string): didJwt.Signer { const signer = didJwt.ES256KSigner(didJwt.hexToBytes(hexPrivateKey), true); return async (data) => { const signature = (await signer(data)) as string; return fromJose(signature); }; } // from did-jwt/util; see SimpleSigner above function fromJose(signature: string): { r: string; s: string; recoveryParam?: number; } { const signatureBytes: Uint8Array = didJwt.base64ToBytes(signature); if (signatureBytes.length < 64 || signatureBytes.length > 65) { throw new TypeError( `Wrong size for signature. Expected 64 or 65 bytes, but got ${signatureBytes.length}`, ); } const r = bytesToHex(signatureBytes.slice(0, 32)); const s = bytesToHex(signatureBytes.slice(32, 64)); const recoveryParam = signatureBytes.length === 65 ? signatureBytes[64] : undefined; return { r, s, recoveryParam }; } // from did-jwt/util; see SimpleSigner above function bytesToHex(b: Uint8Array): string { return u8a.toString(b, "base16"); } // We should be calling 'verify' in more places, showing warnings if it fails. // @returns JWTDecoded with { header: JWTHeader, payload: any, signature: string, data: string } (but doesn't verify the signature) export function decodeEndorserJwt(jwt: string) { try { // First try the standard did-jwt decode return didJwt.decodeJWT(jwt); } catch (error) { // If that fails, try manual decoding try { const parts = jwt.split("."); if (parts.length !== 3) { throw new Error("JWT must have 3 parts"); } const header = JSON.parse(Buffer.from(parts[0], "base64url").toString()); const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()); // Validate the header if (header.typ !== "JWT" || !header.alg) { throw new Error("Invalid JWT header format"); } // Return in the same format as didJwt.decodeJWT return { header, payload, signature: parts[2], data: parts[0] + "." + parts[1], }; } catch (e) { throw new Error(`invalid_argument: Incorrect format JWT - ${e.message}`); } } } // return Promise of at least { issuer, payload, verified boolean } // ... and also if successfully verified by did-jwt (not JWANT): data, doc, signature, signer export async function decodeAndVerifyJwt( jwt: string, ): Promise> { const pieces = jwt.split("."); const header = JSON.parse(base64urlDecodeString(pieces[0])); const payload = JSON.parse(base64urlDecodeString(pieces[1])); const issuerDid = payload.iss; if (!issuerDid) { return Promise.reject({ clientError: { message: `Missing "iss" field in JWT.`, }, }); } if (issuerDid.startsWith(ETHR_DID_PREFIX)) { try { const verified = await didJwt.verifyJWT(jwt, { resolver: ethLocalResolver, }); return verified; } catch (e: unknown) { return Promise.reject({ clientError: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error message: `JWT failed verification: ` + e.toString(), code: JWT_VERIFY_FAILED_CODE, }, }); } } if (issuerDid.startsWith(PEER_DID_PREFIX) && header.typ === "JWANT") { const verified = await verifyPeerSignature( Buffer.from(payload), issuerDid, urlBase64ToUint8Array(pieces[2]), ); if (!verified) { return Promise.reject({ clientError: { // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-expect-error message: `JWT failed verification: ` + e.toString(), code: JWT_VERIFY_FAILED_CODE, }, }); } else { return { issuer: issuerDid, payload: payload, verified: true }; } } if (issuerDid.startsWith(PEER_DID_PREFIX)) { return Promise.reject({ clientError: { message: `JWT with a PEER DID currently only supported with typ == JWANT. Contact us us for JWT suport since it should be straightforward.`, }, }); } return Promise.reject({ clientError: { message: `Unsupported DID method ${issuerDid}`, code: UNSUPPORTED_DID_METHOD_CODE, }, }); }