File

src/verifier/presentations/credential/mdocverifier/mdocverifier.service.ts

Index

Methods

Constructor

constructor(chainValidation: CredentialChainValidationService, logger: PinoLogger)
Parameters :
Name Type Optional
chainValidation CredentialChainValidationService No
logger PinoLogger No

Methods

Private buildDeviceRequest
buildDeviceRequest(docType: string, requestedClaimPaths: RequestedMdocClaimPath[] | undefined, claimsByNamespace: Record>)

Build a device request based on the docType and received claims. This creates a request that matches what was received for verification.

Parameters :
Name Type Optional
docType string No
requestedClaimPaths RequestedMdocClaimPath[] | undefined No
claimsByNamespace Record<string | Record<string, unknown>> No
Returns : DeviceRequest
Private classifyVerificationError
classifyVerificationError(error: any)
Parameters :
Name Type Optional
error any No
Returns : unknown
Private Async extractErrorDetails
extractErrorDetails(vp: string, options: VerifierOptions)

Extract error details from the mDOC document for debugging.

Parameters :
Name Type Optional
vp string No
options VerifierOptions No
Private Async extractIssuerCertInfo
extractIssuerCertInfo(mdocDoc: any, details: MdocErrorDetails)

Extract issuer certificate information from the mDOC document.

Parameters :
Name Type Optional
mdocDoc any No
details MdocErrorDetails No
Returns : Promise<void>
Private Async handleVerificationError
handleVerificationError(vp: string, error: any, options: VerifierOptions)

Handle verification errors with detailed logging.

Parameters :
Name Type Optional
vp string No
error any No
options VerifierOptions No
Private mapChainErrorToFailureType
mapChainErrorToFailureType(errorCode?: string)
Parameters :
Name Type Optional
errorCode string Yes
Returns : unknown
Private Async summarizeTrustedCerts
summarizeTrustedCerts(trustListSource: VerifierOptions)

Summarize trusted certificates for error logging.

Parameters :
Name Type Optional
trustListSource VerifierOptions No
Returns : Promise<string>
Private Async validateIssuerCertificateChain
validateIssuerCertificateChain(mdocDocument: any, options: VerifierOptions)

Validates the issuer certificate chain from the mDOC's IssuerAuth. Extracts x5chain from the COSE Sign1 structure and validates it using the shared CredentialChainValidationService.

Parameters :
Name Type Optional
mdocDocument any No
options VerifierOptions No
Async verify
verify(vp: string, sessionData: MdocSessionData, options: VerifierOptions, requestedClaimPaths?: RequestedMdocClaimPath[])
Decorators :
@Span('mdoc.verify')

Verifies an mDOC credential.

Parameters :
Name Type Optional Description
vp string No

The base64url encoded device response

sessionData MdocSessionData No

Session data for transcript generation

options VerifierOptions No

Verification options including trust list

requestedClaimPaths RequestedMdocClaimPath[] Yes

Verification result with claims

import { Injectable } from "@nestjs/common";
import {
    base64,
    DeviceRequest,
    DeviceResponse,
    DocRequest,
    hex,
    ItemsRequest,
    SessionTranscript,
    Verifier,
} from "@owf/mdoc";
import * as x509 from "@peculiar/x509";
import { Span } from "nestjs-otel";
import { PinoLogger } from "nestjs-pino";
import { VerifierOptions } from "../../../../shared/trust/types";
import { mdocContext } from "../../mdoc-context";
import {
    ChainValidationResult,
    CredentialChainValidationService,
} from "../credential-chain-validation.service";

export type MdocSessionData = {
    protocol: "openid4vp";
    nonce: string;
    responseMode: string;
    clientId: string;
    responseUri: string;
    /** SHA-256 JWK thumbprint (raw bytes) of the verifier's JAR signing key. */
    jwkThumbprint?: Uint8Array;
};

export type RequestedMdocClaimPath = string[];

export type MdocVerificationResult = {
    verified: boolean;
    claims: Record<string, unknown>;
    payload: string;
    docType?: string;
    failureType?:
        | "signature_invalid"
        | "no_trust_chain_to_root"
        | "trust_chain_not_trusted"
        | "x5c_missing"
        | "verification_error";
    failureReason?: string;
};

/**
 * Error details extracted from an mDOC document for debugging.
 */
interface MdocErrorDetails {
    docType: string;
    issuerCertInfo: string;
    issuerThumbprint: string;
    issuerValidity: string;
    trustedCertsSummary: string;
}

