src/verifier/iso18013/iso18013.service.ts

Relationships

Used by

Depends on

No results matching.

Index

Methods

Constructor

constructor(presentationsService: PresentationsService, sessionService: SessionService, encryptionService: EncryptionService, mdocverifierService: MdocverifierService, webhookService: WebhookService, auditLogService: AuditLogService, configService: ConfigService, certService: CertService, keyChainService: KeyChainService, webhookEndpointRepo: Repository<WebhookEndpointEntity>, logger: PinoLogger)
Parameters :
Name Type Optional
presentationsService PresentationsService No
sessionService SessionService No
encryptionService EncryptionService No
mdocverifierService MdocverifierService No
webhookService WebhookService No
auditLogService AuditLogService No
configService ConfigService No
certService CertService No
keyChainService KeyChainService No
webhookEndpointRepo Repository<WebhookEndpointEntity> No
logger PinoLogger No

Methods

Private Async buildReaderAuthForOffer
buildReaderAuthForOffer(tenantId: string, accessKeyChainId: string | undefined, itemsRequest: ItemsRequest, encryptionInfoB64u: string, origin: string)

Build a detached ReaderAuth (COSE_Sign1) for the offer, signing the ReaderAuthentication structure with the tenant's Access key chain.

The DCAPIHandover SessionTranscript is reconstructed deterministically from the EncryptionInfo (base64url) and browser origin — identical to the one the wallet derives — so the reader signature binds to this exact request.

Note: signing extracts the Access private key as a JWK (mirroring mDOC issuance), so KMS-backed non-extractable keys are not yet supported for reader authentication.

Parameters :
Name Type Optional
tenantId string No
accessKeyChainId string | undefined No
itemsRequest ItemsRequest No
encryptionInfoB64u string No
origin string No
Returns : Promise<ReaderAuth>
Async createOffer
createOffer(requestId: string, tenantId: string, origin: string, skewSeconds?: number)

Create an ISO 18013-7 Annex C offer: build DeviceRequest + encryptionInfo and persist a session for the response phase.

Parameters :
Name Type Optional
requestId string No
tenantId string No
origin string No
skewSeconds number Yes
Async processResponse
processResponse(sessionId: string, encryptedB64: string)

Process the HPKE-encrypted DeviceResponse returned by the wallet via DC API.

Parameters :
Name Type Optional Description
sessionId string No

Session UUID returned by createOffer

encryptedB64 string No

base64url-encoded HPKE output: enc(65B) || ciphertext

Returns : Promise<Record<string, unknown>>
Private Async resolveWebhookFromEndpoint
resolveWebhookFromEndpoint(webhookEndpointId: string | null | undefined, tenantId: string)
Parameters :
Name Type Optional
webhookEndpointId string | null | undefined No
tenantId string No
import { randomBytes, randomUUID } from "node:crypto";
import {
    BadRequestException,
    Injectable,
    NotFoundException,
} from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import type { ItemsRequest, ReaderAuth } from "@owf/mdoc";
import { X509Certificate } from "@peculiar/x509";
import { exportJWK } from "jose";
import { InjectRepository } from "@nestjs/typeorm";
import { InjectPinoLogger, PinoLogger } from "nestjs-pino";
import { Repository } from "typeorm";
import { EncryptionService } from "../../crypto/encryption/encryption.service";
import { CertService } from "../../crypto/key/cert/cert.service";
import { KeyUsageType } from "../../crypto/key/entities/key-chain.entity";
import { KeyChainService } from "../../crypto/key/key-chain.service";
import { WebhookEndpointEntity } from "../../issuer/configuration/webhook-endpoint/entities/webhook-endpoint.entity";
import { ServiceTypeIdentifier } from "../../issuer/trust-list/trustlist.service";
import { SessionStatus } from "../../session/entities/session.entity";
import { SessionService } from "../../session/session.service";
import { revocationModeToPolicy } from "../../shared/trust/revocation-policy.util";
import {
    DEFAULT_VERIFIER_SKEW_SECONDS,
    RevocationCheckMode,
    VerifierOptions,
} from "../../shared/trust/types";
import { AuditLogService } from "../../shared/utils/logger/audit-log.service";
import { WebhookService } from "../../shared/utils/webhook/webhook.service";
import { MdocverifierService } from "../presentations/credential/mdocverifier/mdocverifier.service";
import {
    TrustedAuthorityQueryEtsiTl,
    TrustedAuthorityQueryOpenIdFederation,
    TrustedAuthorityType,
} from "../presentations/entities/presentation-config.entity";
import { PresentationsService } from "../presentations/presentations.service";
import {
    buildDeviceRequestCbor,
    buildEncryptionInfo,
    buildIsoMdocDcApiTranscript,
    buildItemsRequest,
    buildReaderAuth,
    parseEncryptedResponse,
} from "./cbor-request";
import { hpkeOpen } from "./hpke";
import { WebhookConfig } from "../../shared/utils/webhook/webhook.dto";

