File

src/crypto/key/cert/crl-validation.service.ts

Description

Service for validating certificates against Certificate Revocation Lists (CRL).

This service:

  • Extracts CRL Distribution Points from certificates
  • Fetches and caches CRL data
  • Checks if certificates are revoked

Index

Properties
Methods

Constructor

constructor(httpService: HttpService)
Parameters :
Name Type Optional
httpService HttpService No

Methods

Private arrayBufferToHex
arrayBufferToHex(buffer: ArrayBuffer)

Convert ArrayBuffer to hex string.

Parameters :
Name Type Optional
buffer ArrayBuffer No
Returns : string
Private Async checkAgainstCrl
checkAgainstCrl(cert: x509.X509Certificate, crlUrl: string)

Check a certificate against a specific CRL URL.

Parameters :
Name Type Optional
cert x509.X509Certificate No
crlUrl string No
Async checkCertificateRevocation
checkCertificateRevocation(certPem: string)

Check if a certificate is revoked according to its CRL.

Parameters :
Name Type Optional Description
certPem string No
  • PEM-encoded certificate to check

CRL validation result

clearCache
clearCache()

Clear the CRL cache.

Returns : void
extractCrlDistributionPoints
extractCrlDistributionPoints(cert: x509.X509Certificate)

Extract CRL Distribution Point URLs from a certificate.

Parameters :
Name Type Optional Description
cert x509.X509Certificate No
  • The X.509 certificate
Returns : string[]

Array of CRL URLs

Private extractUrlsFromParsedCdp
extractUrlsFromParsedCdp(value: any, urls: string[])

Extract URLs from parsed CDP extension data.

Parameters :
Name Type Optional
value any No
urls string[] No
Returns : void
Private extractUrlsFromRawCdp
extractUrlsFromRawCdp(rawData: ArrayBuffer, urls: string[])

Extract URLs from raw ASN.1 CDP extension data. This is a fallback when the library doesn't parse it for us.

Parameters :
Name Type Optional
rawData ArrayBuffer No
urls string[] No
Returns : void
Private Async fetchCrl
fetchCrl(url: string)

Fetch and parse a CRL from a URL, with caching.

Parameters :
Name Type Optional
url string No
Returns : Promise<CertificateList>
getCacheStats
getCacheStats()

Get cache statistics.

Returns : literal type
Private isCacheValid
isCacheValid(cached: CachedCrl)

Check if a cached CRL is still valid.

Parameters :
Name Type Optional
cached CachedCrl No
Returns : boolean
Private parseReasonCode
parseReasonCode(buffer: ArrayBuffer)

Parse CRL reason code.

Parameters :
Name Type Optional
buffer ArrayBuffer No
Returns : string

Properties

Private Readonly crlCache
Type : unknown
Default value : new Map<string, CachedCrl>()
Private Readonly defaultCacheTtlMs
Type : unknown
Default value : 60 * 60 * 1000

Default cache TTL in milliseconds (1 hour)

Private Readonly fetchTimeoutMs
Type : number
Default value : 10000

Timeout for CRL fetch in milliseconds

Private Readonly logger
Type : unknown
Default value : new Logger(CrlValidationService.name)
import { HttpService } from "@nestjs/axios";
import { Injectable, Logger } from "@nestjs/common";
import { AsnParser } from "@peculiar/asn1-schema";
import { CertificateList } from "@peculiar/asn1-x509";
import * as x509 from "@peculiar/x509";
import { firstValueFrom } from "rxjs";

/**
 * Cached CRL with metadata.
 */
interface CachedCrl {
    /** The parsed CRL */
    crl: CertificateList;
    /** When the cache entry was fetched */
    fetchedAt: number;
    /** Next update time from the CRL (if available) */
    nextUpdate?: Date;
}

/**
 * Result of a CRL validation check.
 */
export interface CrlValidationResult {
    /** Whether the certificate is valid (not revoked) */
    isValid: boolean;
    /** If revoked, the revocation date */
    revokedAt?: Date;
    /** If revoked, the reason code */
    reason?: string;
    /** Error message if validation failed */
    error?: string;
    /** Whether CRL was fetched from cache */
    fromCache?: boolean;
}

/**
 * Service for validating certificates against Certificate Revocation Lists (CRL).
 *
 * This service:
 * - Extracts CRL Distribution Points from certificates
 * - Fetches and caches CRL data
 * - Checks if certificates are revoked
 */
@Injectable()
export class CrlValidationService {
    private readonly logger = new Logger(CrlValidationService.name);
    private readonly crlCache = new Map<string, CachedCrl>();

    /** Default cache TTL in milliseconds (1 hour) */
    private readonly defaultCacheTtlMs = 60 * 60 * 1000;

    /** Timeout for CRL fetch in milliseconds */
    private readonly fetchTimeoutMs = 10000;

    constructor(private readonly httpService: HttpService) {}

