src/verifier/presentations/credential/mdocverifier/mdocverifier.service.ts
Methods |
|
constructor(chainValidation: CredentialChainValidationService, logger: PinoLogger)
|
|||||||||
|
Parameters :
|
| 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 :
Returns :
DeviceRequest
|
| Private classifyVerificationError | ||||||
classifyVerificationError(error: any)
|
||||||
|
Parameters :
Returns :
unknown
|
| Private Async extractErrorDetails | |||||||||
extractErrorDetails(vp: string, options: VerifierOptions)
|
|||||||||
|
Extract error details from the mDOC document for debugging.
Parameters :
Returns :
Promise<MdocErrorDetails>
|
| Private Async extractIssuerCertInfo | |||||||||
extractIssuerCertInfo(mdocDoc: any, details: MdocErrorDetails)
|
|||||||||
|
Extract issuer certificate information from the mDOC document.
Parameters :
Returns :
Promise<void>
|
| Private Async handleVerificationError | ||||||||||||
handleVerificationError(vp: string, error: any, options: VerifierOptions)
|
||||||||||||
|
Handle verification errors with detailed logging.
Parameters :
Returns :
Promise<MdocVerificationResult>
|
| Private mapChainErrorToFailureType | ||||||
mapChainErrorToFailureType(errorCode?: string)
|
||||||
|
Parameters :
Returns :
unknown
|
| Private Async summarizeTrustedCerts | ||||||
summarizeTrustedCerts(trustListSource: VerifierOptions)
|
||||||
|
Summarize trusted certificates for error logging.
Parameters :
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 :
Returns :
Promise<ChainValidationResult>
|
| Async verify | ||||||||||||||||||||
verify(vp: string, sessionData: MdocSessionData, options: VerifierOptions, requestedClaimPaths?: RequestedMdocClaimPath[])
|
||||||||||||||||||||
Decorators :
@Span('mdoc.verify')
|
||||||||||||||||||||
|
Verifies an mDOC credential.
Parameters :
Returns :
Promise<MdocVerificationResult>
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,
}),
}),
],
});
}
}