@credo-ts/core
Version:
<p align="center"> <br /> <img alt="Credo Logo" src="https://github.com/openwallet-foundation/credo-ts/blob/c7886cb8377ceb8ee4efe8d264211e561a75072d/images/credo-logo.png" height="250px" /> </p> <h1 align="center"><b>Credo - Core</b></h1
417 lines (416 loc) • 19.1 kB
JavaScript
import { TypedArrayEncoder } from "../../utils/TypedArrayEncoder.mjs";
import "../../utils/index.mjs";
import { injectable } from "../../plugins/index.mjs";
import __decorateMetadata from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorateMetadata.mjs";
import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.mjs";
import "../../agent/index.mjs";
import { KeyManagementApi } from "../kms/KeyManagementApi.mjs";
import "../kms/index.mjs";
import { convertLegacyTrustedCertificates } from "../x509/utils/convertLegacyTrustedCertificates.mjs";
import { X509Certificate } from "../x509/X509Certificate.mjs";
import { X509ModuleConfig } from "../x509/X509ModuleConfig.mjs";
import { EventEmitter } from "../../agent/EventEmitter.mjs";
import { X509Service } from "../x509/X509Service.mjs";
import { mapNonEmptyArray } from "../../types.mjs";
import "../../utils/credentialUseTypes.mjs";
import { TrustedIssuerContext } from "../../agent/TrustedIssuerContext.mjs";
import { getMdocContext } from "../../crypto/contexts/mdocContext.mjs";
import "../x509/index.mjs";
import { MdocDeviceRequestNotSatisfiedError, MdocError, MdocVerificationSessionExpiredError } from "./MdocError.mjs";
import { mdocSigningJwk, nameSpacesRecordToDeviceNamespaces } from "./mdocUtil.mjs";
import { Mdoc } from "./Mdoc.mjs";
import { MdocRecord } from "./repository/MdocRecord.mjs";
import { MdocRepository } from "./repository/MdocRepository.mjs";
import { MdocVerificationSessionRecord } from "./repository/MdocVerificationSessionRecord.mjs";
import { MdocVerificationSessionRepository } from "./repository/MdocVerificationSessionRepository.mjs";
import "./repository/index.mjs";
import { canUseInstanceFromCredentialRecord, useInstanceFromCredentialRecord } from "../../utils/credentialUse.mjs";
import { MdocDeviceResponse } from "./MdocDeviceResponse.mjs";
import "./MdocEvents.mjs";
import "./MdocVerificationSessionState.mjs";
import { createDeviceRequestDefinition, getDeviceRequestMatchOptions } from "./utils/deviceRequestDefinition.mjs";
import { CoseKey, EncryptionInfo, Holder, IsoMdocDcApi, Verifier, defaultVerificationCallback, onCategoryCheck, reportDeviceRequestMatch } from "@owf/mdoc";
//#region src/modules/mdoc/MdocDcApiService.ts
const defaultExpiresInSeconds = 300;
let MdocDcApiService = class MdocDcApiService {
constructor(mdocRepository, mdocVerificationSessionRepository, eventEmitter) {
this.mdocRepository = mdocRepository;
this.mdocVerificationSessionRepository = mdocVerificationSessionRepository;
this.eventEmitter = eventEmitter;
}
/**
* Create an `org-iso-mdoc` request (verifier side) and persist a verification session for it.
*
* The origin is not part of the request payload; it only enters the protocol through the session
* transcript. It therefore has to be supplied again to {@link MdocDcApiService.verifyResponse},
* except that a `readerAuth` request signs over the transcript and is fixed to the origin it is
* created for.
*/
async createVerificationSession(agentContext, options) {
const kms = agentContext.resolve(KeyManagementApi);
const mdocContext = getMdocContext(agentContext);
const deviceRequestDefinition = createDeviceRequestDefinition(options.docRequests, options.treatAmbiguousMultipleDocRequestsAsAlternatives);
const { keyId, publicJwk } = await kms.createKey({ type: {
kty: "EC",
crv: "P-256"
} });
try {
const readerCertificateChain = options.readerAuth ? Array.isArray(options.readerAuth.certificate) ? options.readerAuth.certificate : [options.readerAuth.certificate] : void 0;
const { request, encryptionInfo } = await IsoMdocDcApi.createRequest({
docRequests: deviceRequestDefinition.docRequests.map(({ docType, nameSpaces }) => ({
docType,
namespaces: Object.fromEntries(Object.entries(nameSpaces).map(([nameSpace, elements]) => [nameSpace, Object.fromEntries(Object.entries(elements).map(([elementIdentifier, { intentToRetain }]) => [elementIdentifier, intentToRetain]))]))
})),
recipientPublicKey: CoseKey.fromJwk({
...publicJwk,
kid: keyId
}),
readerAuth: readerCertificateChain && options.readerAuth ? {
signingKey: CoseKey.fromJwk(mdocSigningJwk(readerCertificateChain[0].publicJwk)),
certificateChain: readerCertificateChain.map((certificate) => certificate.rawCertificate),
origin: options.readerAuth.origin
} : void 0
}, mdocContext);
const verificationSession = new MdocVerificationSessionRecord({
state: "RequestCreated",
deviceRequestBase64Url: request.deviceRequest,
deviceRequestDefinition,
sessionTranscript: {
type: "isoMdocDcApi",
encryptionInfoBase64Url: request.encryptionInfo,
nonce: TypedArrayEncoder.toBase64Url(encryptionInfo.nonce)
},
sessionKeyId: keyId,
expiresAt: new Date(Date.now() + (options.expiresInSeconds ?? defaultExpiresInSeconds) * 1e3)
});
await this.mdocVerificationSessionRepository.save(agentContext, verificationSession);
this.emitStateChangedEvent(agentContext, verificationSession, null);
return {
verificationSession,
request
};
} catch (error) {
await kms.deleteKey({ keyId }).catch((deleteError) => {
agentContext.config.logger.error(`Failed to delete session key ${keyId} after failed request creation`, {
deleteError,
requestCreationError: error
});
});
throw error;
}
}
/**
* Decrypt and verify an `org-iso-mdoc` response (verifier side).
*
* The response is bound to the origin through the session transcript, so decryption only succeeds
* for the origin the wallet actually used. That is what makes a response relayed from another
* origin fail.
*/
async verifyResponse(agentContext, options) {
const mdocContext = getMdocContext(agentContext);
const verificationSession = await this.mdocVerificationSessionRepository.getById(agentContext, options.verificationSessionId);
verificationSession.assertState("RequestCreated");
const sessionTranscript = verificationSession.getSessionTranscript("isoMdocDcApi");
const response = options.response.response;
try {
if (verificationSession.isExpired) throw new MdocVerificationSessionExpiredError(verificationSession.expiresAt);
const recipientKey = CoseKey.fromJwk({
...EncryptionInfo.fromBase64Url(sessionTranscript.encryptionInfoBase64Url).recipientPublicKey.jwk,
kid: verificationSession.sessionKeyId
});
const decrypted = await IsoMdocDcApi.decryptResponse({
response,
origin: options.origin,
encryptionInfo: sessionTranscript.encryptionInfoBase64Url,
recipientKey
}, mdocContext);
const deviceResponse = MdocDeviceResponse.fromBase64Url(TypedArrayEncoder.toBase64Url(decrypted.deviceResponse.encode()));
const sessionTranscriptOptions = {
type: "sessionTranscriptBytes",
sessionTranscriptBytes: decrypted.sessionTranscript.encode()
};
if (options.trustedCertificates) await deviceResponse.verify(agentContext, {
trustedCertificates: options.trustedCertificates,
sessionTranscriptOptions,
now: options.now
});
else for (const documentResponse of deviceResponse.splitIntoSingleDocumentResponses()) {
const mdoc = new Mdoc(documentResponse.deviceResponse.documents[0].issuerSigned);
await documentResponse.verify(agentContext, {
trustedCertificates: await this.getTrustedCertificatesForMdoc(agentContext, mdoc),
sessionTranscriptOptions,
now: options.now
});
}
const deviceRequestMatch = Verifier.matchDeviceRequest({
deviceRequest: TypedArrayEncoder.fromBase64Url(verificationSession.deviceRequestBase64Url),
deviceResponse: decrypted.deviceResponse,
matchOptions: getDeviceRequestMatchOptions(verificationSession.deviceRequestDefinition)
});
if (!deviceRequestMatch.success) {
const reasons = [];
reportDeviceRequestMatch(deviceRequestMatch, ({ status, reason, check }) => {
if (status === "FAILED") reasons.push(reason ?? check);
});
throw new MdocDeviceRequestNotSatisfiedError(deviceRequestMatch, `Device response does not satisfy the device request. ${reasons.join(". ")}`);
}
verificationSession.sessionTranscript = {
...sessionTranscript,
origin: options.origin
};
await this.updateState(agentContext, verificationSession, "ResponseVerified");
return {
verificationSession,
deviceResponse,
deviceRequestMatch,
origin: options.origin
};
} catch (error) {
verificationSession.errorMessage = error instanceof Error ? error.message : "Unknown error";
await this.updateState(agentContext, verificationSession, "Error");
throw error;
}
}
/**
* Resolve the trusted certificates for a single mdoc in a device response.
*
* Same resolution order as the other credential verification paths: the global
* `getTrustedIssuersForVerification` callback first, then the deprecated
* `getTrustedCertificatesForVerification` callback, and only if neither returns anything the
* statically configured trusted certificates. Both callbacks receive the certificate chain of
* the mdoc being verified, so trust can be decided per document.
*/
async getTrustedCertificatesForMdoc(agentContext, mdoc) {
const x509ModuleConfig = agentContext.dependencyManager.resolve(X509ModuleConfig);
const certificateChain = mdoc.issuerSignedCertificateChain.map((certificate) => X509Certificate.fromRawCertificate(certificate));
return (await TrustedIssuerContext.getTrustedIssuersForVerification(agentContext, {
signer: {
method: "x509",
certificateChain
},
verification: {
type: "credential",
credential: mdoc
}
}))?.trustedIssuers ?? await x509ModuleConfig.getTrustedCertificatesForVerification?.(agentContext, {
certificateChain,
verification: {
type: "credential",
credential: mdoc
}
}) ?? x509ModuleConfig.trustedCertificates ?? [];
}
/**
* Assert the reader certificate chain of a single doc request is trusted.
*
* Same resolution order as the issuer trust in {@link MdocDcApiService.getTrustedCertificatesForMdoc}:
* the certificates passed to `resolveRequest` first, then the global
* `getTrustedIssuersForVerification` callback, then the deprecated
* `getTrustedCertificatesForVerification` callback, and only if none of these return anything
* the statically configured trusted certificates.
*
* As everywhere else x509 signers are verified, resolving nothing is not a reason to continue
* without establishing trust: the reader certificate chain is signed by the reader itself, so
* skipping the chain check would leave the reader identity self-asserted. A wallet that wants to
* decide on the reader identity itself can return the leaf certificate from the callback, which
* trusts the reader on the certificate it presented.
*/
async assertTrustedReaderCertificateChain(agentContext, options) {
const x509ModuleConfig = agentContext.dependencyManager.resolve(X509ModuleConfig);
let trustedCertificates = options.trustedReaderCertificates?.map((certificate) => typeof certificate === "string" ? certificate : certificate.toString("pem"));
if (!trustedCertificates) {
const trustedIssuers = await TrustedIssuerContext.getTrustedIssuersForVerification(agentContext, {
signer: {
method: "x509",
certificateChain: options.certificateChain
},
verification: options.verification
});
const legacyTrustedCertificates = trustedIssuers ? void 0 : await x509ModuleConfig.getTrustedCertificatesForVerification?.(agentContext, {
certificateChain: options.certificateChain,
verification: options.verification
});
trustedCertificates = trustedIssuers?.trustedIssuers.flatMap(({ issuance }) => issuance) ?? (legacyTrustedCertificates ? convertLegacyTrustedCertificates(legacyTrustedCertificates).flatMap(({ issuance }) => issuance) : void 0) ?? x509ModuleConfig.trustedCertificates;
}
if (!trustedCertificates) throw new MdocError("No trusted certificates found. Cannot verify reader authentication.");
const onCheck = onCategoryCheck(defaultVerificationCallback, "READER_AUTH");
try {
await X509Service.validateCertificateChain(agentContext, {
certificateChain: options.certificateChain.map((certificate) => certificate.toString("pem")),
trustedCertificates,
verificationDate: options.now
});
onCheck({
status: "PASSED",
check: "Reader certificate chain must be trusted"
});
} catch (error) {
onCheck({
status: "FAILED",
check: "Reader certificate chain must be trusted",
reason: error instanceof Error ? error.message : "Unknown error"
});
}
}
/**
* Parse an incoming `org-iso-mdoc` request (wallet side) and match it against stored mdocs.
*
* `origin` must be the origin provided by the platform; Annex C C.5 requires the mdoc to abort
* when no origin was received.
*/
async resolveRequest(agentContext, options) {
const mdocContext = getMdocContext(agentContext);
const parsedRequest = await IsoMdocDcApi.parseRequest({
request: options.request,
origin: options.origin,
disableReaderCertificateChainValidation: true,
now: options.now
}, mdocContext);
const candidates = await this.getCandidates(agentContext, parsedRequest.docRequests.map((docRequest) => docRequest.docType), options.useMode ?? "NewOrFirst");
const deviceNamespaces = options.deviceNameSpaces ? nameSpacesRecordToDeviceNamespaces(options.deviceNameSpaces) : void 0;
const holderMatch = Holder.matchDeviceRequest({
deviceRequest: parsedRequest.deviceRequest,
credentials: candidates.map(({ mdoc }) => ({
issuerSigned: mdoc.issuerSigned,
deviceNamespaces
})),
treatAmbiguousMultipleDocRequestsAsAlternatives: options.treatAmbiguousMultipleDocRequestsAsAlternatives
});
const toValidCredential = ({ credentialIndex, ...credentialMatch }) => ({
...credentialMatch,
record: candidates[credentialIndex].record
});
const toFailedCredential = ({ credentialIndex, ...credentialMatch }) => ({
...credentialMatch,
record: candidates[credentialIndex].record
});
const docRequests = [];
for (const [docRequestIndex, docRequest] of parsedRequest.docRequests.entries()) {
const nameSpaces = Object.fromEntries(Array.from(docRequest.namespaces.entries()).map(([nameSpace, elements]) => [nameSpace, Object.fromEntries(elements.entries())]));
const readerAuth = docRequest.hasReaderAuth ? { certificateChain: (docRequest.readerCertificateChain ?? []).map((certificate) => X509Certificate.fromRawCertificate(certificate)) } : void 0;
if (readerAuth) await this.assertTrustedReaderCertificateChain(agentContext, {
certificateChain: readerAuth.certificateChain,
verification: {
type: "mdocReaderAuth",
exchange: {
type: "dcApi",
origin: parsedRequest.origin
},
docType: docRequest.docType,
nameSpaces
},
trustedReaderCertificates: options.trustedReaderCertificates,
now: options.now
});
const docRequestMatch = holderMatch.docRequests[docRequestIndex];
const resolvedDocRequest = {
docRequestIndex,
docType: docRequest.docType,
nameSpaces,
readerAuth,
failedCredentials: docRequestMatch.failedCredentials.map(toFailedCredential)
};
docRequests.push(docRequestMatch.success ? {
...resolvedDocRequest,
success: true,
validCredentials: mapNonEmptyArray(docRequestMatch.validCredentials, toValidCredential)
} : {
...resolvedDocRequest,
success: false,
validCredentials: []
});
}
if (holderMatch.docRequestsAsAlternatives) return {
origin: parsedRequest.origin,
success: holderMatch.success,
docRequestsAsAlternatives: true,
docRequests,
parsedRequest
};
return holderMatch.success ? {
origin: parsedRequest.origin,
success: true,
docRequestsAsAlternatives: false,
docRequests: docRequests.filter((docRequest) => docRequest.success),
parsedRequest
} : {
origin: parsedRequest.origin,
success: false,
docRequestsAsAlternatives: false,
docRequests,
parsedRequest
};
}
/**
* Fetch the stored mdocs for the requested doctypes.
*
* Only the requested doctypes are fetched, as a wallet can hold many more mdocs than a request
* covers. The issuer-signed CBOR of each record is decoded once, and matched against every doc
* request from there.
*
* Records that cannot provide an instance for `useMode` are left out, as creating a response with
* them would throw.
*/
async getCandidates(agentContext, docTypes, useMode) {
const uniqueDocTypes = Array.from(new Set(docTypes));
if (uniqueDocTypes.length === 0) return [];
return (await this.mdocRepository.findByQuery(agentContext, { $or: uniqueDocTypes.map((docType) => ({ docType })) })).filter((record) => canUseInstanceFromCredentialRecord({
credentialRecord: record,
useMode
})).map((record) => ({
record,
mdoc: record.firstCredential
}));
}
/**
* Create the encrypted `org-iso-mdoc` response (wallet side).
*/
async createResponse(agentContext, options) {
const mdocContext = getMdocContext(agentContext);
const parsedRequest = options.resolvedRequest.parsedRequest;
const documents = await Promise.all(options.credentials.map(async ({ docRequestIndex, record, useMode, elements, deviceNameSpaces }) => {
const mdocRecord = record instanceof MdocRecord ? record : await this.mdocRepository.getById(agentContext, record);
const { credentialInstance: mdoc } = await useInstanceFromCredentialRecord({
agentContext,
credentialRecord: mdocRecord,
useMode: useMode ?? "NewOrFirst"
});
const deviceKeyJwk = mdocSigningJwk(mdoc.deviceKey);
if (!deviceKeyJwk.kid) throw new MdocError("Unable to create device response. The mdoc device key has no key id.");
return {
docRequestIndex,
issuerSigned: mdoc.issuerSigned,
deviceKey: CoseKey.fromJwk(deviceKeyJwk),
elements,
deviceNamespaces: deviceNameSpaces ? nameSpacesRecordToDeviceNamespaces(deviceNameSpaces) : void 0
};
}));
return await IsoMdocDcApi.createResponse({
parsedRequest,
documents
}, mdocContext);
}
async updateState(agentContext, verificationSession, newState) {
const previousState = verificationSession.state;
verificationSession.state = newState;
await this.mdocVerificationSessionRepository.update(agentContext, verificationSession);
this.emitStateChangedEvent(agentContext, verificationSession, previousState);
}
emitStateChangedEvent(agentContext, verificationSession, previousState) {
this.eventEmitter.emit(agentContext, {
type: "Mdoc.VerificationSessionStateChanged",
payload: {
verificationSession: verificationSession.clone(),
previousState
}
});
}
};
MdocDcApiService = __decorate([injectable(), __decorateMetadata("design:paramtypes", [
typeof MdocRepository === "undefined" ? Object : MdocRepository,
typeof MdocVerificationSessionRepository === "undefined" ? Object : MdocVerificationSessionRepository,
typeof EventEmitter === "undefined" ? Object : EventEmitter
])], MdocDcApiService);
//#endregion
export { MdocDcApiService };
//# sourceMappingURL=MdocDcApiService.mjs.map