    /**
     * Check if a certificate is revoked according to its CRL.
     *
     * @param certPem - PEM-encoded certificate to check
     * @returns CRL validation result
     */
    async checkCertificateRevocation(
        certPem: string,
    ): Promise<CrlValidationResult> {
        try {
            const cert = new x509.X509Certificate(certPem);
            const crlUrls = this.extractCrlDistributionPoints(cert);

            if (crlUrls.length === 0) {
                this.logger.debug(
                    `No CRL Distribution Points found in certificate ${cert.subject}`,
                );
                return {
                    isValid: true,
                    error: "No CRL Distribution Points in certificate",
                };
            }

            // Try each CRL URL until we get a successful check
            for (const url of crlUrls) {
                try {
                    const result = await this.checkAgainstCrl(cert, url);
                    return result;
                } catch (error: any) {
                    this.logger.warn(
                        `Failed to check CRL at ${url}: ${error.message}`,
                    );
                    continue;
                }
            }

            // All CRL URLs failed
            return {
                isValid: false,
                error: `Failed to validate against any CRL: ${crlUrls.join(", ")}`,
            };
        } catch (error: any) {
            this.logger.error(
                `CRL validation error: ${error.message}`,
                error.stack,
            );
            return {
                isValid: false,
                error: `CRL validation failed: ${error.message}`,
            };
        }
    }

    /**
     * Extract CRL Distribution Point URLs from a certificate.
     *
     * @param cert - The X.509 certificate
     * @returns Array of CRL URLs
     */
    extractCrlDistributionPoints(cert: x509.X509Certificate): string[] {
        const urls: string[] = [];

        // CRL Distribution Points OID: 2.5.29.31
        const cdpExtension = cert.getExtension("2.5.29.31");
        if (!cdpExtension) {
            return urls;
        }

        try {
            // The extension value is ASN.1 encoded
            // CRLDistributionPoints ::= SEQUENCE SIZE (1..MAX) OF DistributionPoint
            // We need to parse the raw value to extract URLs
            const extValue = (cdpExtension as any).value;

            if (extValue && typeof extValue === "object") {
                // @peculiar/x509 may provide parsed data
                this.extractUrlsFromParsedCdp(extValue, urls);
            }

            // If we couldn't extract from parsed data, try raw ASN.1
            if (urls.length === 0 && cdpExtension.rawData) {
                this.extractUrlsFromRawCdp(cdpExtension.rawData, urls);
            }
        } catch (error: any) {
            this.logger.warn(
                `Failed to parse CRL Distribution Points: ${error.message}`,
            );
        }

        return urls;
    }

    /**
     * Extract URLs from parsed CDP extension data.
     */
    private extractUrlsFromParsedCdp(value: any, urls: string[]): void {
        if (Array.isArray(value)) {
            for (const item of value) {
                this.extractUrlsFromParsedCdp(item, urls);
            }
        } else if (typeof value === "object" && value !== null) {
            // Look for fullName or uniformResourceIdentifier
            if (value.type === "url" && typeof value.value === "string") {
                urls.push(value.value);
            } else if (
                value.uniformResourceIdentifier &&
                typeof value.uniformResourceIdentifier === "string"
            ) {
                urls.push(value.uniformResourceIdentifier);
            } else if (value.fullName) {
                this.extractUrlsFromParsedCdp(value.fullName, urls);
            } else if (value.distributionPoint) {
                this.extractUrlsFromParsedCdp(value.distributionPoint, urls);
            } else {
                // Recursively check all properties
                for (const key of Object.keys(value)) {
                    this.extractUrlsFromParsedCdp(value[key], urls);
                }
            }
        } else if (
            typeof value === "string" &&
            (value.startsWith("http://") || value.startsWith("https://"))
        ) {
            urls.push(value);
        }
    }

    /**
     * Extract URLs from raw ASN.1 CDP extension data.
     * This is a fallback when the library doesn't parse it for us.
     */
    private extractUrlsFromRawCdp(rawData: ArrayBuffer, urls: string[]): void {
        const bytes = new Uint8Array(rawData);
        const str = new TextDecoder("utf-8", { fatal: false }).decode(bytes);

        // Simple regex to find HTTP URLs in the raw data
        const urlRegex = /https?:\/\/[^\x00-\x1f\x7f-\xff]+/g;
        const matches = str.match(urlRegex);
        if (matches) {
            for (const match of matches) {
                // Clean up any trailing garbage
                const cleanUrl = match.replace(/[^\x20-\x7e]/g, "");
                if (
                    cleanUrl.startsWith("http://") ||
                    cleanUrl.startsWith("https://")
                ) {
                    urls.push(cleanUrl);
                }
            }
        }
    }

