UNPKG

@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

459 lines (458 loc) • 21.3 kB
import { injectable } from "../../plugins/index.mjs"; import __decorate from "../../_virtual/_@oxc-project_runtime@0.151.0/helpers/esm/decorate.mjs"; import { CredoWebCrypto } from "../../crypto/webcrypto/CredoWebCrypto.mjs"; import "../../crypto/webcrypto/index.mjs"; import { X509CrlUnavailableError, X509Error } from "./X509Error.mjs"; import { X509Certificate } from "./X509Certificate.mjs"; import "./X509ValidationOptions.mjs"; import { X509ModuleConfig } from "./X509ModuleConfig.mjs"; import { validateCriticalCrlExtensionIds } from "./utils/criticalExtensions.mjs"; import { X509CertificateRevocationList } from "./X509CertificateRevocationList.mjs"; import { CrlSummaryVerifiedCrl, buildCrlSummary, computeCrlSha256, computeIssuerNameSha256, computeIssuerPublicJwkThumbprint } from "./utils/crlSummary.mjs"; import { fetchCrl, getCachedCrlSummary, setCachedCrlSummary } from "./utils/crlFetcher.mjs"; import "./X509CrlDistributionPoint.mjs"; //#region src/modules/x509/X509RevocationService.ts var _X509RevocationService; /** * Default freshness period for a cached verified CRL summary (1 hour): how long it is served * without being revalidated against freshly fetched CRL bytes. */ const DEFAULT_CRL_CACHE_EXPIRY_SECONDS = 3600; let X509RevocationService = _X509RevocationService = class X509RevocationService { /** * Check the revocation status of a single certificate using CRL. */ static async checkCertificateRevocation(agentContext, { certificate, issuerCertificate, revocationCheckOptions }) { const parsedCertificate = certificate instanceof X509Certificate ? certificate : X509Certificate.fromEncodedCertificate(certificate); const parsedIssuerCertificate = issuerCertificate instanceof X509Certificate ? issuerCertificate : X509Certificate.fromEncodedCertificate(issuerCertificate); const options = revocationCheckOptions ?? agentContext.dependencyManager.resolve(X509ModuleConfig).revocationCheck ?? { mode: "SoftFail" }; const mode = options.mode ?? "SoftFail"; if (mode === "Disabled") return { isValid: true, details: "Revocation checking disabled" }; try { return await _X509RevocationService.checkCrl(agentContext, parsedCertificate, parsedIssuerCertificate, options); } catch (error) { return _X509RevocationService.handleRevocationError(error, mode); } } /** * Check the revocation status of every certificate in a chain (the leaf and, unless * `checkFullChain` is disabled, the intermediate CAs). The root is skipped as it is self-issued and * not covered by a CRL we can fetch and verify. * * The chain must be ordered from root (index 0) to leaf (last index); each certificate's issuer is * the certificate that precedes it in the chain. * * The same CRL handling limitations as {@link checkCertificateRevocation} apply (CRL only; delta * and indirect CRLs rejected; no IDP distribution-point-name matching; no `onlySomeReasons` * cross-check; Freshest CRL / AIA and `invalidityDate` not processed). */ static async checkCertificateChainRevocation(agentContext, certificateChain, config, verificationDate) { const configuredRevocationCheck = config.revocationCheck; if (!configuredRevocationCheck || configuredRevocationCheck.mode === "Disabled") return { isValid: true, details: "Revocation checking disabled" }; const revocationConfig = { ...configuredRevocationCheck, verificationDate: configuredRevocationCheck.verificationDate ?? verificationDate }; const startIndex = revocationConfig.checkFullChain ?? true ? 1 : certificateChain.length - 1; for (let i = startIndex; i < certificateChain.length; i++) { const certificate = certificateChain[i]; const issuerCertificate = certificateChain[i - 1]; if (!issuerCertificate) continue; const result = await _X509RevocationService.checkCertificateRevocation(agentContext, { certificate, issuerCertificate, revocationCheckOptions: revocationConfig }); if (!result.isValid) return { isValid: false, error: result.error, details: result.details }; } return { isValid: true, details: "No certificates are revoked" }; } /** * Checks the revocation status of a certificate using CRL with proper reason partitioning * Based on RFC 5280 Section 5.2.5 */ static async checkCrl(agentContext, certificate, issuerCertificate, options) { const distributionPoints = certificate.crlDistributionPoints; if (distributionPoints.length === 0) return { isValid: true, details: "No CRL distribution points in certificate" }; const verificationDate = options.verificationDate ?? /* @__PURE__ */ new Date(); const fullDistributionPoint = distributionPoints.find((dp) => dp.reasons === void 0 && dp.crlIssuer === void 0); if (fullDistributionPoint) return await _X509RevocationService.checkDistributionPoint(agentContext, certificate, issuerCertificate, fullDistributionPoint, verificationDate, options); const requiredReasonsMask = options.requiredReasons ? _X509RevocationService.calculateReasonMask(options.requiredReasons) : _X509RevocationService.calculateAllReasonsInDPs(distributionPoints); let coveredReasons = 0; const coveredCrls = []; const fetchedDPs = []; const fetchErrors = []; const unsupportedDPs = []; let integrityFailure = false; for (const dp of distributionPoints) { const dpReasonMask = _X509RevocationService.calculateReasonMask(dp.reasons); if ((dpReasonMask & requiredReasonsMask & ~coveredReasons) === 0) continue; if (dp.crlIssuer !== void 0) { unsupportedDPs.push({ reasons: dpReasonMask, feature: `crlIssuer (${dp.crlIssuer})` }); continue; } const result = await _X509RevocationService.fetchAndVerifyCrl(agentContext, certificate, dp.urls, issuerCertificate, verificationDate, options); if (!result.success) { fetchErrors.push(`URLs ${dp.urls.join(", ")}: ${result.error}`); if (result.reachable) integrityFailure = true; continue; } coveredReasons |= dpReasonMask; fetchedDPs.push(result.usedUrl); coveredCrls.push(result.crl); if ((coveredReasons & requiredReasonsMask) === requiredReasonsMask) break; } let revokedEntry = null; for (const crl of coveredCrls) { revokedEntry = crl.findRevoked(certificate); if (revokedEntry) break; } if (revokedEntry) return { isValid: false, isRevoked: true, error: new X509Error(`Certificate '${certificate.subject}' has been revoked. Revocation date: ${revokedEntry.revocationDate.toISOString()}` + (revokedEntry.reason !== void 0 ? `, reason: ${revokedEntry.reason}` : "")), details: `Revoked on ${revokedEntry.revocationDate.toISOString()}`, method: "crl" }; if ((coveredReasons & requiredReasonsMask) !== requiredReasonsMask) { const missingReasons = requiredReasonsMask & ~coveredReasons; const coveredNames = _X509RevocationService.reasonMaskToNames(coveredReasons); const requiredNames = _X509RevocationService.reasonMaskToNames(requiredReasonsMask); const missingNames = _X509RevocationService.reasonMaskToNames(missingReasons); let errorMsg = `Unable to cover all required revocation reasons. Covered: [${coveredNames.join(", ")}], Required: [${requiredNames.join(", ")}], Missing: [${missingNames.join(", ")}]. Certificate revocation status cannot be fully determined.`; let unsupportedCoversMissing = false; for (const unsupported of unsupportedDPs) if (unsupported.reasons & missingReasons) { unsupportedCoversMissing = true; const unsupportedReasons = _X509RevocationService.reasonMaskToNames(unsupported.reasons & missingReasons); errorMsg += ` Unsupported CRL feature ${unsupported.feature} covers missing reasons: [${unsupportedReasons.join(", ")}].`; } if (fetchErrors.length > 0) errorMsg += ` Fetch errors: ${fetchErrors.join("; ")}`; const structuralGap = (missingReasons & ~_X509RevocationService.calculateAllReasonsInDPs(distributionPoints)) !== 0; throw !integrityFailure && !unsupportedCoversMissing && !structuralGap ? new X509CrlUnavailableError(errorMsg) : new X509Error(errorMsg); } return { isValid: true, isRevoked: false, details: `Certificate not found in CRLs from: ${fetchedDPs.join(", ")}`, method: "crl" }; } /** * Calculate bitmask of revocation reasons covered by a distribution point */ static calculateReasonMask(reasons) { if (!reasons || reasons.length === 0) return 511; let mask = 0; for (const reason of reasons) if (reason >= 0 && reason <= 8) mask |= 1 << reason; return mask; } /** * Calculate the union of all reasons present in distribution points * Used to determine the default required reasons (no point requiring reasons the issuer never published) */ static calculateAllReasonsInDPs(distributionPoints) { let allReasons = 0; for (const dp of distributionPoints) allReasons |= _X509RevocationService.calculateReasonMask(dp.reasons); return allReasons; } /** * Convert a bitmask of revocation reasons to human-readable names */ static reasonMaskToNames(mask) { const names = []; const reasonNames = { [0]: "unused", [1]: "keyCompromise", [2]: "cACompromise", [3]: "affiliationChanged", [4]: "superseded", [5]: "cessationOfOperation", [6]: "certificateHold", [7]: "privilegeWithdrawn", [8]: "aACompromise" }; for (let i = 0; i <= 8; i++) if (mask & 1 << i) names.push(reasonNames[i] ?? `unknown(${i})`); return names; } /** * Check revocation status using a single distribution point (typically a "full" DP) */ static async checkDistributionPoint(agentContext, certificate, issuerCertificate, distributionPoint, verificationDate, options) { const result = await _X509RevocationService.fetchAndVerifyCrl(agentContext, certificate, distributionPoint.urls, issuerCertificate, verificationDate, options); if (!result.success) { const message = `Failed to fetch CRL from distribution point (URLs: ${distributionPoint.urls.join(", ")}). Error: ${result.error}`; throw result.reachable ? new X509Error(message) : new X509CrlUnavailableError(message); } const revokedEntry = result.crl.findRevoked(certificate); if (revokedEntry) return { isValid: false, isRevoked: true, error: new X509Error(`Certificate '${certificate.subject}' has been revoked. Revocation date: ${revokedEntry.revocationDate.toISOString()}` + (revokedEntry.reason !== void 0 ? `, reason: ${revokedEntry.reason}` : "")), details: `Revoked on ${revokedEntry.revocationDate.toISOString()}`, method: "crl" }; return { isValid: true, isRevoked: false, details: `Certificate not found in CRL from ${result.usedUrl}`, method: "crl" }; } /** * Determine whether a verified CRL can be used to authoritatively determine the revocation status * of `certificate`. Uses the CRL's own extensions (RFC 5280 §5.2.4 Delta CRL Indicator and §5.2.5 * Issuing Distribution Point) to reject CRLs we cannot correctly interpret as a complete, direct * CRL covering this certificate. Returns a human-readable reason when the CRL must be rejected. */ static checkCrlApplicability(crl, certificate) { const criticalExtensions = validateCriticalCrlExtensionIds(crl.criticalExtensionIds); if (!criticalExtensions.isValid) return { usable: false, reason: criticalExtensions.error?.message ?? "CRL contains unrecognized critical extensions" }; if (crl.deltaCrlIndicator !== void 0) return { usable: false, reason: "CRL is a delta CRL, which is not supported for revocation checking" }; const issuingDistributionPoint = crl.issuingDistributionPoint; if (issuingDistributionPoint) { if (issuingDistributionPoint.indirectCRL) return { usable: false, reason: "CRL is an indirect CRL, which is not supported for revocation checking" }; if (issuingDistributionPoint.onlyContainsAttributeCerts) return { usable: false, reason: "CRL only covers attribute certificates" }; const isCertificateAuthority = certificate.isCertificateAuthority; if (issuingDistributionPoint.onlyContainsUserCerts && isCertificateAuthority) return { usable: false, reason: "CRL only covers end-entity certificates but the certificate is a CA certificate" }; if (issuingDistributionPoint.onlyContainsCACerts && !isCertificateAuthority) return { usable: false, reason: "CRL only covers CA certificates but the certificate is an end-entity certificate" }; } return { usable: true }; } /** * Fetch and verify a CRL from a list of mirror URLs * Tries each URL in order until one succeeds */ static async fetchAndVerifyCrl(agentContext, certificate, urls, issuerCertificate, verificationDate, options) { let lastError; let reachable = false; const webCrypto = new CredoWebCrypto(agentContext); for (const url of urls) { const result = await _X509RevocationService.fetchVerifyAndCacheCrl(agentContext, { url, issuerCertificate, verificationDate, options, useCache: true }, webCrypto); if (result.success) { const applicability = _X509RevocationService.checkCrlApplicability(result.crl, certificate); if (applicability.usable) return { success: true, crl: result.crl, usedUrl: url }; lastError = `CRL from ${url} cannot be used: ${applicability.reason}`; reachable = true; continue; } lastError = result.error; if (result.reachable) reachable = true; } return { success: false, error: lastError ?? `All CRL URLs failed: ${urls.join(", ")}`, reachable }; } /** * Obtain a verified CRL for a URL, preferring the cached verified summary. * * A summary is only reused when the provided issuer certificate carries the same subject name and * public key the summary was verified against, its key usage (when present) permits cRLSign, and * the summary's validity window covers `verificationDate` — together these reproduce exactly the * issuer-dependent checks of {@link X509CertificateRevocationList.verify}, so a renewed or * cross-certified issuer certificate (same name and key) reuses the summary directly. A reusable * summary that is still fresh (`staleAt` in the future) is then trusted from the * (application-controlled) cache without any network I/O; a stale one is revalidated on the full * path, which reuses it without re-parsing when the freshly fetched bytes are unchanged. * * Anything else — a different key or subject name, a key usage without cRLSign, an out-of-window, * missing or malformed summary — falls through to the full fetch-parse-verify path, which * produces all failure results (an unreachable host is then an availability failure like any * other fetch, also when revalidating a stale summary). On success the full path overwrites the * summary with the new issuer binding. */ static async fetchVerifyAndCacheCrl(agentContext, fetchOptions, webCrypto) { const { url, issuerCertificate, verificationDate, useCache } = fetchOptions; if (useCache) { const summary = await getCachedCrlSummary(agentContext, url); const verificationTime = verificationDate.getTime(); const issuerKeyUsage = issuerCertificate.keyUsage; if (summary && summary.issuerNameSha256 === computeIssuerNameSha256(issuerCertificate) && summary.issuerPublicJwkThumbprint === computeIssuerPublicJwkThumbprint(issuerCertificate) && !(issuerKeyUsage && issuerKeyUsage.length > 0 && !issuerKeyUsage.includes(64)) && verificationTime >= summary.thisUpdate && (summary.nextUpdate === void 0 || verificationTime <= summary.nextUpdate)) { if (summary.staleAt > verificationTime) return { success: true, crl: new CrlSummaryVerifiedCrl(summary) }; return _X509RevocationService.fetchParseVerifyAndCacheCrl(agentContext, { ...fetchOptions, staleSummary: summary }, webCrypto); } } return _X509RevocationService.fetchParseVerifyAndCacheCrl(agentContext, fetchOptions, webCrypto); } /** * Download a single CRL, parse it, and verify it against the issuer. When `useCache` is enabled, * a successful verification also caches a derived summary so subsequent checks can skip the * download and the (potentially very expensive) parse via {@link fetchVerifyAndCacheCrl}. * * `staleSummary` is a cached summary that passed all reuse gates but whose `staleAt` has passed: * when the fetched bytes hash to its `crlSha256`, the parse and verification are skipped and the * summary is re-cached with a refreshed `staleAt`. */ static async fetchParseVerifyAndCacheCrl(agentContext, { url, issuerCertificate, verificationDate, options, useCache, staleSummary }, webCrypto) { let reachable = false; try { const crlData = await fetchCrl({ url, timeoutMs: options.timeoutMs, maxSizeBytes: options.maxCrlSizeBytes, agentContext }); reachable = true; const crlCacheExpirySeconds = options.crlCacheExpirySeconds ?? agentContext.dependencyManager.resolve(X509ModuleConfig).revocationCheck?.crlCacheExpirySeconds ?? DEFAULT_CRL_CACHE_EXPIRY_SECONDS; const staleAt = verificationDate.getTime() + crlCacheExpirySeconds * 1e3; if (staleSummary && computeCrlSha256(crlData) === staleSummary.crlSha256) { const refreshed = { ...staleSummary, staleAt }; await setCachedCrlSummary(agentContext, url, refreshed, _X509RevocationService.computeCacheTtlSeconds(staleSummary.nextUpdate, verificationDate, crlCacheExpirySeconds)); return { success: true, crl: new CrlSummaryVerifiedCrl(refreshed) }; } const crl = X509CertificateRevocationList.fromRaw(crlData); const verifyResult = await crl.verify({ issuerCertificate, verificationDate }, webCrypto); if (!verifyResult.isValid) return { success: false, error: verifyResult.error?.message ?? "CRL verification failed", reachable }; if (useCache) { const ttlSeconds = _X509RevocationService.computeCacheTtlSeconds(crl.nextUpdate?.getTime(), verificationDate, crlCacheExpirySeconds); try { await setCachedCrlSummary(agentContext, url, buildCrlSummary(crl, issuerCertificate, staleAt), ttlSeconds); } catch {} } return { success: true, crl }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : String(error), reachable }; } } /** * Compute the cache TTL (in seconds) for a verified CRL summary: the full time until its * `nextUpdate` (freshness within that window is governed by the summary's `staleAt`). Returns 0 * when the CRL is already at/after `nextUpdate` so it is not cached. A CRL without `nextUpdate` * gets the freshness period itself as TTL, so its entry expires instead of being revalidated. */ static computeCacheTtlSeconds(nextUpdate, verificationDate, crlCacheExpirySeconds) { if (nextUpdate === void 0) return crlCacheExpirySeconds; return Math.max(0, Math.floor((nextUpdate - verificationDate.getTime()) / 1e3)); } /** * Handles errors during revocation checking based on the configured mode */ static handleRevocationError(error, mode) { const errorMessage = error instanceof Error ? error.message : String(error); if (mode === "Disabled") return { isValid: true, details: "Revocation checking disabled" }; if (mode === "SoftFail" && error instanceof X509CrlUnavailableError) return { isValid: true, details: `CRL could not be obtained but SoftFail mode allowed it: ${errorMessage}` }; return { isValid: false, error: new X509Error(`Required revocation check failed: ${errorMessage}`, { cause: error instanceof Error ? error : void 0 }) }; } /** * Fetch a CRL from a URL and parse it into an {@link X509CertificateRevocationList}. * * When `issuerCertificate` is provided, the CRL's signature, issuer name, issuer `cRLSign` key * usage, and validity window are verified, and an error is thrown on failure. When omitted, the CRL * is returned unverified. * * Note: this is a raw fetch+parse helper. Unlike {@link checkCertificateRevocation}, it does not * apply revocation-scope/applicability checks (delta/indirect/IDP scope), so it will return a delta * or scoped CRL as-is for inspection. */ static async fetchCertificateRevocationList(agentContext, { url, issuerCertificate, timeoutMs, maxCrlSizeBytes, verificationDate = /* @__PURE__ */ new Date() }) { if (!issuerCertificate) { const crlData = await fetchCrl({ url, timeoutMs, maxSizeBytes: maxCrlSizeBytes, agentContext }); return X509CertificateRevocationList.fromRaw(crlData); } const parsedIssuerCertificate = issuerCertificate instanceof X509Certificate ? issuerCertificate : X509Certificate.fromEncodedCertificate(issuerCertificate); const result = await _X509RevocationService.fetchParseVerifyAndCacheCrl(agentContext, { url, issuerCertificate: parsedIssuerCertificate, verificationDate, options: { timeoutMs, maxCrlSizeBytes }, useCache: false }, new CredoWebCrypto(agentContext)); if (!result.success) throw result.reachable ? new X509Error(`CRL from ${url} could not be verified: ${result.error}`) : new X509CrlUnavailableError(result.error); return result.crl; } }; X509RevocationService = _X509RevocationService = __decorate([injectable()], X509RevocationService); //#endregion export { X509RevocationService }; //# sourceMappingURL=X509RevocationService.mjs.map