import type { RawAxiosRequestHeaders } from "axios"; import type { DIDDocument } from "did-resolver"; import { parse, parseUrl } from "@cef-ebsi/ebsi-uri"; import { isAxiosError } from "axios"; import { decodeJWT } from "did-jwt"; import type { EbsiEnvConfiguration, EbsiVerifiableAccreditation, EbsiVerifiableAttestation, VerifyCredentialOptions, } from "../types.ts"; import { AXIOS_TIMEOUT, getEbsiResource, getErrorMessage, getEthereumAddress, } from "../utils.ts"; import { validateEbsiVerifiableAccreditation, validateEbsiVerifiableAttestation, } from "./ajv.ts"; import { validateDates } from "./validateDates.ts"; interface TrustedIssuerAttribute { attribute: { body: string; hash: string; issuerType: string; rootTao: string; tao: string; }; did: string; } interface TrustedPoliciesRegistryV2UserResponse { address: string; attributes: Record; } const acceptedTrustedPoliciesRegistryAttributes = [ "TIR:insertIssuer", "TIR:updateIssuer", "TIR:setAttributeMetadata", ] as const; /** * Function to determine if a VC is self attesting an accreditation * to issue credentials with type "VerifiableAuthorisationForTrustChain". * This self accreditation is done by Support Office. * * It requires in the VC: * - $.type to contain "VerifiableAccreditationToAttest" * - $.credentialSubject.accreditedFor to contain "VerifiableAuthorisationForTrustChain" * * @param vcPayload - The verifiable credential payload to validate * @returns true if the VC is self attesting an accreditation */ export function isSelfAttestationForTrustChain( vcPayload: EbsiVerifiableAttestation, ): boolean { const { credentialSubject, type } = vcPayload; if ( !Array.isArray(type) || // TODO: fix known limitation (currently only accepts credentialSubject as object). Array.isArray(credentialSubject) ) { return false; } const issuer = typeof vcPayload.issuer === "string" ? vcPayload.issuer : vcPayload.issuer.id; if (issuer !== credentialSubject.id) return false; if (!type.includes("VerifiableAccreditationToAttest")) return false; const { accreditedFor } = credentialSubject; if (!accreditedFor || !Array.isArray(accreditedFor)) return false; return accreditedFor.some( (accreditedForValue: unknown) => accreditedForValue && typeof accreditedForValue === "object" && "types" in accreditedForValue && Array.isArray(accreditedForValue.types) && accreditedForValue.types.includes("VerifiableAuthorisationForTrustChain"), ); } /** * Function to validate if a verifiable credential has the correct * trust chain of accreditations in the Trusted Issuers Registry * * @param vcPayload - The verifiable credential payload to validate * @param config - EBSI environment configuration * @param options - Accreditations validation options */ export async function validateAccreditations( vcPayload: EbsiVerifiableAttestation, config: EbsiEnvConfiguration, options?: VerifyCredentialOptions, ): Promise { const issuer = typeof vcPayload.issuer === "string" ? vcPayload.issuer : vcPayload.issuer.id; let { termsOfUse } = vcPayload; const { credentialSubject, type } = vcPayload; let typesSubject = [...new Set(type)]; if (!termsOfUse) { if (!options?.validateAccreditationWithoutTermsOfUse) { // Exit early if there is no termsOfUse to validate return; } if (isSelfAttestationForTrustChain(vcPayload)) { // It is a self attestation to issue credentials with type VerifiableAuthorisationForTrustChain await validateIssuerCanSelfAttestForTrustChain( vcPayload, config, options.timeout, options.axiosHeaders, ); return; } throw new Error("Credential doesn't contain terms of use"); } if (!Array.isArray(termsOfUse)) { termsOfUse = [termsOfUse]; } // Include in typesSubject the types defined in accreditedFor const credentialSubjectList = Array.isArray(credentialSubject) ? credentialSubject : [credentialSubject]; for (const subject of credentialSubjectList) { if ("accreditedFor" in subject && Array.isArray(subject["accreditedFor"])) { const { accreditedFor } = subject as { // TODO: improve validation accreditedFor: { types: string[] }[]; }; for (const af of accreditedFor) { typesSubject.push(...af.types); } } } typesSubject = [...new Set(typesSubject)]; let missingTypes = typesSubject; await Promise.all( termsOfUse.map(async (t): Promise => { if (!t.id) return; // termsOfUse must be IssuanceCertificate if (t.type !== "IssuanceCertificate") { throw new Error("Terms of use must be of type IssuanceCertificate"); } // the link in termsOfUse should point to an attribute of the issuer const idParts = t.id.split("/"); if (issuer !== idParts.at(-3)) { throw new Error(`Issuer ${issuer} not found in the termsOfUse`); } // get the accreditation of the issuer const accreditation = await getCredential( t.id, config, options?.timeout, options?.validAt, options?.clockSkew, options?.axiosHeaders, ); // Check which "types" the issuer is allowed to issue by checking accreditation.type missingTypes = getMissingTypes(missingTypes, accreditation.type); if ("accreditedFor" in accreditation.credentialSubject) { // Check which "types" the issuer is allowed to issue by checking accreditation.credentialSubject.accreditedFor const { accreditedFor } = accreditation.credentialSubject as { // TODO: improve validation accreditedFor: { types: string[] }[]; }; for (const af of accreditedFor) { missingTypes = getMissingTypes(missingTypes, af.types); } } }), ); // check if there were types to accredit if (missingTypes.length > 0) { throw new Error( `Issuer ${issuer} is not accredited to issue credentials with type ${JSON.stringify( missingTypes, )}`, ); } } /** * Function to check in the Trusted Policies Registry if an issuer * has TIR Admin rights to create/update issuers. So, this user can * self attest to issue VerifiableAuthorisationForTrustChain * * @param vcPayload - The verifiable credential payload * @param config - EBSI environment configuration * @param timeout - Axios timeout (default: 15 seconds) */ export async function validateIssuerCanSelfAttestForTrustChain( vcPayload: EbsiVerifiableAttestation, config: EbsiEnvConfiguration, timeout = AXIOS_TIMEOUT, axiosHeaders?: RawAxiosRequestHeaders, ): Promise { const issuer = typeof vcPayload.issuer === "string" ? vcPayload.issuer : vcPayload.issuer.id; try { const didResponse = await getEbsiResource( { network: config.network.name, resource: `/identifiers/${issuer}`, scheme: config.scheme, service: "did-registry", }, config, timeout, false, axiosHeaders, ); const ethAddresses = didResponse.data.verificationMethod ?.map((vMethod) => { if (!vMethod.publicKeyJwk) return ""; return getEthereumAddress(vMethod.publicKeyJwk); }) .filter(Boolean) ?? []; if (ethAddresses.length === 0) { throw new Error( "DID document without verification methods using crv secp256k1", ); } const errorMessages = []; for (const address of ethAddresses) { // Check if address has any of acceptedTrustedPoliciesRegistryAttributes try { await hasAcceptedTrustedPoliciesRegistryAttributes( address, config, timeout, axiosHeaders, ); return; } catch (error) { errorMessages.push(getErrorMessage(error)); } } throw new Error(JSON.stringify(errorMessages)); } catch (error) { throw new Error( `Invalid self attestation of ${issuer} to issue credentials for VerifiableAuthorisationForTrustChain: ${getErrorMessage( error, )}`, ); } } /** * Function to get a credential (attribute) from the Trusted Issuers Registry */ async function getCredential( url: string, config: EbsiEnvConfiguration, timeout?: number, validAt?: number, clockSkew?: number, axiosHeaders?: RawAxiosRequestHeaders, ): Promise { try { const ebsiUriComponents = url.startsWith(`${config.scheme}:`) ? parse(url, config) : parseUrl(url, config); // Validate service if (ebsiUriComponents.service !== "trusted-issuers-registry") { throw new Error( `Service mismatch: ${url} is not part of the trusted-issuers-registry service`, ); } // Load resource const response = await getEbsiResource( ebsiUriComponents, config, timeout, false, axiosHeaders, ); if (!response.data.attribute.body) { throw new Error("No attribute in the response"); } if (response.data.attribute.issuerType === "Revoked") { throw new Error("Revoked"); } const { payload } = decodeJWT(response.data.attribute.body); const vc = payload["vc"] as unknown; if (!vc) throw new Error("vc field not found"); // Make sure that the credential is a valid attestation validateEbsiVerifiableAttestation(vc, config, timeout); // If the credential is an accreditation, validate it if ( !Array.isArray(vc.credentialSubject) && "accreditedFor" in vc.credentialSubject && // Exclude VerifiableAuthorisationForTrustChain, as it is not a typical accreditation (no termsOfUse) // Note: there's no dedicated JSON schema for VerifiableAuthorisationForTrustChain "termsOfUse" in vc ) { validateEbsiVerifiableAccreditation(vc, config, timeout); } validateDates(vc, validAt ?? Math.floor(Date.now() / 1000), clockSkew); return vc; } catch (error) { throw new Error(`Invalid credential ${url}: ${getErrorMessage(error)}`); } } /** * Function to get missing types of an accreditation * @param types - list of types to validate * @param accreditorTypes - list of types found in the accreditor * @returns the list of types that are not fulfilled by "accreditorTypes" */ function getMissingTypes(types: string[], accreditorTypes: string[]): string[] { if (accreditorTypes.includes("VerifiableAuthorisationForTrustChain")) { // the root of trust chain allows any type in the credential // then an empty array is returned as all types are fulfilled return []; } const accreditorIsTI = accreditorTypes.includes( "VerifiableAccreditationToAttest", ); const accreditorIsTAO = accreditorTypes.includes( "VerifiableAccreditationToAccredit", ); const missingTypes: string[] = []; for (const t of types) { // do not include the types already present in the accreditor if (accreditorTypes.includes(t)) continue; // the authorisation to onboard is implicit for TIs and TAOs if ( t === "VerifiableAuthorisationToOnboard" && (accreditorIsTI || accreditorIsTAO) ) { continue; } // the accreditation to attest is implicit for TAOs if (t === "VerifiableAccreditationToAttest" && accreditorIsTAO) continue; // include the missing type missingTypes.push(t); } return missingTypes; } /** * Function to check if a user has any of accepted the Trusted Policies Registry attributes */ async function hasAcceptedTrustedPoliciesRegistryAttributes( address: string, config: EbsiEnvConfiguration, timeout: number, axiosHeaders?: RawAxiosRequestHeaders, ) { const tprVersion = config.services["trusted-policies-registry"]; if (tprVersion === "v2") { // Get user from TPR const tprResponse = await getEbsiResource( { network: config.network.name, resource: `/users/${address}`, scheme: config.scheme, service: "trusted-policies-registry", }, config, timeout, false, axiosHeaders, ); // Check if it contains admin attributes for TIR let hasAdminAttribute = false; const tprAttributes = tprResponse.data.attributes; hasAdminAttribute = Object.keys(tprAttributes).some((attrId) => { const attr = Number(tprAttributes[attrId]); return ( acceptedTrustedPoliciesRegistryAttributes.includes( attrId as (typeof acceptedTrustedPoliciesRegistryAttributes)[number], ) && attr ); }); if (hasAdminAttribute) return; } else if (tprVersion === "v3") { for (const attribute of acceptedTrustedPoliciesRegistryAttributes) { try { await getEbsiResource( { network: config.network.name, resource: `/subjects/${address}/policies/${encodeURIComponent(attribute)}`, scheme: config.scheme, service: "trusted-policies-registry", }, config, timeout, false, axiosHeaders, ); return; // Stop looking for other attributes } catch (error) { // If the error is "Subject not found", stop looking for other attributes if ( isAxiosError(error) && error.response?.status === 404 && error.response.data && typeof error.response.data === "object" && "title" in error.response.data && error.response.data.title === "Subject Not Found" ) { throw new Error(`Subject ${address} not found`); } } } } else { throw new Error( `the Trusted Policies Registry version ${tprVersion} is not supported`, ); } throw new Error( `the address ${address} doesn't have one of these attributes ${acceptedTrustedPoliciesRegistryAttributes.join( ",", )} in the Trusted Policies Registry`, ); }