@Injectable()
export class MdocverifierService {
    constructor(
        private readonly chainValidation: CredentialChainValidationService,
        private readonly logger: PinoLogger,
    ) {
        this.logger.setContext(MdocverifierService.name);
    }

    /**
     * Verifies an mDOC credential.
     * @param vp The base64url encoded device response
     * @param sessionData Session data for transcript generation
     * @param options Verification options including trust list
     * @returns Verification result with claims
     */
    @Span("mdoc.verify")
    async verify(
        vp: string,
        sessionData: MdocSessionData,
        options: VerifierOptions,
        requestedClaimPaths?: RequestedMdocClaimPath[],
    ): Promise<MdocVerificationResult> {
        try {
            // 1) Decode the device response
            const uint8Array = Buffer.from(vp, "base64url");
            const deviceResponse = DeviceResponse.decode(uint8Array);
            const mdocDocument = deviceResponse.documents?.[0];

            if (!mdocDocument) {
                throw new Error("mDOC document not found in device response");
            }

            // 2) Extract claims from the issuer signed data
            const issuerSigned = mdocDocument.issuerSigned;
            const docType = mdocDocument.docType;

            // Collect claims from all available namespaces.
            // getPrettyClaims() requires an exact namespace string, so we iterate
            // the namespaces present in the credential rather than guessing from docType.
            const namespacesMap =
                issuerSigned.issuerNamespaces?.issuerNamespaces ??
                new Map<string, unknown>();
            const claimsByNamespace: Record<
                string,
                Record<string, unknown>
            > = {};
            const claims: Record<string, unknown> = {};
            for (const [ns] of namespacesMap.entries()) {
                const nsClaims = issuerSigned.getPrettyClaims(ns);
                if (nsClaims) {
                    claimsByNamespace[ns] = nsClaims;
                    Object.assign(claims, nsClaims);
                }
            }

            // 3) Build the session transcript for verification
            const sessionTranscript = await SessionTranscript.forOid4Vp(
                sessionData,
                mdocContext,
            );

            // 4) Build a device request (currently requesting all claims that were received)
            const deviceRequest = this.buildDeviceRequest(
                docType,
                requestedClaimPaths,
                claimsByNamespace,
            );

            // 5) Verify the device response (signature, device binding, etc.)
            // Certificate chain validation is disabled here - we do it separately via CredentialChainValidationService
            await Verifier.verifyDeviceResponse(
                {
                    deviceRequest,
                    deviceResponse,
                    sessionTranscript,
                    trustedCertificates: [],
                    disableCertificateChainValidation: true,
                },
                mdocContext,
            );

            // 6) Validate certificate chain using shared CredentialChainValidationService
            // This ensures consistent trust validation with SD-JWT-VC and other formats
            const chainResult = await this.validateIssuerCertificateChain(
                mdocDocument,
                options,
            );

            if (!chainResult.verified) {
                if (chainResult.errorDetails) {
                    this.logger.warn(
                        `Certificate chain validation failed: ${chainResult.errorDetails}`,
                    );
                }

                const failureType = this.mapChainErrorToFailureType(
                    chainResult.error,
                );
                const failureReason =
                    chainResult.errorDetails ||
                    chainResult.error ||
                    "Certificate chain validation failed";

                return {
                    verified: false,
                    claims,
                    payload: vp,
                    docType,
                    failureType,
                    failureReason,
                };
            }

            this.logger.debug(
                `MDL device response verified successfully for docType: ${docType}`,
            );

            return {
                verified: true,
                claims,
                payload: vp,
                docType,
            };
        } catch (error: any) {
            console.log(error);
            return this.handleVerificationError(vp, error, options);
        }
    }

