import type { EbsiUriComponents } from "@cef-ebsi/ebsi-uri"; import type { AxiosResponse, RawAxiosRequestHeaders } from "axios"; import type { JWTHeader, JWTPayload, JWTVerified, JWTVerifyOptions, } from "did-jwt"; import type { DIDDocument, DIDResolutionMetadata, JsonWebKey, VerificationMethod, } from "did-resolver"; import { EBSI_DID_METHOD_PREFIX, validate as validateEbsiDid, } from "@cef-ebsi/ebsi-did-resolver"; import { parse, parseUrl } from "@cef-ebsi/ebsi-uri"; import { KEY_DID_METHOD_PREFIX, util } from "@cef-ebsi/key-did-resolver"; import { AggregateAjvError } from "@segment/ajv-human-errors"; import { decodeJWT, verifyJWT } from "did-jwt"; import { parse as parseDid, Resolver } from "did-resolver"; import { base16 } from "multiformats/bases/base16"; import { base64 } from "multiformats/bases/base64"; import { Inflate } from "pako"; import type { EbsiEnvConfiguration, EbsiVerifiableAttestation, Service, VcJwtPayload, VerifyCredentialOptions, } from "./types.ts"; import { JsonSchemaLoadingError, JsonSchemaValidationError, ValidationError, } from "./errors/index.ts"; import { AXIOS_TIMEOUT, equalJWKs, getEbsiResource, getErrorMessage, loadSchema, } from "./utils.ts"; import { getAjvInstance, validateEbsiVerifiableAttestation, } from "./validators/ajv.ts"; import { isSelfAttestationForTrustChain, validateAccreditations, } from "./validators/validateAccreditations.ts"; import { validateDates } from "./validators/validateDates.ts"; // Re-export validators export * from "./validators/ajv.ts"; export * from "./validators/validateAccreditations.ts"; export * from "./validators/validateDates.ts"; type JWTHeaderWithKid = JWTHeader & { kid: string }; /** * Handles DID resolution errors * @param didDocument - Resolved DID Document * @param didResolutionMetadata - DID Resolution Metadata * @param did - DID that has been resolved */ export function handleDidResolutionErrors( didDocument: DIDDocument | null, didResolutionMetadata: DIDResolutionMetadata, did: string, ): asserts didDocument is DIDDocument { if (!didDocument) { let errorMessage = `Unable to resolve ${did}. Error: ${ didResolutionMetadata.error ?? "unknown" }`; if (typeof didResolutionMetadata["message"] === "string") { errorMessage += `. ${didResolutionMetadata["message"]}`; } throw new ValidationError(errorMessage); } } /** * Validates the format of headerJwk and validates that it matches with the signerJwk * @param headerJwk - JWK in the header * @param signerJwk - JWK of the signer */ export function validateAndCompareJwk( headerJwk: unknown, signerJwk?: JsonWebKey, ) { try { util.validateJwk(headerJwk); } catch (error) { throw new ValidationError( `Invalid JWK found in the header: ${(error as Error).message}`, ); } if (!signerJwk || !equalJWKs(headerJwk, signerJwk)) { throw new ValidationError( `The JWK from the header does not match with the verification method's public key that verifies the signature`, ); } } /** * Validates that the '\@context' is conform to the W3C specs. * @param context - A set of URIs to validate */ export function validateContext(context: string[]) { if (!Array.isArray(context)) { 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 the credential payload according to its schema, as defined in `payload.credentialSchema`. * This validator only runs when the credential schema type is "FullJsonSchemaValidator2021" or * "JsonSchema". * * Notes: * - make sure to run `validateEbsiVerifiableAttestation` on the payload before calling `validateDates`. * @param payload - `EbsiVerifiableAttestation` * @param config - EBSI environment configuration * @param timeout - Axios requests timeout. Default: 15 seconds * @param extraTypes - List of extra valid types for which no error is thrown. */ export async function validateCredentialSchema( payload: EbsiVerifiableAttestation, config: EbsiEnvConfiguration, timeout?: number, extraTypes: string[] = [], ): Promise { const { credentialSchema } = payload; const jsonSchemaTypes = ["FullJsonSchemaValidator2021", "JsonSchema"]; const validTypes = new Set([...extraTypes, ...jsonSchemaTypes]); const credentialSchemaArray = Array.isArray(credentialSchema) ? credentialSchema : [credentialSchema]; if (credentialSchemaArray.length === 0) { throw new ValidationError("credentialSchema is required"); } // Check if the credential schema array contains an invalid type const invalidCredentialSchema = credentialSchemaArray.find( (credSchema) => !validTypes.has(credSchema.type), ); if (invalidCredentialSchema) { throw new ValidationError( `Unsupported credential schema type ${invalidCredentialSchema.type}`, ); } // Extract IDs when type is "FullJsonSchemaValidator2021" or "JsonSchema" const schemaIds = credentialSchemaArray .filter((credSchema) => jsonSchemaTypes.includes(credSchema.type)) .map((credSchema) => credSchema.id); // If there are no schemas to validate, return if (schemaIds.length === 0) { return; } // Validate all credentialSchema const ajv = getAjvInstance(config, timeout); await Promise.all( schemaIds.map(async (originalSchemaId) => { const schemaId = originalSchemaId.split("/").pop(); if (!schemaId) { throw new ValidationError( `Can't extract schema ID from "${originalSchemaId}"`, ); } try { if (!ajv.getSchema(schemaId)) { const trustedSchema = await loadSchema( config, timeout, )(originalSchemaId); // Run compileAsync to get all referenced schemas await ajv.compileAsync(trustedSchema); // Add schema to cache if (!ajv.getSchema(schemaId)) { ajv.addSchema(trustedSchema, schemaId); } } } catch (error) { if (error instanceof ValidationError) { throw error; } throw new ValidationError( `JSON schema "${originalSchemaId}" not found. Reason: ${ (error as Error).message }`, ); } const validate = ajv.getSchema(schemaId); if (!validate) { throw new JsonSchemaLoadingError( `Unable to get JSON schema "${originalSchemaId}"`, ); } // Validate the payload const valid = validate(payload); if (!valid && validate.errors) { const errors = new AggregateAjvError(validate.errors, { fieldLabels: "jsonPointer", }); throw new JsonSchemaValidationError( `Invalid VC payload. ${errors.message}`, validate.errors, ); } }), ); } /** * Validates that the credential is not revoked nor suspended when it contains a `credentialStatus` * property. * @see https://w3c.github.io/vc-status-list-2021/ * @param payload - `EbsiVerifiableAttestation` * @param resolver - DID resolver * @param config - EBSI environment configuration * @param options - Configuration object containing the timeout for the HTTP requests */ export async function validateCredentialStatus( payload: Pick< EbsiVerifiableAttestation, "credentialStatus" | "credentialSubject" >, resolver: Resolver, config: EbsiEnvConfiguration, options?: VerifyCredentialOptions, ) { const { credentialStatus, credentialSubject } = payload; if ( !credentialStatus || (Array.isArray(credentialStatus) && credentialStatus.length === 0) ) { // Return without throwing an error return; } const credentialStatusArray = Array.isArray(credentialStatus) ? credentialStatus : [credentialStatus]; // Validate type for (const { type } of credentialStatusArray) { if (!["EbsiAccreditationEntry", "StatusList2021Entry"].includes(type)) { throw new ValidationError( `The credentialStatus type ${type} is not supported`, ); } } // Validate status lists // TODO: if 1 credential status is invalid, abort the other requests await Promise.all( credentialStatusArray.map(async (credStatus) => { if (credStatus.type === "StatusList2021Entry") { await validateStatusList2021Entry( credStatus, resolver, config, options, ); return; } if (credStatus.type === "EbsiAccreditationEntry") { await validateEbsiAccreditationEntry( credStatus, credentialSubject, config, options?.timeout, options?.axiosHeaders, ); } // Other types are not supported }), ); } /** * Validates the Verifiable Credential `credentialSubject` property. * * Rules: * - The credential subject must be a valid EBSI DID except when the credential type is `StatusList2021Credential`. * - If credentialSubject is an array, at least 1 ID must match the `sub` property * @param payload - `EbsiVerifiableAttestation` * @param sub - Credential subject DID that must appear at least once in credentialSubject */ export function validateCredentialSubject( payload: Pick, sub?: string, ): void { // Do not validate the credentialSubject of a StatusList2021Credential if (payload.type.includes("StatusList2021Credential")) { return; } const { credentialSubject } = payload; if (!Array.isArray(credentialSubject)) { const subject = credentialSubject.id; if (!subject) { throw new ValidationError("credentialSubject ID is required"); } try { // Accept both `did:ebsi` v1 and `did:key` methods validateDid(subject); } catch (error) { throw new ValidationError( `Invalid credential subject "${subject}". Reason: ${error instanceof Error ? error.message : "unknown"}`, ); } return; } if (!sub) { throw new ValidationError("sub is required"); } if (!credentialSubject.some(({ id }) => id === sub)) { throw new ValidationError( "At least 1 credentialSubject ID must match the sub property", ); } // All credentialSubject IDs must be valid EBSI DIDs for (const { id } of credentialSubject) { if (!id) { throw new ValidationError("credentialSubject ID is required"); } try { // Accept both `did:ebsi` v1 and `did:key` methods validateDid(id); } catch (error) { throw new ValidationError( `Invalid credential subject "${id}". Reason: ${error instanceof Error ? error.message : "unknown"}`, ); } } } /** * Validates that the given value is a well-formatted EBSI DID (Legal Entity or Natural Person). * Throws a `ValidationError` if it is not. * * Note: this function doesn't check if the DID is registered in the DID Registry. * @param value - The DID to validate * @param options - Whether to allow the DID URL to contain a query, path or fragment */ export function validateDid( value: unknown, options?: { /** * Allow DID to contain a fragment */ allowFragment?: boolean; /** * Allow DID to contain a path */ allowPath?: boolean; /** * Allow DID to contain a query */ allowQuery?: boolean; }, ): asserts value is string { if (typeof value !== "string") { throw new ValidationError("The DID must be a string"); } try { const parsedDid = parseDid(value); if (!parsedDid) { throw new Error("The DID URL can't be parsed"); } if (parsedDid.path && !options?.allowPath) { throw new Error("The DID URL must not contain a DID path"); } if (parsedDid.query && !options?.allowQuery) { throw new Error("The DID URL must not contain a DID query"); } if (parsedDid.fragment && !options?.allowFragment) { throw new Error("The DID URL must not contain a DID fragment"); } // Get DID (without query parameters or fragment) const { did } = parsedDid; if (did.startsWith(EBSI_DID_METHOD_PREFIX)) { // Use @cef-ebsi/ebsi-did-resolver validator validateEbsiDid(did); } else if (did.startsWith(KEY_DID_METHOD_PREFIX)) { // Use @cef-ebsi/key-did-resolver validator util.validateDid(did); } else { throw new Error( `The DID must start with "${EBSI_DID_METHOD_PREFIX}" or "${KEY_DID_METHOD_PREFIX}"`, ); } } catch (error) { throw new ValidationError( error instanceof Error ? error.message : `${value} is not a valid EBSI DID`, ); } } /** * Validates that the EbsiAccreditationEntry credential is not revoked nor suspended. * @param credentialStatus - The `credentialStatus` object to validate * @param credentialSubject - The `credentialSubject` object related to the `credentialStatus` * @param config - EBSI environment configuration * @param timeout - Axios requests timeout. Default: 15 seconds */ export async function validateEbsiAccreditationEntry( credentialStatus: EbsiVerifiableAttestation["credentialStatus"], credentialSubject: EbsiVerifiableAttestation["credentialSubject"], config: EbsiEnvConfiguration, timeout?: number, axiosHeaders?: RawAxiosRequestHeaders, ) { if (!credentialStatus) { throw new ValidationError("credentialStatus is required"); } if (Array.isArray(credentialStatus)) { throw new ValidationError( "The credentialStatus passed to validateStatusList2021Entry must be a single element", ); } if (credentialStatus.type !== "EbsiAccreditationEntry") { throw new ValidationError( 'The credentialStatus type must be "EbsiAccreditationEntry"', ); } // In an EBSI Verifiable Accreditation Record, the credentialSubject MUST be an object, not an array if (Array.isArray(credentialSubject)) { throw new ValidationError("credentialSubject can't be an array"); } if (!credentialSubject.id) { throw new ValidationError( 'The credentialSubject "id" property is required', ); } if (!credentialSubject["reservedAttributeId"]) { throw new ValidationError( 'The credentialSubject "reservedAttributeId" property is required', ); } if (typeof credentialSubject["reservedAttributeId"] !== "string") { throw new ValidationError( 'The credentialSubject "reservedAttributeId" property must be a valid string', ); } let decodedReservedAttributeId: Uint8Array; try { decodedReservedAttributeId = base16.baseDecode( credentialSubject["reservedAttributeId"].replace("0x", ""), // Remove 0x prefix if present. TIR API accepts both with and without prefix ); } catch { throw new ValidationError( 'The credentialSubject "reservedAttributeId" property must be encoded in hexadecimal', ); } if (decodedReservedAttributeId.byteLength !== 32) { throw new ValidationError( 'The credentialSubject "reservedAttributeId" property must be 32 bytes long', ); } // Extract EBSI URI components let ebsiUriComponents: EbsiUriComponents; try { ebsiUriComponents = credentialStatus.id.startsWith("ebsi:") ? parse(credentialStatus.id, config) : parseUrl(credentialStatus.id, config); } catch (error) { throw new ValidationError( `Error while parsing attribute URL ${credentialStatus.id}. ${error instanceof Error ? error.message : "Reason: unknown"}`, ); } // Validate service if (ebsiUriComponents.service !== "trusted-issuers-registry") { throw new Error( `Service mismatch: ${credentialStatus.id} is not part of the trusted-issuers-registry service`, ); } const expectedResource = `/issuers/${credentialSubject.id}/attributes/${credentialSubject["reservedAttributeId"]}`; if (ebsiUriComponents.resource !== expectedResource) { throw new ValidationError( `The credentialStatus "id" property must match the resource ${expectedResource}`, ); } // Fetch attribute (assume response is correctly formatted) let attributeResponse: AxiosResponse<{ attribute: { body: string; hash: string; issuerType: "Revoked" | "RootTAO" | "TAO" | "TI" | "undefined"; rootTao: string; tao: string; }; // Import type from TIR API v4 @Get("/:did/attributes/:attributeId") endpoint did: string; }>; try { attributeResponse = await getEbsiResource( ebsiUriComponents, config, timeout ?? AXIOS_TIMEOUT, false, axiosHeaders, ); } catch (error) { throw new ValidationError( `Unable to fetch the attribute ${credentialStatus.id}. Reason: ${getErrorMessage(error)}`, ); } // Parse and validate response const attributeData = attributeResponse.data; if (attributeData.did !== credentialSubject.id) { throw new ValidationError( `The attribute "did" property must match the credentialSubject "id" property`, ); } const { attribute } = attributeData; if (attribute.issuerType === "Revoked") { throw new ValidationError("The credential is revoked"); } } /** * Validates the credential issuer. * @param payload - `EbsiVerifiableAttestation` * @param did - The DID of the credential signer * @param kid - The `kid` identifying the verification method used to sign the JWT * @param alg - The algorithm used to sign the JWT * @param resolver - DID resolver * @param config - EBSI environment configuration * @param selfAttestationForTrustChain - Skip Trusted Issuer's verification if the VC is a self-attestation for a trust chain. * @param timeout - Axios requests timeout. Default: 15 seconds * @param axiosHeaders - Custom Axios request headers */ export async function validateIssuer( payload: Pick, did: string, kid: string | undefined, alg: string | undefined, resolver: Resolver, config: EbsiEnvConfiguration, selfAttestationForTrustChain: boolean, timeout = AXIOS_TIMEOUT, axiosHeaders?: RawAxiosRequestHeaders, ): Promise> { const credentialIssuer = typeof payload.issuer === "string" ? payload.issuer : payload.issuer.id; try { validateDid(credentialIssuer); } catch (error) { throw new ValidationError( `Invalid issuer "${credentialIssuer}". Reason: ${error instanceof Error ? error.message : "unknown"}`, ); } // Make sure the DIDs match if (credentialIssuer !== did) { throw new ValidationError( `payload.issuer "${credentialIssuer}" and did "${did}" 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] !== credentialIssuer) { throw new ValidationError("did and kid don't match"); } // Verify that the DID document can be resolved const didResolutionResult = await resolver.resolve(credentialIssuer, { axiosHeaders, timeout, }); const { didDocument, didResolutionMetadata } = didResolutionResult; handleDidResolutionErrors( didDocument, didResolutionMetadata, credentialIssuer, ); // Retrieve verification method(s) used to sign the VC if (typeof alg !== "string") { throw new ValidationError("alg is required"); } // Check "assertionMethod" verification relationship // @ts-expect-error TS 5.4 doesn't know that the result of .filter() is not undefined const verificationMethods: VerificationMethod[] = ( didDocument.assertionMethod ?? [] ) .map((method) => { if (typeof method === "string") { // Get verificationMethod corresponding to assertionMethod ID return didDocument.verificationMethod?.find((vm) => vm.id === method); } return method; }) .filter((method) => { return ( method && method.id === kid && verificationMethodMatchesAlg(method, alg) ); }); if (verificationMethods.length === 0) { throw new ValidationError( `Could not find a verification method with the "assertionMethod" relationship related to "${kid}" for algorithm "${alg}"`, ); } const isLegalEntity = credentialIssuer.startsWith(EBSI_DID_METHOD_PREFIX); if (isLegalEntity && !selfAttestationForTrustChain) { try { await getEbsiResource( { network: config.network.name, resource: `/issuers/${credentialIssuer}`, scheme: config.scheme, service: "trusted-issuers-registry", }, config, timeout, false, axiosHeaders, ); } catch { throw new ValidationError( `"${credentialIssuer}" is not a trusted issuer`, ); } } return { authenticators: verificationMethods, didResolutionResult, issuer: credentialIssuer, }; } /** * Validates the VC JWT properties. Ensures that all the properties are available and match the VC. * @param jwt - The VC JWT * @param config - EBSI environment configuration * @param timeout - Axios requests timeout. Default: 15 seconds */ export function validateJwtProps( jwt: string, config: EbsiEnvConfiguration, timeout?: number, ): { header: JWTHeaderWithKid; payload: VcJwtPayload } { 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 VC. * 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 (!header.alg || 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. validateDid(header["kid"], { allowFragment: true }); // did-jwt assumes that the "typ" header is always "JWT", but this may not be the case // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!header.typ || header.typ !== "JWT") { throw new ValidationError(`"typ" JWT header must be "JWT"`); } /** * Payload * * iss REQUIRED. MUST match the value of the property issuer of the Verifiable Credential. * sub REQUIRED. MUST match the value of the property credentialSubject.id of the Verifiable Credential. * jti REQUIRED. MUST match the value of the property id of Verifiable Credential. * iat REQUIRED. MUST match the value of the property issuanceDate of Verifiable Credential. * nbf REQUIRED. MUST match the value of the property validFrom of Verifiable Credential. * exp REQUIRED (if vc.expirationDate exists). MUST match the value of the property expirationDate of Verifiable Credential. * vc REQUIRED. MUST be a valid Verifiable Credential JSON object. */ const { exp, iat, iss, jti, nbf, sub, vc } = payload; validateEbsiVerifiableAttestation(vc, config, timeout); const credentialIssuer = typeof vc.issuer === "string" ? vc.issuer : vc.issuer.id; if (!iss || iss !== credentialIssuer) { throw new ValidationError( `JWT "iss" property MUST match the VC issuer "${credentialIssuer}"`, ); } if (Array.isArray(vc.credentialSubject)) { const ids = vc.credentialSubject.map((c) => c.id).filter(Boolean); // If there's at least 1 credentialSubject with an ID, check if the sub matches one of the IDs if (ids.length > 0 && (!sub || !ids.includes(sub))) { throw new ValidationError( `JWT "sub" property MUST match the VC credentialSubject "${ids.join( '" or "', )}"`, ); } } else if (sub !== vc.credentialSubject.id) { throw new ValidationError( `JWT "sub" property MUST match the VC credentialSubject "${ vc.credentialSubject.id ?? "" }"`, ); } if (!jti || jti !== vc.id) { throw new ValidationError( `JWT "jti" property MUST match the VC ID "${vc.id}"`, ); } const issued = Math.floor(Date.parse(vc.issued) / 1000); if (!iat || iat !== issued) { throw new ValidationError( `JWT "iat" property MUST match the VC issued "${issued.toString()}"`, ); } const validFrom = Math.floor(Date.parse(vc.validFrom) / 1000); if (!nbf || nbf !== validFrom) { throw new ValidationError( `JWT "nbf" property MUST match the VC validFrom "${validFrom.toString()}"`, ); } if (vc.expirationDate) { const expirationDate = Math.floor(Date.parse(vc.expirationDate) / 1000); if (!exp || exp !== expirationDate) { throw new ValidationError( `JWT "exp" property MUST match the VC expirationDate "${expirationDate.toString()}"`, ); } } return { header: header as JWTHeaderWithKid, payload: payload as VcJwtPayload, }; } /** * Validates that the StatusList2021Entry credential is not revoked nor suspended. * @see https://w3c.github.io/vc-status-list-2021/ * @param credentialStatus - The `credentialStatus` object to validate * @param resolver - DID resolver * @param config - EBSI environment configuration * @param options - Options object for verifying the VC */ export async function validateStatusList2021Entry( credentialStatus: NonNullable, resolver: Resolver, config: EbsiEnvConfiguration, options?: VerifyCredentialOptions, ) { if (Array.isArray(credentialStatus)) { throw new ValidationError( "The credentialStatus passed to validateStatusList2021Entry must be a single element", ); } if (credentialStatus.type !== "StatusList2021Entry") { throw new ValidationError( 'The credentialStatus type must be "StatusList2021Entry"', ); } if (!("statusPurpose" in credentialStatus)) { throw new ValidationError( 'The credentialStatus MUST contain a "statusPurpose" property', ); } const { statusPurpose } = credentialStatus; if (statusPurpose !== "revocation" && statusPurpose !== "suspension") { throw new ValidationError( 'The credentialStatus "statusPurpose" property must be either "revocation" or "suspension"', ); } if (!("statusListIndex" in credentialStatus)) { throw new ValidationError( 'The credentialStatus MUST contain a "statusListIndex" property', ); } if (typeof credentialStatus["statusListIndex"] !== "string") { throw new ValidationError( 'The credentialStatus "statusListIndex" property must be a number expressed as a string', ); } const statusListIndex = Number.parseInt( credentialStatus["statusListIndex"], 10, ); if (Number.isNaN(statusListIndex)) { throw new ValidationError( 'The credentialStatus "statusListIndex" property is not a valid number expressed as a string', ); } if (statusListIndex < 0) { throw new ValidationError( 'The credentialStatus "statusListIndex" property must be greater or equal to 0', ); } if (!("statusListCredential" in credentialStatus)) { throw new ValidationError( 'The credentialStatus MUST contain a "statusListCredential" property', ); } const statusListCredentialUrl = credentialStatus["statusListCredential"]; if (typeof statusListCredentialUrl !== "string") { throw new ValidationError( 'The credentialStatus "statusListCredential" property must be a string', ); } let ebsiUriComponents: EbsiUriComponents; try { ebsiUriComponents = statusListCredentialUrl.startsWith("ebsi:") ? parse(statusListCredentialUrl, config) : parseUrl(statusListCredentialUrl, config); } catch (error) { throw new ValidationError( `Error while parsing status list credential URL ${statusListCredentialUrl}. ${error instanceof Error ? error.message : "Reason: unknown"}`, ); } // Validate service if (ebsiUriComponents.service !== "trusted-issuers-registry") { throw new Error( `Service mismatch: ${statusListCredentialUrl} is not part of the trusted-issuers-registry service`, ); } // Fetch statusListCredentialUrl, verify it is a valid StatusList2021Credential and that the statusListIndex is not revoked or suspended let statusListCredentialUrlResponse: Awaited< ReturnType >; try { statusListCredentialUrlResponse = await getEbsiResource( ebsiUriComponents, config, options?.timeout ?? AXIOS_TIMEOUT, false, options?.axiosHeaders, ); } catch (error) { throw new ValidationError( `Unable to fetch the StatusList2021Credential: ${statusListCredentialUrl}. Reason: ${getErrorMessage( error, )}`, ); } // Parse and validate response. It must be a valid StatusList2021Credential JWT. const statusListCredentialJwt = statusListCredentialUrlResponse.data; if (typeof statusListCredentialJwt !== "string") { throw new ValidationError( "The StatusList2021Credential must be a JWT string", ); } let statusListCredential: EbsiVerifiableAttestation; try { statusListCredential = await verifyVcJwt( statusListCredentialJwt, resolver, config, { ...options, // the Status List Credential does not require terms of use validateAccreditationWithoutTermsOfUse: false, }, ); } catch (error) { throw new ValidationError( `The StatusList2021Credential JWT is not valid: ${ error instanceof Error ? error.message : "unknown error" }`, ); } // Possible improvements: // - Check that the issuer is the same as the issuer of the VC if (!("type" in statusListCredential.credentialSubject)) { throw new ValidationError( 'The StatusList2021Credential "credentialSubject" property MUST contain a "type" property', ); } if (statusListCredential.credentialSubject["type"] !== "StatusList2021") { throw new ValidationError( 'The StatusList2021Credential "credentialSubject.type" property MUST be "StatusList2021"', ); } if (!("statusPurpose" in statusListCredential.credentialSubject)) { throw new ValidationError( 'The StatusList2021Credential "credentialSubject" property MUST contain a "statusPurpose" property', ); } if ( statusListCredential.credentialSubject["statusPurpose"] !== statusPurpose ) { throw new ValidationError( 'The StatusList2021Credential "credentialSubject.statusPurpose" property MUST match the "statusPurpose" property of the VC', ); } if (!("encodedList" in statusListCredential.credentialSubject)) { throw new ValidationError( 'The StatusList2021Credential "credentialSubject" property MUST contain a "encodedList" property', ); } const { encodedList } = statusListCredential.credentialSubject; if (typeof encodedList !== "string") { throw new ValidationError( 'The StatusList2021Credential "credentialSubject.encodedList" property MUST be a string', ); } // The encodedList property of the credential subject MUST be the GZIP-compressed [RFC1952], // base-64 encoded [RFC4648] bitstring values for the associated range of verifiable credential // status values. The uncompressed bitstring MUST be at least 16KB in size. The bitstring MUST be // encoded such that the first index, with a value of zero (0), is located at the left-most bit // in the bitstring and the last index, with a value of one less than the length of the bitstring // (bitstring_length - 1), is located at the right-most bit in the bitstring. // Transform the base64 encoded string into a Uint8Array let decodedList: Uint8Array; try { decodedList = base64.baseDecode(encodedList); } catch { throw new ValidationError( 'The StatusList2021Credential "credentialSubject.encodedList" property MUST be a valid base64 string', ); } // Inflate decodedList const inflator = new Inflate(); inflator.push(decodedList); if (inflator.err) { throw new ValidationError( "The StatusList2021Credential is not a valid GZIP-compressed bitstring", ); } const inflatedList = inflator.result as Uint8Array; const bytePosition = Math.floor(statusListIndex / 8); const bitPosition = statusListIndex % 8; const byte = inflatedList.at(bytePosition); if (byte === undefined) { throw new ValidationError( "The StatusList2021Credential encoded list doesn't contain the statusListIndex", ); } // Transform number to base2 string, pad with 0 to have 8 bits, get the bit at the bitPosition const bit = byte.toString(2).padStart(8, "0").at(bitPosition); if (bit === undefined) { throw new ValidationError( "The StatusList2021Credential encoded list doesn't contain the statusListIndex", ); } // bit="0" -> ok, bit="1" -> revoked/suspended if (bit === "1") { throw new ValidationError( `The credential is ${ statusPurpose === "revocation" ? "revoked" : "suspended" }`, ); } } /** * 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] !== "VerifiableCredential") { throw new ValidationError('The first type must be "VerifiableCredential"'); } } /** * Checks if the verification method matches the given algorithm * @param method - The DID Method to validate * @param alg - The algorithm used to sign the VC */ export function verificationMethodMatchesAlg( method: VerificationMethod, alg: string, ) { // Only support JsonWebKey2020, ignore all other types // TODO: support more types if (method.type !== "JsonWebKey2020") { return false; } if (!method.publicKeyJwk) { return false; } switch (alg) { case "EdDSA": { return ( method.publicKeyJwk.kty === "OKP" && method.publicKeyJwk.crv === "Ed25519" ); } case "ES256": { return ( method.publicKeyJwk.kty === "EC" && method.publicKeyJwk.crv === "P-256" ); } case "ES256K": { return ( method.publicKeyJwk.kty === "EC" && method.publicKeyJwk.crv === "secp256k1" ); } default: { return false; } } } /** * Verifies the VC JWT * @param vc - The VC JWT * @param resolver - DID resolver * @param config - EBSI environment configuration * @param options - Validation options * @returns The EBSI Verifiable Attestation */ export async function verifyVcJwt( vc: string, resolver: Resolver, config: EbsiEnvConfiguration, options?: VerifyCredentialOptions, ): Promise { const { header, payload } = validateJwtProps(vc, config, options?.timeout); const { vc: credentialPayload } = payload; const didAuthenticator = await validateIssuer( credentialPayload, payload.iss, header.kid, header.alg, resolver, config, isSelfAttestationForTrustChain(credentialPayload), options?.timeout, options?.axiosHeaders, ); let resultVerifyJWT: JWTVerified; try { resultVerifyJWT = await verifyJWT(vc, { didAuthenticator, policies: { aud: false, exp: false, iat: false, nbf: false, }, resolver, }); } catch (error) { const errorMessage = error instanceof Error ? error.message : "Reason unknown"; throw new ValidationError(`VC JWT validation failed: ${errorMessage}`); } if (header["jwk"]) { validateAndCompareJwk(header["jwk"], resultVerifyJWT.signer.publicKeyJwk); } // Additional EBSI rules validateContext(credentialPayload["@context"]); validateType(credentialPayload.type); // Validate dates (especially issuanceDate/validFrom) validateDates( credentialPayload, options?.validAt ?? Math.floor(Date.now() / 1000), options?.clockSkew, ); if (!options?.skipCredentialSubjectValidation) { // Check if credentialSubject is a valid EBSI DID validateCredentialSubject(credentialPayload, payload.sub); } // Validate that the VC is conform to the schema await validateCredentialSchema( credentialPayload, config, options?.timeout, options?.extraCredentialSchemaTypes, ); if (!options?.skipAccreditationsValidation) { // Validate trust chain of accreditations await validateAccreditations(credentialPayload, config, options); } if (!options?.skipStatusValidation) { await validateCredentialStatus( credentialPayload, resolver, config, options, ); } return credentialPayload; }