export interface Iso18013Offer {
    session: string;
    uri: string;
    crossDeviceUri: string;
    org_iso_mdoc: {
        device_request: string; // base64url CBOR DeviceRequest
        encryption_info: string; // base64url CBOR EncryptionInfo
    };
}

@Injectable()
export class Iso18013Service {
    constructor(
        private readonly presentationsService: PresentationsService,
        private readonly sessionService: SessionService,
        private readonly encryptionService: EncryptionService,
        private readonly mdocverifierService: MdocverifierService,
        private readonly webhookService: WebhookService,
        private readonly auditLogService: AuditLogService,
        private readonly configService: ConfigService,
        private readonly certService: CertService,
        private readonly keyChainService: KeyChainService,
        @InjectRepository(WebhookEndpointEntity)
        private readonly webhookEndpointRepo: Repository<WebhookEndpointEntity>,
        @InjectPinoLogger(Iso18013Service.name)
        private readonly logger: PinoLogger,
    ) {}

    private async resolveWebhookFromEndpoint(
        webhookEndpointId: string | null | undefined,
        tenantId: string,
    ): Promise<WebhookConfig | undefined> {
        if (!webhookEndpointId) {
            return undefined;
        }

        const endpoint = await this.webhookEndpointRepo.findOneBy({
            id: webhookEndpointId,
            tenantId,
        });
        if (!endpoint) {
            this.logger.warn(
                {
                    tenantId,
                    webhookEndpointId,
                },
                "Webhook endpoint configured on presentation config was not found",
            );
            return undefined;
        }

        return { url: endpoint.url, auth: endpoint.auth };
    }

