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/vcdm11.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/vcdm1.1-presentation-schema"; import { JsonSchemaLoadingError, JsonSchemaValidationError, validateDid, ValidationError, verificationMethodMatchesAlg, } from "@europeum-ebsi/verifiable-credential"; import { getAjvInstance as vcLibGetAjvInstance, verifyCredentialJwt, } from "@europeum-ebsi/verifiable-credential/vcdm11.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, 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/2018/credentials/v1", ...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/2018/credentials/v1") { throw new ValidationError( 'The first URI in "@context" must be "https://www.w3.org/2018/credentials/v1"', ); } } /** * 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 JWT sub. It MUST match the VP holder DID. 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 { try { await Promise.all( payload.verifiableCredential.map(async (vcJwt) => { if (typeof vcJwt === "string") { return validateCredentialJwt(vcJwt, holder, config, options); } throw new ValidationError("Unsupported verifiableCredential type"); }), ); } 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 - VC payload * @param holder - The VP holder's DID */ export function validateCredentialSubject( payload: Partial, holder: string, ) { if (typeof payload.sub !== "string") { throw new ValidationError("JWT payload missing a sub field"); } if (payload.sub !== holder) { throw new ValidationError( `VC subject "${payload.sub}" and VP holder "${holder}" 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" * @param timeout - Axios timeout * @param skipHolderDidResolutionValidation - If true, the holder DID will not be resolved * @param axiosHeaders - Custom Axios request headers */ export async function validateHolder( payload: Pick, holder: string, kid: string, alg: string | undefined, issuedAt: number, resolver: Resolver, proofPurpose: ProofPurposeTypes, timeout: number, skipHolderDidResolutionValidation: boolean, axiosHeaders: RawAxiosRequestHeaders, ): Promise { validateDid(holder); if (payload.holder !== holder) { throw new ValidationError( `payload.holder "${payload.holder}" and holder "${holder}" 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] !== holder) { 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 = holder; if (holder.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 * 1000) .toISOString() .replace(".000Z", "Z"); didUrl = `${holder}?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 ${holder}. Error: ${didResolutionMetadata.error}. ${didResolutionMetadata["message"]}`, ); } throw new ValidationError( `Couldn't find the DID document associated with ${holder}`, ); } // 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: holder, }; } /** * 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"); } /** * Header * * 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 REQUIRED. MUST be JWT. */ if (typeof header.alg !== "string") { throw new ValidationError(`"alg" JWT header is required`); } if (!["EdDSA", "ES256", "ES256K"].includes(header.alg)) { throw new ValidationError(`${header.alg} is not a supported alg`); } if (!header.kid || 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 (header.typ !== "JWT") { throw new ValidationError(`"typ" JWT header must be "JWT"`); } /** * Payload * * iss REQUIRED. MUST match the value of the property holder of the Verifiable Presentation. * sub REQUIRED. MUST match the value of the property credentialSubject.id of the Verifiable Credential(s). * aud REQUIRED. MUST represent the identity of the intended audience. * jti OPTIONAL. SHOULD be a unique identifier for the Verifiable Presentation. * iat REQUIRED. Time at which the Verifiable Presentation has been issued. * nbf REQUIRED. Time before which the Verifiable Presentation MUST NOT be accepted for processing. * exp REQUIRED. Expiration time after which the Verifiable Presentation MUST NOT be accepted for processing. * vp REQUIRED. MUST be a valid Verifiable Presentation JSON object. */ const { aud, exp, iat, iss, jti, nbf, sub, vp } = payload; validateEbsiVerifiablePresentation(vp, config, options.timeout); if (!iss || iss !== vp.holder) { throw new ValidationError( `JWT "iss" property MUST match the VP holder "${vp.holder}"`, ); } if (!sub || sub !== vp.holder) { throw new ValidationError( `JWT "sub" property MUST match the VP holder "${vp.holder}"`, ); } if (!aud || aud !== audience) { throw new ValidationError( `JWT "aud" property MUST match the expected audience "${audience}"`, ); } if (jti && jti !== vp["id"]) { throw new ValidationError( `JWT "jti" property MUST match the VP ID "${vp["id"] as string}"`, ); } if (!iat) { throw new ValidationError(`JWT "iat" property is required`); } if (iat) { validateTimestamp(iat); } if (!nbf) { throw new ValidationError(`JWT "nbf" property is required`); } if (nbf) { validateTimestamp(nbf); } if (!exp) { throw new ValidationError(`JWT "exp" property is required`); } if (exp) { validateTimestamp(exp); } if (nbf && exp && nbf > exp) { throw new ValidationError(`JWT "nbf" can't be more recent than "exp"`); } 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 as VpJwtHeader, payload: payload as VpJwtPayload }; } /** * Verifies the VP JWT signature. * @param jwt - Verifiable Presentation as JWT * @param resolver - DID resolver * @param didAuthenticator - DID resolution results, including resolved verification methods */ export async function validateSignature( jwt: string, resolver: Resolver, didAuthenticator: NonNullable, ) { try { const verifyVpResult = await verifyJWT(jwt, { didAuthenticator, policies: { aud: false, exp: false, iat: false, nbf: false, }, 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): void { if (typeof value === "number") { if (!(Number.isInteger(value) && value < 100_000_000_000)) { throw new ValidationError( `"${value.toString()}" is not a unix timestamp in seconds`, ); } } else { throw new ValidationError( "Invalid timestamp provided. Only numbers are supported.", ); } } /** * 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"', ); } }