@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
JavaScript
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