    /**
     * Create an ISO 18013-7 Annex C offer: build DeviceRequest + encryptionInfo
     * and persist a session for the response phase.
     */
    async createOffer(
        requestId: string,
        tenantId: string,
        origin: string,
        skewSeconds?: number,
    ): Promise<Iso18013Offer> {
        const config = await this.presentationsService.getPresentationConfig(
            requestId,
            tenantId,
        );

        const pubJwk =
            await this.encryptionService.getEncryptionPublicKey(tenantId);
        const nonce = randomBytes(16);
        const sessionId = randomUUID();

        // Find the first mso_mdoc credential in the DCQL query
        const mdocCred = config.dcql_query.credentials.find(
            (c) => c.format === "mso_mdoc",
        );
        if (!mdocCred) {
            throw new BadRequestException(
                `Presentation config "${requestId}" has no mso_mdoc credential`,
            );
        }

        const docType = (mdocCred.meta as { doctype?: string })?.doctype;
        if (!docType) {
            throw new BadRequestException(
                `Presentation config "${requestId}" mso_mdoc credential has no meta.doctype`,
            );
        }

        // Build namespace→claims map from DCQL claims
        const namespaces: Record<string, Record<string, boolean>> = {};
        for (const claim of mdocCred.claims ?? []) {
            if (claim.path.length === 0) continue;
            const ns = claim.path.length > 1 ? claim.path[0] : docType;
            const claimName =
                claim.path.length > 1 ? claim.path[1] : claim.path[0];
            if (!namespaces[ns]) namespaces[ns] = {};
            namespaces[ns][claimName] = false; // intentToRetain = false
        }
        if (Object.keys(namespaces).length === 0) {
            namespaces[docType] = {};
        }

        const encryptionInfoCbor = buildEncryptionInfo(
            pubJwk.x!,
            pubJwk.y!,
            nonce,
        );

        // A single ItemsRequest instance is shared between the DocRequest and,
        // when reader authentication is enabled, the ReaderAuthentication that is
        // signed over it — the wallet recomputes the latter from the former.
        const itemsRequest = buildItemsRequest(docType, namespaces);

        const readerAuth = config.readerAuth
            ? await this.buildReaderAuthForOffer(
                  tenantId,
                  config.accessKeyChainId ?? undefined,
                  itemsRequest,
                  encryptionInfoCbor.toString("base64url"),
                  origin,
              )
            : undefined;

        const deviceRequestCbor = buildDeviceRequestCbor(
            itemsRequest,
            readerAuth,
        );

        const expiresAt = new Date(
            Date.now() + (config.lifeTime ?? 300) * 1000,
        );
        const resolvedWebhook = await this.resolveWebhookFromEndpoint(
            config.webhookEndpointId,
            tenantId,
        );

        await this.sessionService.create({
            id: sessionId,
            tenantId,
            requestId,
            useDcApi: true,
            dcApiProtocol: "iso-18013-7",
            browserOrigin: origin,
            vp_nonce: nonce.toString("hex"),
            webhookEndpointId: config.webhookEndpointId ?? undefined,
            parsedWebhook: resolvedWebhook,
            redirectUri: config.redirectUri ?? undefined,
            skewSeconds:
                skewSeconds ??
                config.skewSeconds ??
                DEFAULT_VERIFIER_SKEW_SECONDS,
            expiresAt,
            status: SessionStatus.Active,
        });

        return {
            session: sessionId,
            uri: "",
            crossDeviceUri: "",
            org_iso_mdoc: {
                device_request: deviceRequestCbor.toString("base64url"),
                encryption_info: encryptionInfoCbor.toString("base64url"),
            },
        };
    }

    /**
     * Build a detached ReaderAuth (COSE_Sign1) for the offer, signing the
     * ReaderAuthentication structure with the tenant's Access key chain.
     *
     * The DCAPIHandover SessionTranscript is reconstructed deterministically from
     * the EncryptionInfo (base64url) and browser origin — identical to the one
     * the wallet derives — so the reader signature binds to this exact request.
     *
     * Note: signing extracts the Access private key as a JWK (mirroring mDOC
     * issuance), so KMS-backed non-extractable keys are not yet supported for
     * reader authentication.
     */
    private async buildReaderAuthForOffer(
        tenantId: string,
        accessKeyChainId: string | undefined,
        itemsRequest: ItemsRequest,
        encryptionInfoB64u: string,
        origin: string,
    ): Promise<ReaderAuth> {
        const { sessionTranscript } = await buildIsoMdocDcApiTranscript(
            encryptionInfoB64u,
            origin,
        );

        const cert = await this.certService.find({
            tenantId,
            type: KeyUsageType.Access,
            certId: accessKeyChainId,
        });

        const keyChain = await this.keyChainService.getEntity(
            tenantId,
            cert.keyId,
        );
        const signingJwk = (await exportJWK(
            await crypto.subtle.importKey(
                "jwk",
                keyChain.activeJwk,
                { name: "ECDSA", namedCurve: "P-256" },
                true,
                ["sign"],
            ),
        )) as Record<string, unknown>;

        const certificateChain = cert.crt.map(
            (pem) => new Uint8Array(new X509Certificate(pem).rawData),
        );

        return buildReaderAuth(
            itemsRequest,
            sessionTranscript,
            signingJwk,
            certificateChain,
        );
    }

