src/verifier/iso18013/iso18013.service.ts
No results matching.
Methods |
|
constructor(presentationsService: PresentationsService, sessionService: SessionService, encryptionService: EncryptionService, mdocverifierService: MdocverifierService, webhookService: WebhookService, auditLogService: AuditLogService, configService: ConfigService, certService: CertService, keyChainService: KeyChainService, webhookEndpointRepo: Repository<WebhookEndpointEntity>, logger: PinoLogger)
|
||||||||||||||||||||||||||||||||||||
|
Defined in src/verifier/iso18013/iso18013.service.ts:66
|
||||||||||||||||||||||||||||||||||||
|
Parameters :
|
| 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 :
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 :
Returns :
Promise<Iso18013Offer>
|
| Async processResponse | ||||||||||||
processResponse(sessionId: string, encryptedB64: string)
|
||||||||||||
|
Process the HPKE-encrypted DeviceResponse returned by the wallet via DC API.
Parameters :
Returns :
Promise<Record<string, unknown>>
|
| Private Async resolveWebhookFromEndpoint | |||||||||
resolveWebhookFromEndpoint(webhookEndpointId: string | null | undefined, tenantId: string)
|
|||||||||
|
Defined in src/verifier/iso18013/iso18013.service.ts:83
|
|||||||||
|
Parameters :
Returns :
Promise<WebhookConfig | undefined>
|
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 {};
}
}