import type { JWTHeader, JWTPayload, JWTVerifyOptions, } from "@europeum-ebsi/did-jwt"; import type { EbsiEnvConfiguration } from "@europeum-ebsi/verifiable-credential"; import type { VerifyCredentialOptions } from "@europeum-ebsi/verifiable-credential/vcdm20.js"; import type { RawAxiosRequestHeaders } from "axios"; import type { VerificationMethod } from "did-resolver"; import { decodeJWT, verifyJWT } from "@europeum-ebsi/did-jwt"; import { EBSI_DID_METHOD_PREFIX } from "@europeum-ebsi/ebsi-did-resolver"; import { metadata, schema } from "@europeum-ebsi/vcdm2.0-presentation-schema"; import { JsonSchemaLoadingError, JsonSchemaValidationError, validateDid, ValidationError, verificationMethodMatchesAlg, } from "@europeum-ebsi/verifiable-credential"; import { getAjvInstance as vcLibGetAjvInstance, verifyCredentialJwt, } from "@europeum-ebsi/verifiable-credential/vcdm20.js"; import { AggregateAjvError } from "@segment/ajv-human-errors"; import { Resolver } from "did-resolver"; import memoize from "memoize"; import type { ProofPurposeTypes } from "../shared/types.ts"; import type { Schemas, VerifyPresentationJwtOptions, VpJwtClaims, VpJwtHeader, VpJwtPayload, } from "./types.ts"; /** * Returns a memoized Ajv instance * @param config - EBSI environment configuration * @param timeout - Axios requests timeout * @returns Ajv instance */ export const getAjvInstance = memoize( (config: EbsiEnvConfiguration, timeout?: number) => { // Configure Ajv const ajv = vcLibGetAjvInstance(config, timeout); // Pre-register EBSI Verifiable Presentation 2022-02 schema ajv.addSchema(schema, metadata.id.base16); ajv.addSchema(schema, metadata.id.multibase_base58btc); return ajv; }, { cacheKey: ([config, timeout]) => `ajv-vp-${JSON.stringify(config)}${(timeout ?? "undefined").toString()}`, }, ); /** * Validates that the '\@context' is conform to the W3C specs. * @param context - A set of URIs to validate */ export function validateContext( context: string[], ): asserts context is ["https://www.w3.org/ns/credentials/v2", ...string[]] { if ( !Array.isArray(context) || context.length === 0 || context.some((c) => typeof c !== "string") ) { throw new ValidationError('"@context" must be an array of strings'); } if (context[0] !== "https://www.w3.org/ns/credentials/v2") { throw new ValidationError( 'The first URI in "@context" must be "https://www.w3.org/ns/credentials/v2"', ); } } /** * Validates VC JWT in the context of the VP. * @param vcJwt - Verifiable Credential as JWT * @param holder - The VP holder's DID * @param config - EBSI environment configuration * @param options - `VerifyCredentialOptions` object */ export async function validateCredentialJwt( vcJwt: string, holder: string, config: EbsiEnvConfiguration, options: VerifyCredentialOptions, ): Promise { // Run VC @europeum-ebsi/verifiable-credential validation await verifyCredentialJwt(vcJwt, config, options); const { payload } = decodeJWT(vcJwt); // Check VC subject. It MUST match the VP holder. validateCredentialSubject(payload, holder); } /** * Validates all the VP's credentials. * @param payload - A `Schemas["Presentation"]` object * @param holder - The VP holder's DID * @param config - EBSI environment configuration * @param options - `VerifyCredentialOptions` object */ export async function validateCredentials( payload: Pick, holder: string, config: EbsiEnvConfiguration, options: VerifyCredentialOptions, ): Promise { if (!payload.verifiableCredential) { return; } try { await Promise.all( payload.verifiableCredential.map(async (verifiableCredential) => { // Validate data URI prefix const supportedDataUriPrefix = "data:application/vc+jwt,"; if (!verifiableCredential.id.startsWith(supportedDataUriPrefix)) { throw new ValidationError( "Unsupported verifiableCredential ID. Must start with 'data:application/vc+jwt,'", ); } const vcJwt = verifiableCredential.id.slice( supportedDataUriPrefix.length, ); return validateCredentialJwt(vcJwt, holder, config, options); }), ); } catch (error) { throw new ValidationError( `VC JWT validation failed. ${error instanceof Error ? error.message : "Reason unknown"}`, ); } } /** * Validates that the VC credentialSubject matches the VP holder. * @param payload - An `Schemas["Presentation"]` object * @param vpHolder - The VP holder's DID */ export function validateCredentialSubject( payload: Partial, vpHolder: string, ) { if (!("credentialSubject" in payload)) { throw new ValidationError( "VC payload is missing a credentialSubject field", ); } const credentialSubjectArray = ( Array.isArray(payload["credentialSubject"]) ? payload["credentialSubject"] : [payload["credentialSubject"]] ) as unknown[]; for (const credentialSubject of credentialSubjectArray) { if ( !credentialSubject || typeof credentialSubject !== "object" || !("id" in credentialSubject) || typeof credentialSubject.id !== "string" ) { throw new ValidationError("Unsupported credentialSubject ID"); } if (credentialSubject.id !== vpHolder) { throw new ValidationError( `VC subject "${credentialSubject.id}" and VP holder "${vpHolder}" don't match`, ); } } } /** * Validates that the given value is a Schemas["Presentation"] object. * @param value - Presentation payload * @param config - EBSI environment configuration * @param timeout - Axios requests timeout */ export function validateEbsiVerifiablePresentation( value: unknown, config: EbsiEnvConfiguration, timeout: number, ): asserts value is Schemas["Presentation"] { const ajv = getAjvInstance(config, timeout); const validate = ajv.getSchema(metadata.id.base16); if (!validate) { throw new JsonSchemaLoadingError("Unable to get EBSI VP 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, ); } } /** * Validates the presentation holder. * @param payload - a Schemas["Presentation"] * @param holder - the VP holder's DID * @param kid - the kid of the key used to sign the VP * @param alg - the algorithm used to sign the VP * @param issuedAt - The timestamp when the JWT was issued * @param resolver - DID resolver * @param proofPurpose - one of "authentication", "assertionMethod", "capabilityInvocation", "capabilityDelegation". Default: "authentication" * @param timeout - Axios timeout (default: 15 seconds) * @param skipHolderDidResolutionValidation - If true, the holder DID will not be resolved * @param axiosHeaders - Custom Axios request headers */ export async function validateHolder( payload: Pick, expectedHolder: string | undefined, kid: string, alg: string | undefined, issuedAt: number | undefined, resolver: Resolver, proofPurpose: ProofPurposeTypes, timeout: number, skipHolderDidResolutionValidation: boolean, axiosHeaders: RawAxiosRequestHeaders, ): Promise { if (!payload.holder) { throw new ValidationError("payload.holder must be defined"); } const payloadHolder = typeof payload.holder === "string" ? payload.holder : payload.holder.id; validateDid(payloadHolder); if (expectedHolder && payloadHolder !== expectedHolder) { throw new ValidationError( `payload.holder "${payloadHolder}" and holder "${expectedHolder}" don't match`, ); } // Validate kid if (typeof kid !== "string") { throw new ValidationError("kid is required"); } if (!kid.includes("#")) { throw new ValidationError(`kid doesn't contain "#"`); } if (kid.split("#")[0] !== payloadHolder) { throw new ValidationError("did and kid don't match"); } // Verify that the holder is registered in the DID Registry if (skipHolderDidResolutionValidation) { // Do not try to resolve holder DID return undefined; } let didUrl = payloadHolder; if (payloadHolder.startsWith(EBSI_DID_METHOD_PREFIX)) { // Resolve DID document at the time of the VC issuance // Normalize to UTC 00:00:00 and without sub-second decimal precision const versionTime = new Date( (issuedAt ?? Math.floor(Date.now() / 1000)) * 1000, ) .toISOString() .replace(".000Z", "Z"); didUrl = `${payloadHolder}?versionTime=${versionTime}`; } const didResolutionResult = await resolver.resolve(didUrl, { axiosHeaders, timeout, }); const { didDocument, didResolutionMetadata } = didResolutionResult; if (!didDocument) { if ( didResolutionMetadata.error && typeof didResolutionMetadata["message"] === "string" ) { throw new ValidationError( `Unable to resolve ${payloadHolder}. Error: ${didResolutionMetadata.error}. ${didResolutionMetadata["message"]}`, ); } throw new ValidationError( `Couldn't find the DID document associated with ${payloadHolder}`, ); } // Retrieve verification method(s) used to sign the VC if (typeof alg !== "string") { throw new ValidationError("alg is required"); } const verificationMethods = [...(didDocument[proofPurpose] ?? [])] .map((method) => { if (typeof method === "string") { // Get verificationMethod corresponding to assertionMethod ID return didDocument.verificationMethod?.find((vm) => vm.id === method); } return method; }) .filter((method): method is VerificationMethod => { return method?.id === kid && verificationMethodMatchesAlg(method, alg); }); if (verificationMethods.length === 0) { throw new ValidationError( `Could not find a verification method related to "${kid}" for the proof purpose "${proofPurpose}" and algorithm "${alg}"`, ); } return { authenticators: verificationMethods, didResolutionResult, issuer: payloadHolder, }; } /** * Validates the VP JWT properties. Ensures that all the properties are available and match the VP. * @param jwt - The VP JWT * @param audience - Represents the identity of the intended audience * @param config - EBSI environment configuration * @param options - Extra verification options */ export function validateJwtProps( jwt: string, audience: string, config: EbsiEnvConfiguration, options: Pick< Required, "clockSkew" | "timeout" | "validAt" >, ): { header: VpJwtHeader; payload: VpJwtPayload; } { let payload: JWTPayload; let header: JWTHeader; try { const decodedJwt = decodeJWT(jwt); payload = decodedJwt.payload; header = decodedJwt.header; } catch { throw new ValidationError("Unable to decode JWT VC"); } const { aud, exp, iat, iss, jti, nbf, sub, ...vp } = payload; validateEbsiVerifiablePresentation(vp, config, options.timeout); validateVpJwtHeader(header, false); validateVpJwtClaims(payload, vp, audience); if (!vp.holder) { throw new ValidationError(`VP holder is required`); } const { clockSkew, validAt } = options; if (nbf && nbf - clockSkew > validAt) { throw new ValidationError("JWT is not valid yet"); } if (exp && validAt > exp + clockSkew) { throw new ValidationError("JWT has expired"); } return { header: header, payload: payload as VpJwtPayload }; } /** * Verifies the VP JWT signature. * @param jwt - Verifiable Presentation as JWT * @param audience - Represents the identity of the intended audience * @param resolver - DID resolver * @param didAuthenticator - DID resolution results, including resolved verification methods */ export async function validateSignature( jwt: string, audience: string, resolver: Resolver, didAuthenticator: NonNullable, ) { try { const verifyVpResult = await verifyJWT(jwt, { audience, didAuthenticator, policies: { // Let @europeum-ebsi/did-jwt validate the JWT claims aud: true, exp: true, iat: true, nbf: true, }, resolver, }); return verifyVpResult; } catch (error) { const errorMessage = error instanceof Error ? error.message : "Reason unknown"; throw new ValidationError(`VP JWT validation failed: ${errorMessage}`); } } /** * Validates that the given value is a valid timestamp. * @param value - Value to validate */ export function validateTimestamp( value: unknown, propName: string, ): asserts value is number { if (typeof value !== "number") { throw new ValidationError(`${propName} must be a number`); } if (!(Number.isInteger(value) && value < 100_000_000_000)) { throw new ValidationError(`${propName} is not a unix timestamp in seconds`); } } /** * Validates that the 'type' is conform to the W3C specs. * @param type - A set of types to validate */ export function validateType(type: string[]) { if (!Array.isArray(type)) { throw new ValidationError('"type" must be an array of strings'); } if (type[0] !== "VerifiablePresentation") { throw new ValidationError( 'The first type must be "VerifiablePresentation"', ); } } /** * Validates the JWT claims according to https://www.w3.org/TR/2025/REC-vc-jose-cose-20250515/#jose-header-parameters-jwt-claims * @param claims - JWT claims * @param vp - EBSI Verifiable Presentation */ export function validateVpJwtClaims( claims: unknown, vp: Schemas["Presentation"], audience: string, ): asserts claims is VpJwtClaims { if (typeof claims !== "object" || claims === null) { throw new ValidationError( "Invalid JWT claims. The value must be an object.", ); } if ("aud" in claims && claims.aud !== audience) { throw new ValidationError( `"aud" JWT claim property MUST match the expected audience "${audience}"`, ); } if ("exp" in claims) { validateTimestamp(claims.exp, '"exp" JWT claim'); } if ("iat" in claims) { validateTimestamp(claims.iat, '"iat" JWT claim'); } // If `iss` is provided, check if it matches the VP holder if ("iss" in claims && vp.holder) { const presentationHolder = typeof vp.holder === "string" ? vp.holder : vp.holder.id; if (claims.iss !== presentationHolder) { throw new ValidationError( `"iss" JWT claim must match the Verifiable Presentation holder "${presentationHolder}"`, ); } } // If `jti` is provided, check if it matches the vp ID if ("jti" in claims && vp.id && claims.jti !== vp.id) { throw new ValidationError( `"jti" JWT claim must match the Verifiable Presentation ID "${vp.id}"`, ); } if ("nbf" in claims) { validateTimestamp(claims.nbf, '"nbf" JWT claim'); if ("exp" in claims && claims.nbf > (claims.exp as number)) { throw new ValidationError( `"nbf" JWT claim can't be more recent than "exp" JWT claim`, ); } } // If `sub` is provided, check if it matches the VC credentialSubject if ("sub" in claims && vp.holder) { const presentationHolder = typeof vp.holder === "string" ? vp.holder : vp.holder.id; if (claims.sub !== presentationHolder) { throw new ValidationError( `"sub" JWT claim must match the Verifiable Presentation holder "${presentationHolder}"`, ); } } } /** * Validates the JWT header according to https://www.w3.org/TR/2025/REC-vc-jose-cose-20250515/#jose-header-parameters-jwt-claims * * `alg` REQUIRED. The signature algorithm used to sign the VP. * `kid` REQUIRED. It MUST point to a DID URI resolving to an issuer's key in the DID document. * `typ` OPTIONAL. Should be "vp+jwt" if present. * * @param claims - JWT header * @param partialHeader - If the header is partial */ export function validateVpJwtHeader( header: unknown, partialHeader?: false, ): asserts header is VpJwtHeader; export function validateVpJwtHeader( header: unknown, partialHeader: true, ): asserts header is Partial; export function validateVpJwtHeader(header: unknown, partialHeader = false) { if (typeof header !== "object" || header === null) { throw new ValidationError( "Invalid JWT header. The value must be an object.", ); } if (!("alg" in header) && !partialHeader) { throw new ValidationError(`"alg" JWT header is required`); } if ("alg" in header) { if (typeof header.alg !== "string") { throw new ValidationError(`"alg" JWT header must be a string`); } if (!["EdDSA", "ES256", "ES256K"].includes(header.alg)) { throw new ValidationError(`${header.alg} is not a supported alg`); } } if (!("kid" in header) && !partialHeader) { throw new ValidationError(`"kid" JWT header is required`); } if ("kid" in header) { if (typeof header.kid !== "string") { throw new ValidationError(`"kid" JWT header is required`); } // In this test, we simply check if the kid is a valid EBSI DID. // Another test will try to resolve a DID document based on this, // so we don't need to check if it's registered here. try { validateDid(header.kid.split("#")[0]); } catch (error) { throw new ValidationError( error instanceof Error ? error.message : '"kid" is not a valid EBSI DID', ); } } if ("typ" in header && header.typ !== "vp+jwt") { throw new ValidationError('"typ" JWT header should be "vp+jwt"'); } }