    /**
     * Process the HPKE-encrypted DeviceResponse returned by the wallet via DC API.
     *
     * @param sessionId     Session UUID returned by createOffer
     * @param encryptedB64  base64url-encoded HPKE output: enc(65B) || ciphertext
     */
    async processResponse(
        sessionId: string,
        encryptedB64: string,
    ): Promise<Record<string, unknown>> {
        let session;
        try {
            session = await this.sessionService.getBy({
                id: sessionId,
                dcApiProtocol: "iso-18013-7",
            });
        } catch {
            throw new NotFoundException("ISO 18013-7 session not found");
        }

        if (session.consumed) {
            throw new BadRequestException(
                "The presentation offer has already been used",
            );
        }

        const logContext = {
            sessionId: session.id,
            tenantId: session.tenantId,
            flowType: "ISO18013" as const,
            stage: "response_processing",
        };

        this.auditLogService.logFlowStart(logContext, {
            action: "process_iso18013_response",
        });

        const privJwk = await this.encryptionService.getEncryptionPrivateJwk(
            session.tenantId,
        );

        const nonce = Buffer.from(session.vp_nonce!, "hex");
        const origin = session.browserOrigin!;

        // Reconstruct the DCAPIHandover SessionTranscript from stored session data.
        // The handover hashes the base64url EncryptionInfo exactly as sent in the
        // offer; buildEncryptionInfo is deterministic, so re-encoding the stored
        // nonce with the tenant key reproduces the identical string.
        const encryptionInfoB64u = buildEncryptionInfo(
            privJwk.x!,
            privJwk.y!,
            nonce,
        ).toString("base64url");
        const transcript = await buildIsoMdocDcApiTranscript(
            encryptionInfoB64u,
            origin,
        );

        // Parse EncryptedResponse = ["dcapi", {"enc": bstr, "cipherText": bstr}]
        const encryptedBytes = Buffer.from(encryptedB64, "base64url");
        let encKey: Buffer;
        let ciphertext: Buffer;
        try {
            ({ enc: encKey, cipherText: ciphertext } =
                parseEncryptedResponse(encryptedBytes));
        } catch (err: any) {
            throw new BadRequestException(
                `Invalid EncryptedResponse: ${err?.message ?? err}`,
            );
        }

        let deviceResponseCbor: Buffer;
        try {
            deviceResponseCbor = hpkeOpen(
                encKey,
                ciphertext,
                { x: privJwk.x!, y: privJwk.y!, d: privJwk.d! },
                transcript.hpkeInfo,
            );
        } catch (err: any) {
            const reason = `HPKE decryption failed: ${err?.message ?? err}`;
            this.logger.warn({ sessionId }, reason);
            await this.sessionService.add(session.id, {
                status: SessionStatus.Failed,
                errorReason: reason,
            });
            this.auditLogService.logFlowError(logContext, err as Error, {
                stage: "hpke_decryption",
            });
            throw new BadRequestException("HPKE decryption failed");
        }

        const config = await this.presentationsService.getPresentationConfig(
            session.requestId!,
            session.tenantId,
        );

        const mdocCred = config.dcql_query.credentials.find(
            (c) => c.format === "mso_mdoc",
        );
        if (!mdocCred) {
            throw new BadRequestException("No mso_mdoc credential in config");
        }

        // Build VerifierOptions from the credential's trusted_authorities config,
        // mirroring the trust validation applied in the OID4VP flow.
        const host = this.configService.getOrThrow<string>("PUBLIC_URL");
        const tenantHost = `${host}/issuers/${session.tenantId}`;

        const loteAuthorities = mdocCred.trusted_authorities?.find(
            (auth): auth is TrustedAuthorityQueryEtsiTl =>
                auth.type === TrustedAuthorityType.ETSI_TL,
        );
        const federationAuthorities = mdocCred.trusted_authorities?.find(
            (auth): auth is TrustedAuthorityQueryOpenIdFederation =>
                auth.type === TrustedAuthorityType.OPENID_FEDERATION,
        );

        const resolvedLoteAuthorities =
            await this.presentationsService.resolveTrustListRefsForTenant(
                loteAuthorities?.values,
                session.tenantId,
                tenantHost,
            );

        const verifyOptions: VerifierOptions = {
            trustListSource: {
                lotes: resolvedLoteAuthorities,
                acceptedServiceTypes: [
                    ServiceTypeIdentifier.EaaIssuance,
                    ServiceTypeIdentifier.PIDIssuance,
                ],
            },
            federationTrustSource: federationAuthorities?.values.length
                ? {
                      mode: "hybrid",
                      trustAnchors: federationAuthorities.values.map(
                          (value) => ({
                              entityId: value,
                              entityConfigurationUri: `${value.replace(/\/$/, "")}/.well-known/openid-federation`,
                          }),
                      ),
                  }
                : undefined,
            policy: {
                requireX5c: true,
                revocation: revocationModeToPolicy(
                    config.statusCheckMode ?? RevocationCheckMode.Strict,
                ),
            },
            skewSeconds:
                session.skewSeconds ??
                config.skewSeconds ??
                DEFAULT_VERIFIER_SKEW_SECONDS,
        };

        const deviceResponseB64 = deviceResponseCbor.toString("base64url");

        // Verify the mDOC using the pre-built DCAPIHandover transcript
        const verifyResult = await this.mdocverifierService.verify(
            deviceResponseB64,
            {
                protocol: "iso-18013-7",
                sessionTranscript: transcript.sessionTranscript,
            },
            verifyOptions,
            mdocCred.claims?.map((c) => c.path),
        );

        this.auditLogService.logCredentialVerification(
            logContext,
            verifyResult.verified,
            { docType: verifyResult.docType },
        );

        if (!verifyResult.verified) {
            const reason =
                verifyResult.failureReason ?? "mDOC verification failed";
            await this.sessionService.add(session.id, {
                status: SessionStatus.Failed,
                errorReason: reason,
            });
            this.auditLogService.logFlowError(logContext, new Error(reason), {
                stage: "mdoc_verification",
            });
            throw new BadRequestException(reason);
        }

        const credentials = [
            {
                id: mdocCred.id,
                format: "mso_mdoc",
                docType: verifyResult.docType,
                claims: verifyResult.claims,
            },
        ];

        const responseCode = randomUUID();

        await this.sessionService.add(session.id, {
            credentials: credentials as any,
            status: SessionStatus.Completed,
            responseCode,
            consumed: true,
            consumedAt: new Date(),
        });

        const webhook =
            session.parsedWebhook ??
            (await this.resolveWebhookFromEndpoint(
                session.webhookEndpointId,
                session.tenantId,
            ));
        if (webhook) {
            const webhookResponse = await this.webhookService
                .sendWebhook({
                    webhook,
                    session,
                    credentials,
                    expectResponse: false,
                })
                .catch((err: any) => {
                    this.logger.warn(
                        { sessionId },
                        `Webhook delivery failed: ${err?.message ?? err}`,
                    );
                    return undefined;
                });

            if (webhookResponse?.redirectUri) {
                session.redirectUri = webhookResponse.redirectUri;
            }
        }

        this.auditLogService.logFlowComplete(logContext, {
            credentialCount: credentials.length,
            webhookSent: !!webhook,
        });

        if (session.redirectUri) {
            const processedUri = decodeURIComponent(
                session.redirectUri,
            ).replaceAll("{sessionId}", session.id);
            const sep = processedUri.includes("?") ? "&" : "?";
            return {
                redirect_uri: `${processedUri}${sep}response_code=${responseCode}`,
            };
        }

        return {};
    }
}

results matching ""

    No results matching ""