    /**
     * Validates the issuer certificate chain from the mDOC's IssuerAuth.
     * Extracts x5chain from the COSE Sign1 structure and validates it using
     * the shared CredentialChainValidationService.
     */
    private async validateIssuerCertificateChain(
        mdocDocument: any,
        options: VerifierOptions,
    ): Promise<ChainValidationResult> {
        // Extract x5chain from IssuerAuth (COSE Sign1 unprotected headers)
        const issuerAuth = mdocDocument?.issuerSigned?.issuerAuth;
        const x5chain: Uint8Array[] | undefined = issuerAuth?.x5chain;

        if (!x5chain || x5chain.length === 0) {
            // No x5c in the credential
            if (options.policy?.requireX5c) {
                return {
                    verified: false,
                    matchedEntity: null,
                    error: "x5c_required",
                    errorDetails:
                        "Policy requires x5c but none was found in IssuerAuth",
                };
            }
            // If x5c not required, skip trust validation
            return { verified: true, matchedEntity: null };
        }

        // Convert Uint8Array[] to base64 string[] for CredentialChainValidationService
        const x5cBase64 = x5chain.map((cert) => base64.encode(cert));

        return this.chainValidation.validateChain(
            x5cBase64,
            options.trustListSource,
            {
                requireX5c: options.policy?.requireX5c,
                pinnedCertMode: options.policy?.pinnedCertMode ?? "leaf",
                serviceTypeFilter: "/Issuance",
            },
        );
    }

    /**
     * Handle verification errors with detailed logging.
     */
    private async handleVerificationError(
        vp: string,
        error: any,
        options: VerifierOptions,
    ): Promise<MdocVerificationResult> {
        const configuredTrustLists =
            options.trustListSource?.lotes?.map((l) => l.url).join(", ") ||
            "none configured";

        const details = await this.extractErrorDetails(vp, options);

        const errorDetails = [
            `Error: ${error?.message ?? error}`,
            `DocType: ${details.docType}`,
            `Issuer cert: ${details.issuerCertInfo}`,
            `Issuer thumbprint: ${details.issuerThumbprint}`,
            `Issuer validity: ${details.issuerValidity}`,
            `Configured trust lists: ${configuredTrustLists}`,
            `Trusted certs: ${details.trustedCertsSummary}`,
        ].join(" | ");

        this.logger.error(`mDOC verification failed: ${errorDetails}`);

        let failureType = this.classifyVerificationError(error);
        let failureReason = error?.message ?? String(error);

        // In some failing cases the mDOC library throws a signature-related error first,
        // even though the underlying trust chain is also not valid for the configured trust list.
        // Probe chain validation here to return a more actionable trust-specific reason.
        if (failureType === "signature_invalid") {
            try {
                const uint8Array = Buffer.from(vp, "base64url");
                const deviceResponse = DeviceResponse.decode(uint8Array);
                const mdocDocument = deviceResponse.documents?.[0];

                if (mdocDocument) {
                    const chainResult =
                        await this.validateIssuerCertificateChain(
                            mdocDocument,
                            options,
                        );

                    if (!chainResult.verified) {
                        failureType = this.mapChainErrorToFailureType(
                            chainResult.error,
                        );
                        failureReason =
                            chainResult.errorDetails ||
                            chainResult.error ||
                            failureReason;

                        this.logger.warn(
                            `mDOC verification encountered signature error but trust chain check also failed: ${failureReason}`,
                        );
                    }
                }
            } catch (chainProbeError: any) {
                this.logger.debug(
                    `Could not probe chain failure after signature error: ${chainProbeError?.message ?? chainProbeError}`,
                );
            }
        }

        return {
            verified: false,
            claims: {},
            payload: vp,
            docType:
                details.docType === "unknown" ? undefined : details.docType,
            failureType,
            failureReason,
        };
    }

    private mapChainErrorToFailureType(
        errorCode?: string,
    ): MdocVerificationResult["failureType"] {
        switch (errorCode) {
            case "x5c_required":
                return "x5c_missing";
            case "chain_build_failed":
                return "no_trust_chain_to_root";
            case "no_trusted_entity_match":
                return "trust_chain_not_trusted";
            default:
                return "verification_error";
        }
    }

    private classifyVerificationError(
        error: any,
    ): MdocVerificationResult["failureType"] {
        const message = String(error?.message ?? error).toLowerCase();

        if (message.includes("signature")) {
            return "signature_invalid";
        }

        return "verification_error";
    }

    /**
     * Extract error details from the mDOC document for debugging.
     */
    private async extractErrorDetails(
        vp: string,
        options: VerifierOptions,
    ): Promise<MdocErrorDetails> {
        const details: MdocErrorDetails = {
            docType: "unknown",
            issuerCertInfo: "unknown",
            issuerThumbprint: "unknown",
            issuerValidity: "unknown",
            trustedCertsSummary: "unknown",
        };

        try {
            const uint8Array = Buffer.from(vp, "base64url");
            const deviceResponse = DeviceResponse.decode(uint8Array);
            const mdocDoc = deviceResponse.documents?.[0];

            if (mdocDoc?.docType) {
                details.docType = mdocDoc.docType;
            }

            // Extract issuer certificate info from the MSO
            await this.extractIssuerCertInfo(mdocDoc, details);

            // Summarize trusted certificates
            details.trustedCertsSummary = await this.summarizeTrustedCerts(
                options.trustListSource,
            );
        } catch (parseError: any) {
            this.logger.debug(
                `Could not extract additional debug info: ${parseError?.message ?? parseError}`,
            );
        }

        return details;
    }