    /**
     * Check a certificate against a specific CRL URL.
     */
    private async checkAgainstCrl(
        cert: x509.X509Certificate,
        crlUrl: string,
    ): Promise<CrlValidationResult> {
        const crl = await this.fetchCrl(crlUrl);
        const serialNumber = cert.serialNumber;

        // Check if the certificate is in the revoked certificates list
        const revokedCerts = crl.tbsCertList.revokedCertificates;

        if (!revokedCerts || revokedCerts.length === 0) {
            return { isValid: true, fromCache: this.crlCache.has(crlUrl) };
        }

        for (const revoked of revokedCerts) {
            // Convert serial numbers to comparable format
            // userCertificate is an ArrayBuffer in @peculiar/asn1-x509
            const revokedSerial = this.arrayBufferToHex(
                revoked.userCertificate as unknown as ArrayBuffer,
            );
            const certSerial = serialNumber.toLowerCase().replace(/:/g, "");

            if (revokedSerial === certSerial) {
                const revokedAt = revoked.revocationDate.getTime();
                let reason: string | undefined;

                // Try to extract revocation reason from extensions
                if (revoked.crlEntryExtensions) {
                    for (const ext of revoked.crlEntryExtensions) {
                        // Reason Code OID: 2.5.29.21
                        if (ext.extnID === "2.5.29.21") {
                            reason = this.parseReasonCode(
                                ext.extnValue as unknown as ArrayBuffer,
                            );
                        }
                    }
                }

                return {
                    isValid: false,
                    revokedAt: new Date(revokedAt),
                    reason,
                    fromCache: this.crlCache.has(crlUrl),
                };
            }
        }

        return { isValid: true, fromCache: this.crlCache.has(crlUrl) };
    }

    /**
     * Fetch and parse a CRL from a URL, with caching.
     */
    private async fetchCrl(url: string): Promise<CertificateList> {
        // Check cache first
        const cached = this.crlCache.get(url);
        if (cached && this.isCacheValid(cached)) {
            this.logger.debug(`Using cached CRL for ${url}`);
            return cached.crl;
        }

        this.logger.debug(`Fetching CRL from ${url}`);

        const ctrl = new AbortController();
        const timeout = setTimeout(() => ctrl.abort(), this.fetchTimeoutMs);

        try {
            const response = await firstValueFrom(
                this.httpService.get(url, {
                    signal: ctrl.signal,
                    responseType: "arraybuffer",
                    headers: {
                        Accept: "application/pkix-crl, application/x-pkcs7-crl",
                    },
                }),
            );

            const crlData = response.data as ArrayBuffer;
            const crl = AsnParser.parse(crlData, CertificateList);

            // Extract nextUpdate for cache TTL
            let nextUpdate: Date | undefined;
            if (crl.tbsCertList.nextUpdate) {
                nextUpdate = crl.tbsCertList.nextUpdate.getTime();
            }

            // Cache the CRL
            this.crlCache.set(url, {
                crl,
                fetchedAt: Date.now(),
                nextUpdate: nextUpdate ? new Date(nextUpdate) : undefined,
            });

            return crl;
        } catch (error: any) {
            if (
                error?.name === "CanceledError" ||
                error?.code === "ERR_CANCELED"
            ) {
                throw new Error(
                    `CRL fetch timed out after ${this.fetchTimeoutMs}ms for URL: ${url}`,
                );
            }
            throw new Error(
                `Failed to fetch CRL from ${url}: ${error?.message || error}`,
            );
        } finally {
            clearTimeout(timeout);
        }
    }

    /**
     * Check if a cached CRL is still valid.
     */
    private isCacheValid(cached: CachedCrl): boolean {
        const now = Date.now();

        // If CRL has nextUpdate, use that as the expiry
        if (cached.nextUpdate) {
            return now < cached.nextUpdate.getTime();
        }

        // Otherwise use default TTL
        return now - cached.fetchedAt < this.defaultCacheTtlMs;
    }

    /**
     * Convert ArrayBuffer to hex string.
     */
    private arrayBufferToHex(buffer: ArrayBuffer): string {
        return Array.from(new Uint8Array(buffer))
            .map((b) => b.toString(16).padStart(2, "0"))
            .join("");
    }

    /**
     * Parse CRL reason code.
     */
    private parseReasonCode(buffer: ArrayBuffer): string {
        const reasons = [
            "unspecified",
            "keyCompromise",
            "cACompromise",
            "affiliationChanged",
            "superseded",
            "cessationOfOperation",
            "certificateHold",
            "unused",
            "removeFromCRL",
            "privilegeWithdrawn",
            "aACompromise",
        ];

        try {
            const bytes = new Uint8Array(buffer);
            // Reason code is an ENUMERATED value, typically 3 bytes: 0x0a 0x01 <value>
            if (bytes.length >= 3 && bytes[0] === 0x0a && bytes[1] === 0x01) {
                const code = bytes[2];
                return reasons[code] || `unknown(${code})`;
            }
        } catch {
            // Ignore parsing errors
        }
        return "unknown";
    }

    /**
     * Clear the CRL cache.
     */
    clearCache(): void {
        this.crlCache.clear();
        this.logger.debug("CRL cache cleared");
    }

    /**
     * Get cache statistics.
     */
    getCacheStats(): { size: number; urls: string[] } {
        return {
            size: this.crlCache.size,
            urls: Array.from(this.crlCache.keys()),
        };
    }
}

results matching ""

    No results matching ""