import type { EBSITrustedNodesList } from "@europeum-ebsi/vcdm1.1-trusted-nodes-list-schema"; import type { EbsiEnvConfiguration } from "@europeum-ebsi/verifiable-presentation"; import type { Schemas } from "@europeum-ebsi/verifiable-presentation/vcdm11.js"; import type { JWK, JWTPayload, ProtectedHeaderParameters } from "jose"; import { metadata as attestationMetadata, schema as attestationSchema, } from "@europeum-ebsi/vcdm1.1-attestation-schema"; import { metadata as presentationMetadata, schema as presentationSchema, } from "@europeum-ebsi/vcdm1.1-presentation-schema"; import { metadata as trustedNodesListMetadata, schema as trustedNodesListSchema, } from "@europeum-ebsi/vcdm1.1-trusted-nodes-list-schema"; import { verifyPresentationJwt } from "@europeum-ebsi/verifiable-presentation/vcdm11.js"; import { AggregateAjvError } from "@segment/ajv-human-errors"; import addFormats from "ajv-formats"; import ajv2020 from "ajv/dist/2020.js"; import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose"; import type { Source } from "./sources/source.ts"; import { JsonSchemaValidationError } from "./errors/JsonSchemaValidationError.ts"; import { GitLabSource } from "./sources/gitlab-source.ts"; import { getCanonicalJwk } from "./utils.ts"; // Export custom errors export { JsonSchemaValidationError } from "./errors/JsonSchemaValidationError.ts"; // Export Source interface export type { Source } from "./sources/source.ts"; interface GetTrustedNodesListOptions { timeout?: number; } interface GetTrustedNodesListResult { /** * The list of trusted nodes extracted from the TNL VC JWT */ list: TrustedNodesList; raw: { /** * The raw TNL VC JWT */ vc: string; /** * The raw TNL VP JWT */ vp: string; }; } type TrustedNodesList = EBSITrustedNodesList["credentialSubject"]; /** * Get Trusted Nodes List from default sources (GitLab only at the moment). * * @param network - EBSI network (e.g. "pilot") * @param options - Options (Axios timeout) * @returns Trusted Nodes List */ export async function getTrustedNodesList( network: string, options?: GetTrustedNodesListOptions, ): Promise { const sources = [ new GitLabSource(network, { timeout: options?.timeout ?? 30_000, }), ]; return getTrustedNodesListFromSources(sources, network); } /** * Get Trusted Nodes List from user-defined sources. * * @param sources - List of sources * @param network - EBSI network (e.g. "pilot") * @returns Trusted Nodes List */ export async function getTrustedNodesListFromSources( sources: readonly Source[], network: string, ): Promise { if (sources.length === 0) { throw new Error( "Please define at least 1 location from which to fetch the trusted nodes list", ); } // Fetch the most recent tnl.jwt file from the different sources const vpJwtSet = new Set( await Promise.all( sources.map(async (source) => { try { const jwt = await source.getTnlJwt(); /* v8 ignore next 3 -- @preserve */ if (typeof jwt !== "string") { throw new TypeError(`The TNL JWT is not a string`); } return jwt.trim(); } catch (error) { throw new Error( `Invalid response from ${source.displayName}: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error }, ); } }), ), ); // Configure Ajv const Ajv2020 = ajv2020.default; const ajv = new Ajv2020({ allErrors: true, verbose: true, }); // JSON Schema formats for Ajv addFormats.default(ajv); // Pre-register EBSI Verifiable Attestation schema ajv.addSchema(attestationSchema, attestationMetadata.id.base16); ajv.addSchema(attestationSchema, attestationMetadata.id.multibase_base58btc); // Pre-register EBSI Trusted Nodes List schema ajv.addSchema(trustedNodesListSchema, trustedNodesListMetadata.id.base16); ajv.addSchema( trustedNodesListSchema, trustedNodesListMetadata.id.multibase_base58btc, ); // Pre-register EBSI Verifiable Presentation schema ajv.addSchema(presentationSchema, presentationMetadata.id.base16); ajv.addSchema( presentationSchema, presentationMetadata.id.multibase_base58btc, ); // Decode VP JWT and inner VC JWT: extract VP kid, VC JWT and TNL version const vpMap = new Map< string, { credentialSubject: EBSITrustedNodesList["credentialSubject"]; vcJwt: string; version: number; vpKid: string; } >(); const vpKidSet = new Set(); for (const jwt of vpJwtSet) { let vpHeader: ProtectedHeaderParameters; try { vpHeader = decodeProtectedHeader(jwt); } catch { throw new Error("Unable to decode VP JWT header"); } // Extract kid if (!("kid" in vpHeader)) { throw new Error("Unable to extract kid from VP JWT"); } const kid = vpHeader.kid; vpKidSet.add(kid); // Validate VP payload let vpPayload: JWTPayload; try { vpPayload = decodeJwt(jwt); } catch { throw new Error("Unable to decode VP JWT payload"); } const vp = vpPayload["vp"]; validateEbsiVerifiablePresentation(vp, ajv); // Extract and validate VC payload if ( vp.verifiableCredential.length !== 1 || typeof vp.verifiableCredential[0] !== "string" ) { throw new Error("verifiableCredential must contain exactly 1 string"); } const vcJwt = vp.verifiableCredential[0]; let vcPayload: JWTPayload; try { vcPayload = decodeJwt(vcJwt); } catch { throw new Error("Unable to decode VC JWT payload"); } const vc = vcPayload["vc"]; validateEbsiTrustedNodesList(vc, ajv); vpMap.set(jwt, { credentialSubject: vc.credentialSubject, vcJwt, version: vc.credentialSubject.version, vpKid: kid, }); } // 1/ If there's only 1 VP JWT, validate signatures and return list of trusted nodes if (vpJwtSet.size === 1) { const [vpJwt] = vpJwtSet; const { credentialSubject, vcJwt, vpKid } = vpMap.get(vpJwt!)!; await validateTrustedNodesListCredential( vpJwt!, vpKid, vcJwt, credentialSubject.nodes, sources, network, ); return { list: credentialSubject, raw: { vc: vcJwt, vp: vpJwt!, }, }; } // 2/ If there are multiple JWTs... // 2.1/ ... and they all have the same kid, use the most recent one, validate signatures and return list of trusted nodes if (vpKidSet.size === 1) { let vpJwt: string; let highestVersion = 0; for (const [jwt, { version }] of vpMap) { if (version === highestVersion) { throw new Error( "Conflicting TNL JWTs found with the same kid and version", ); } if (version > highestVersion) { highestVersion = version; vpJwt = jwt; } } const { credentialSubject, vcJwt, vpKid } = vpMap.get(vpJwt!)!; await validateTrustedNodesListCredential( vpJwt!, vpKid, vcJwt, credentialSubject.nodes, sources, network, ); return { list: credentialSubject, raw: { vc: vcJwt, vp: vpJwt!, }, }; } // 2.2/ ... and they have different kids, use the one that is common to all sources (lowest version) // If the "kid" properties don't match, it means that the 2 files have been signed with different public keys. // In this case, it's safer to use the older version of the TNL JWT (lowest version number), and pull the public key from the archives in the most recent repository. let vpJwt: string; let lowestVersion = Number.POSITIVE_INFINITY; for (const [jwt, { version }] of vpMap) { if (version < lowestVersion) { lowestVersion = version; vpJwt = jwt; } } // Fetch older TNL from all sources (archives) and make sure the same TNL is available everywhere const archivedVpJwtSet = new Set( await Promise.all( sources.map(async (source) => { try { const jwt = await source.getTnlJwt(lowestVersion); /* v8 ignore next 3 -- @preserve */ if (typeof jwt !== "string") { throw new TypeError(`The TNL JWT is not a string`); } return jwt.trim(); } catch (error) { throw new Error( `Invalid response from ${source.displayName}: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error }, ); } }), ), ); if (archivedVpJwtSet.size !== 1) { throw new Error( `Sources returned different results for TNL version ${lowestVersion.toString()}`, ); } const { credentialSubject, vcJwt, vpKid } = vpMap.get(vpJwt!)!; await validateTrustedNodesListCredential( vpJwt!, vpKid, vcJwt, credentialSubject.nodes, sources, network, ); return { list: credentialSubject, raw: { vc: vcJwt, vp: vpJwt!, }, }; } /** * Validates that the given value is an EBSITrustedNodesList object. * @param value - Credential payload * @param ajv - Ajv instance */ function validateEbsiTrustedNodesList( value: unknown, ajv: ajv2020.Ajv2020, ): asserts value is EBSITrustedNodesList { const validate = ajv.getSchema(trustedNodesListMetadata.id.base16); /* v8 ignore next 3 -- @preserve */ if (!validate) { throw new Error("Unable to get EBSI Trusted Nodes List schema"); } const valid = validate(value); if (!valid && validate.errors) { const errors = new AggregateAjvError(validate.errors, { fieldLabels: "jsonPointer", }); throw new JsonSchemaValidationError( `Invalid EBSI Trusted Nodes List. ${errors.message}`, validate.errors, ); } } /** * Validates that the given value is an EBSIVerifiablePresentation object. * @param value - Presentation payload * @param ajv - Ajv instance */ function validateEbsiVerifiablePresentation( value: unknown, ajv: ajv2020.Ajv2020, ): asserts value is Schemas["Presentation"] { const validate = ajv.getSchema(presentationMetadata.id.base16); /* v8 ignore next 3 -- @preserve */ if (!validate) { throw new Error("Unable to get EBSI Verifiable Presentation schema"); } const valid = validate(value); if (!valid && validate.errors) { const errors = new AggregateAjvError(validate.errors, { fieldLabels: "jsonPointer", }); throw new JsonSchemaValidationError( `Invalid EBSI Verifiable Presentation. ${errors.message}`, validate.errors, ); } } async function validateTrustedNodesListCredential( vpJwt: string, vpKid: string, vcJwt: string, nodes: EBSITrustedNodesList["credentialSubject"]["nodes"], sources: readonly Source[], network: string, ) { const [vpIssuerDid, vpIssuerKid] = vpKid.split("#"); if (!vpIssuerDid || !vpIssuerKid) { throw new Error("Unable to extract issuer from VP JWT"); } // Fetch Service Ops Manager's public key from the different sources const serviceOpsManagerPublicKeyMap = new Map(); for (const source of sources) { let jwk: unknown; try { jwk = await source.getPublicKeyJwk(vpIssuerKid); } catch (error) { throw new Error( `Invalid response from ${source.displayName}: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error }, ); } const canonicalJwk = getCanonicalJwk(jwk); serviceOpsManagerPublicKeyMap.set( `${canonicalJwk.x}${canonicalJwk.y}`, canonicalJwk, ); } if (serviceOpsManagerPublicKeyMap.size > 1) { throw new Error( `Received ${serviceOpsManagerPublicKeyMap.size.toString()} different public keys for Service Ops Manager`, ); } const [serviceOpsManagerPublicKeyJwk] = serviceOpsManagerPublicKeyMap.values(); /* v8 ignore next 3 -- @preserve */ if (!serviceOpsManagerPublicKeyJwk) { throw new Error("Couldn't fetch Service Ops Manager's public key"); } // Import JWK const serviceOpsManagerPublicKey = await importJWK( serviceOpsManagerPublicKeyJwk, "ES256", ); // Validate VP JWT signature try { await jwtVerify(vpJwt, serviceOpsManagerPublicKey, { audience: "any", issuer: vpIssuerDid, // Check VP JWT issuer }); } catch { throw new Error("Invalid signature from VP JWT"); } // Decode VC JWT let vcHeader: ProtectedHeaderParameters; try { vcHeader = decodeProtectedHeader(vcJwt); } catch { throw new Error("Unable to decode VC JWT"); } // Extract kid if (!("kid" in vcHeader)) { throw new Error("Unable to extract kid from VC JWT"); } const vcKid = vcHeader.kid; const [vcIssuerDid, vcIssuerKid] = vcKid.split("#"); /* v8 ignore next 3 -- @preserve */ if (!vcIssuerDid || !vcIssuerKid) { throw new Error("Unable to extract issuer from VC JWT"); } // Fetch Support Office's public key from the different sources const supportOfficePublicKeyMap = new Map(); for (const source of sources) { let jwk: unknown; try { jwk = await source.getPublicKeyJwk(vcIssuerKid); } catch (error) { throw new Error( `Invalid response from ${source.displayName}: ${error instanceof Error ? error.message : "Unknown error"}`, { cause: error }, ); } const canonicalJwk = getCanonicalJwk(jwk); supportOfficePublicKeyMap.set( `${canonicalJwk.x}${canonicalJwk.y}`, canonicalJwk, ); } if (supportOfficePublicKeyMap.size > 1) { throw new Error( `Received ${supportOfficePublicKeyMap.size.toString()} different public keys for Support Office`, ); } const [supportOfficePublicKeyJwk] = supportOfficePublicKeyMap.values(); /* v8 ignore next 3 -- @preserve */ if (!supportOfficePublicKeyJwk) { throw new Error("Couldn't fetch Support Office's public key"); } // Import JWK const supportOfficePublicKey = await importJWK( supportOfficePublicKeyJwk, "ES256", ); // Validate VC JWT signature try { await jwtVerify(vcJwt, supportOfficePublicKey, { issuer: vcIssuerDid, // Check VC JWT issuer }); } catch { throw new Error("Invalid signature from VC JWT"); } const hosts = nodes.map((node) => { return new URL(node.apis).host; }); const ebsiEnvConfig = { hosts, network: { isOptional: network === "prod", name: network, }, scheme: "ebsi", services: { "did-registry": "v5", "trusted-issuers-registry": "v5", "trusted-policies-registry": "v3", "trusted-schemas-registry": "v3", }, } satisfies EbsiEnvConfiguration; // Final step: Validate TNL VP JWT await verifyPresentationJwt( vpJwt, "any", // audience must be "any" ebsiEnvConfig, ); }