    /**
     * Extract issuer certificate information from the mDOC document.
     */
    private async extractIssuerCertInfo(
        mdocDoc: any,
        details: MdocErrorDetails,
    ): Promise<void> {
        const issuerAuth = mdocDoc?.issuerSigned?.issuerAuth;
        if (!issuerAuth) return;

        const x5chain = issuerAuth?.x5chain;
        if (!x5chain?.length) return;

        try {
            const leafCertBytes = x5chain[0];
            const leafCert = new x509.X509Certificate(leafCertBytes);
            const thumbprint = await leafCert.getThumbprint("SHA-256");

            details.issuerThumbprint = hex.encode(new Uint8Array(thumbprint));
            details.issuerValidity = `${leafCert.notBefore.toISOString()} - ${leafCert.notAfter.toISOString()}`;
            details.issuerCertInfo = `subject="${leafCert.subject}", issuer="${leafCert.issuer}"`;
        } catch {
            // Ignore certificate parsing errors
        }
    }

    /**
     * Summarize trusted certificates for error logging.
     */
    private async summarizeTrustedCerts(
        trustListSource: VerifierOptions["trustListSource"],
    ): Promise<string> {
        const trustedCerts =
            await this.chainValidation.getTrustedCertificateBuffers(
                trustListSource,
            );

        if (trustedCerts.length === 0) {
            return "none loaded";
        }

        const certSummaries: string[] = [];
        for (const certBuf of trustedCerts.slice(0, 5)) {
            try {
                const cert = new x509.X509Certificate(new Uint8Array(certBuf));
                const thumb = hex.encode(
                    new Uint8Array(await cert.getThumbprint("SHA-256")),
                );
                certSummaries.push(
                    `${cert.subject} (${thumb.substring(0, 16)}...)`,
                );
            } catch {
                // Skip invalid certs
            }
        }

        let summary =
            certSummaries.length > 0 ? certSummaries.join("; ") : "none valid";
        if (trustedCerts.length > 5) {
            summary += ` ...and ${trustedCerts.length - 5} more`;
        }

        return summary;
    }

    /**
     * Build a device request based on the docType and received claims.
     * This creates a request that matches what was received for verification.
     */
    private buildDeviceRequest(
        docType: string,
        requestedClaimPaths: RequestedMdocClaimPath[] | undefined,
        claimsByNamespace: Record<string, Record<string, unknown>>,
    ): DeviceRequest {
        // Prefer original DCQL claim paths because deviceAuth is bound to the
        // request semantics, not just the disclosed claims returned by the wallet.
        const namespaces: Record<string, Record<string, boolean>> = {};

        if (requestedClaimPaths && requestedClaimPaths.length > 0) {
            for (const path of requestedClaimPaths) {
                if (path.length === 0) {
                    continue;
                }

                const namespace = path.length > 1 ? path[0] : docType;
                const claimName =
                    path.length > 1 ? path.slice(1).join(".") : path[0];

                if (!namespaces[namespace]) {
                    namespaces[namespace] = {};
                }
                namespaces[namespace][claimName] = true;
            }
        }

        if (Object.keys(namespaces).length === 0) {
            // Fallback: build namespace map from disclosed claims.
            // This is less strict than using original DCQL but keeps compatibility
            // for flows where no explicit mDOC claims were requested.
            for (const [ns, nsClaims] of Object.entries(claimsByNamespace)) {
                if (Object.keys(nsClaims).length > 0) {
                    namespaces[ns] = {};
                    for (const claimKey of Object.keys(nsClaims)) {
                        namespaces[ns][claimKey] = true;
                    }
                }
            }
        }

        return DeviceRequest.create({
            docRequests: [
                DocRequest.create({
                    itemsRequest: ItemsRequest.create({
                        docType,
                        namespaces,
                    }),
                }),
            ],
        });
    }
}

results matching ""

    No results matching ""