UNPKG

node-opcua-pki

Version:
1,193 lines (1,180 loc) 192 kB
#!/usr/bin/env node import { __commonJS, __dirname, __esm, __filename, init_esm_shims } from "../chunk-GCHH54PS.mjs"; // packages/node-opcua-pki/lib/misc/applicationurn.ts import assert from "assert"; import { createHash } from "crypto"; function makeApplicationUrn(hostname, suffix) { let hostnameHash = hostname; if (hostnameHash.length + 7 + suffix.length >= 64) { hostnameHash = createHash("md5").update(hostname).digest("hex").substring(0, 16); } const applicationUrn = `urn:${hostnameHash}:${suffix}`; assert(applicationUrn.length <= 64); return applicationUrn; } var init_applicationurn = __esm({ "packages/node-opcua-pki/lib/misc/applicationurn.ts"() { "use strict"; init_esm_shims(); } }); // packages/node-opcua-pki/lib/misc/hostname.ts import dns from "dns"; import os from "os"; import { promisify } from "util"; function trim(str, length) { if (!length) { return str; } return str.substring(0, Math.min(str.length, length)); } function fqdn(callback) { const uqdn = os.hostname(); dns.lookup(uqdn, { hints: dns.ADDRCONFIG }, (err1, ip) => { if (err1) { return callback(err1); } dns.lookupService(ip, 0, (err2, _fqdn) => { if (err2) { return callback(err2); } _fqdn = _fqdn.replace(".localdomain", ""); callback(null, _fqdn); }); }); } async function extractFullyQualifiedDomainName() { if (_fullyQualifiedDomainNameInCache) { return _fullyQualifiedDomainNameInCache; } if (process.platform === "win32") { const env = process.env; _fullyQualifiedDomainNameInCache = env.COMPUTERNAME + (env.USERDNSDOMAIN && env.USERDNSDOMAIN?.length > 0 ? `.${env.USERDNSDOMAIN}` : ""); } else { try { _fullyQualifiedDomainNameInCache = await promisify(fqdn)(); if (_fullyQualifiedDomainNameInCache === "localhost") { throw new Error("localhost not expected"); } if (/sethostname/.test(_fullyQualifiedDomainNameInCache)) { throw new Error("Detecting fqdn on windows !!!"); } } catch (_err) { _fullyQualifiedDomainNameInCache = os.hostname(); } } return _fullyQualifiedDomainNameInCache; } async function prepareFQDN() { _fullyQualifiedDomainNameInCache = await extractFullyQualifiedDomainName(); } function getFullyQualifiedDomainName(optional_max_length) { if (!_fullyQualifiedDomainNameInCache) { throw new Error("FullyQualifiedDomainName computation is not completed yet"); } return _fullyQualifiedDomainNameInCache ? trim(_fullyQualifiedDomainNameInCache, optional_max_length) : "%FQDN%"; } var _fullyQualifiedDomainNameInCache; var init_hostname = __esm({ "packages/node-opcua-pki/lib/misc/hostname.ts"() { "use strict"; init_esm_shims(); prepareFQDN(); } }); // packages/node-opcua-pki/lib/toolbox/config.ts var g_config; var init_config = __esm({ "packages/node-opcua-pki/lib/toolbox/config.ts"() { "use strict"; init_esm_shims(); g_config = { opensslVersion: "unset", silent: process.env.VERBOSE ? !process.env.VERBOSE : true, force: false }; } }); // packages/node-opcua-pki/lib/toolbox/debug.ts function debugLog(...args) { if (displayDebug) { console.log.apply(null, args); } } function warningLog(...args) { console.log.apply(null, args); } var doDebug, displayError, displayDebug; var init_debug = __esm({ "packages/node-opcua-pki/lib/toolbox/debug.ts"() { "use strict"; init_esm_shims(); doDebug = process.env.NODEOPCUAPKIDEBUG || false; displayError = true; displayDebug = !!process.env.NODEOPCUAPKIDEBUG || false; } }); // packages/node-opcua-pki/lib/toolbox/common2.ts import assert2 from "assert"; import fs from "fs"; import path from "path"; import chalk from "chalk"; function certificateFileExist(certificateFile) { if (fs.existsSync(certificateFile) && !g_config.force) { warningLog( chalk.yellow(" certificate ") + chalk.cyan(certificateFile) + chalk.yellow(" already exists => do not overwrite") ); return false; } return true; } function mkdirRecursiveSync(folder) { if (!fs.existsSync(folder)) { debugLog(chalk.white(" .. constructing "), folder); fs.mkdirSync(folder, { recursive: true }); } } function makePath(folderName, filename) { let s; if (filename) { s = path.join(path.normalize(folderName), filename); } else { assert2(folderName); s = folderName; } s = s.replace(/\\/g, "/"); return s; } var init_common2 = __esm({ "packages/node-opcua-pki/lib/toolbox/common2.ts"() { "use strict"; init_esm_shims(); init_config(); init_debug(); } }); // packages/node-opcua-pki/lib/toolbox/display.ts import chalk2 from "chalk"; function displayChapter(str) { const l = " "; warningLog(`${chalk2.bgWhite(l)} `); str = ` ${str}${l}`.substring(0, l.length); warningLog(chalk2.bgWhite.cyan(str)); warningLog(`${chalk2.bgWhite(l)} `); } function displayTitle(str) { if (!g_config.silent) { warningLog(""); warningLog(chalk2.yellowBright(str)); warningLog(chalk2.yellow(new Array(str.length + 1).join("=")), "\n"); } } function displaySubtitle(str) { if (!g_config.silent) { warningLog(""); warningLog(` ${chalk2.yellowBright(str)}`); warningLog(` ${chalk2.white(new Array(str.length + 1).join("-"))}`, "\n"); } } function display(str) { if (!g_config.silent) { warningLog(` ${str}`); } } var init_display = __esm({ "packages/node-opcua-pki/lib/toolbox/display.ts"() { "use strict"; init_esm_shims(); init_config(); init_debug(); } }); // packages/node-opcua-pki/lib/toolbox/without_openssl/create_certificate_signing_request.ts import assert3 from "assert"; import fs2 from "fs"; import { createCertificateSigningRequest, pemToPrivateKey, Subject } from "node-opcua-crypto"; async function createCertificateSigningRequestAsync(certificateSigningRequestFilename, params) { assert3(params); assert3(params.rootDir); assert3(params.configFile); assert3(params.privateKey); assert3(typeof params.privateKey === "string"); assert3(fs2.existsSync(params.privateKey), `Private key must exist${params.privateKey}`); assert3(fs2.existsSync(params.rootDir), "RootDir key must exist"); assert3(typeof certificateSigningRequestFilename === "string"); const subject = params.subject ? new Subject(params.subject).toString() : void 0; displaySubtitle("- Creating a Certificate Signing Request with subtile"); const privateKeyPem = await fs2.promises.readFile(params.privateKey, "utf-8"); const privateKey = await pemToPrivateKey(privateKeyPem); const { csr } = await createCertificateSigningRequest({ privateKey, dns: params.dns, ip: params.ip, subject, applicationUri: params.applicationUri, purpose: params.purpose }); await fs2.promises.writeFile(certificateSigningRequestFilename, csr, "utf-8"); display(`- privateKey ${params.privateKey}`); display(`- certificateSigningRequestFilename ${certificateSigningRequestFilename}`); } var init_create_certificate_signing_request = __esm({ "packages/node-opcua-pki/lib/toolbox/without_openssl/create_certificate_signing_request.ts"() { "use strict"; init_esm_shims(); init_display(); } }); // packages/node-opcua-pki/lib/toolbox/common.ts import assert4 from "assert"; function quote(str) { return `"${str || ""}"`; } function adjustDate(params) { assert4(params instanceof Object); params.startDate = params.startDate || /* @__PURE__ */ new Date(); assert4(params.startDate instanceof Date); if (params.validityMs !== void 0) { if (params.validityMs <= 0) { throw new RangeError(`validityMs must be > 0 (got ${params.validityMs})`); } params.endDate = new Date(params.startDate.getTime() + params.validityMs); params.validity = Math.ceil(params.validityMs / 864e5); } else { params.validity = params.validity || 365; params.endDate = new Date(params.startDate.getTime()); params.endDate.setDate(params.startDate.getDate() + params.validity); } assert4(params.endDate instanceof Date); assert4(params.startDate instanceof Date); } function adjustApplicationUri(params) { const applicationUri = params.applicationUri || ""; if (applicationUri.length > 200) { throw new Error(`Openssl doesn't support urn with length greater than 200${applicationUri}`); } } var init_common = __esm({ "packages/node-opcua-pki/lib/toolbox/common.ts"() { "use strict"; init_esm_shims(); } }); // packages/node-opcua-pki/lib/toolbox/without_openssl/create_self_signed_certificate.ts import assert5 from "assert"; import fs3 from "fs"; import { CertificatePurpose, createSelfSignedCertificate as createSelfSignedCertificate1, pemToPrivateKey as pemToPrivateKey2, Subject as Subject2 } from "node-opcua-crypto"; async function createSelfSignedCertificateAsync(certificate, params) { params.purpose = params.purpose || CertificatePurpose.ForApplication; assert5(params.purpose, "Please provide a Certificate Purpose"); assert5(fs3.existsSync(params.configFile)); assert5(fs3.existsSync(params.rootDir)); assert5(fs3.existsSync(params.privateKey)); if (!params.subject) { throw Error("Missing subject"); } assert5(typeof params.applicationUri === "string"); assert5(Array.isArray(params.dns)); adjustDate(params); assert5(Object.prototype.hasOwnProperty.call(params, "validity")); let subject = new Subject2(params.subject); subject = subject.toString(); const purpose = params.purpose; displayTitle("Generate a certificate request"); const privateKeyPem = await fs3.promises.readFile(params.privateKey, "utf-8"); const privateKey = await pemToPrivateKey2(privateKeyPem); const { cert } = await createSelfSignedCertificate1({ privateKey, notBefore: params.startDate, notAfter: params.endDate, validity: params.validity, dns: params.dns, ip: params.ip, subject, applicationUri: params.applicationUri, purpose }); await fs3.promises.writeFile(certificate, cert, "utf-8"); } async function createSelfSignedCertificate(certificate, params) { await createSelfSignedCertificateAsync(certificate, params); } var init_create_self_signed_certificate = __esm({ "packages/node-opcua-pki/lib/toolbox/without_openssl/create_self_signed_certificate.ts"() { "use strict"; init_esm_shims(); init_common(); init_display(); } }); // packages/node-opcua-pki/lib/toolbox/without_openssl/index.ts var init_without_openssl = __esm({ "packages/node-opcua-pki/lib/toolbox/without_openssl/index.ts"() { "use strict"; init_esm_shims(); init_create_certificate_signing_request(); init_create_self_signed_certificate(); } }); // packages/node-opcua-pki/lib/pki/templates/simple_config_template.cnf.ts var config, simple_config_template_cnf_default; var init_simple_config_template_cnf = __esm({ "packages/node-opcua-pki/lib/pki/templates/simple_config_template.cnf.ts"() { "use strict"; init_esm_shims(); config = '##################################################################################################\n## SIMPLE OPENSSL CONFIG FILE FOR SELF-SIGNED CERTIFICATE GENERATION\n################################################################################################################\n\ndistinguished_name = req_distinguished_name\ndefault_md = sha1\n\ndefault_md = sha256 # The default digest algorithm\n\n[ v3_ca ]\nsubjectKeyIdentifier = hash\nauthorityKeyIdentifier = keyid:always,issuer:always\n\n# authorityKeyIdentifier = keyid\nbasicConstraints = CA:TRUE\nkeyUsage = critical, cRLSign, keyCertSign\nnsComment = "Self-signed Certificate for CA generated by Node-OPCUA Certificate utility"\n#nsCertType = sslCA, emailCA\n#subjectAltName = email:copy\n#issuerAltName = issuer:copy\n#obj = DER:02:03\n# crlDistributionPoints = @crl_info\n# [ crl_info ]\n# URI.0 = http://localhost:8900/crl.pem\nsubjectAltName = $ENV::ALTNAME\n\n[ req ]\ndays = 390\nreq_extensions = v3_req\nx509_extensions = v3_ca\n\n[v3_req]\nbasicConstraints = CA:false\nkeyUsage = critical, nonRepudiation, digitalSignature, keyEncipherment, dataEncipherment\nsubjectAltName = $ENV::ALTNAME\n\n[ v3_ca_signed]\nsubjectKeyIdentifier = hash\nauthorityKeyIdentifier = keyid,issuer\nbasicConstraints = critical, CA:FALSE\nkeyUsage = nonRepudiation, digitalSignature, keyEncipherment, dataEncipherment\nextendedKeyUsage = clientAuth,serverAuth \nnsComment = "certificate generated by Node-OPCUA Certificate utility and signed by a CA"\nsubjectAltName = $ENV::ALTNAME\n[ v3_selfsigned]\nsubjectKeyIdentifier = hash\nauthorityKeyIdentifier = keyid,issuer\nbasicConstraints = critical, CA:FALSE\nkeyUsage = nonRepudiation, digitalSignature, keyEncipherment, dataEncipherment\nextendedKeyUsage = clientAuth,serverAuth \nnsComment = "Self-signed certificate generated by Node-OPCUA Certificate utility"\nsubjectAltName = $ENV::ALTNAME\n[ req_distinguished_name ]\ncountryName = Country Name (2 letter code)\ncountryName_default = FR\ncountryName_min = 2\ncountryName_max = 2\n# stateOrProvinceName = State or Province Name (full name)\n# stateOrProvinceName_default = Ile de France\n# localityName = Locality Name (city, district)\n# localityName_default = Paris\norganizationName = Organization Name (company)\norganizationName_default = NodeOPCUA\n# organizationalUnitName = Organizational Unit Name (department, division)\n# organizationalUnitName_default = R&D\ncommonName = Common Name (hostname, FQDN, IP, or your name)\ncommonName_max = 256\ncommonName_default = NodeOPCUA\n# emailAddress = Email Address\n# emailAddress_max = 40\n# emailAddress_default = node-opcua (at) node-opcua (dot) com\nsubjectAltName = $ENV::ALTNAME'; simple_config_template_cnf_default = config; } }); // packages/node-opcua-pki/lib/pki/certificate_manager.ts import { EventEmitter } from "events"; import fs4 from "fs"; import path2 from "path"; import { drainPendingLocks, withLock } from "@ster5/global-mutex"; import chalk3 from "chalk"; import chokidar from "chokidar"; import { exploreCertificate, exploreCertificateInfo, exploreCertificateRevocationList, generatePrivateKeyFile, makeSHA1Thumbprint, readCertificateChain, readCertificateChainAsync, readCertificateRevocationList, split_der, toPem, verifyCertificateSignature } from "node-opcua-crypto"; function getOrComputeInfo(entry) { if (!entry.info) { entry.info = exploreCertificate(entry.certificate); } return entry.info; } function coerceCertificateChain(certificate) { if (Array.isArray(certificate)) { if (certificate.length === 0) return []; return certificate.reduce((acc, cert) => { return acc.concat(split_der(cert)); }, []); } return split_der(certificate); } function makeFingerprint(certificate) { const chain = coerceCertificateChain(certificate); return makeSHA1Thumbprint(chain[0]).toString("hex"); } function short(stringToShorten) { return stringToShorten.substring(0, 10); } function buildIdealCertificateName(certificate) { const chain = coerceCertificateChain(certificate); const fingerprint2 = makeFingerprint(chain); try { const commonName = exploreCertificate(chain[0]).tbsCertificate.subject.commonName || ""; const sanitizedCommonName = commonName.replace(forbiddenChars, "_"); return `${sanitizedCommonName}[${fingerprint2}]`; } catch (_err) { return `invalid_certificate_[${fingerprint2}]`; } } function findMatchingIssuerKey(entries, wantedIssuerKey) { return entries.filter((entry) => { const info = getOrComputeInfo(entry); return info.tbsCertificate.extensions && info.tbsCertificate.extensions.subjectKeyIdentifier === wantedIssuerKey; }); } function isSelfSigned2(info) { return info.tbsCertificate.extensions?.subjectKeyIdentifier === info.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier; } function isSelfSigned3(certificate) { const info = exploreCertificate(certificate); return isSelfSigned2(info); } function _isIssuerInfo(info) { const basicConstraints = info.tbsCertificate.extensions?.basicConstraints; if (basicConstraints?.cA) { return true; } const keyUsage = info.tbsCertificate.extensions?.keyUsage; if (keyUsage?.keyCertSign) { return true; } return false; } function isIssuer(certificate) { try { const info = exploreCertificate(certificate); return _isIssuerInfo(info); } catch (_err) { return false; } } function findIssuerCertificateInChain(certificate, chain) { const coercedCertificate = coerceCertificateChain(certificate); const firstCertificate = coercedCertificate[0]; if (!firstCertificate) { return null; } const certInfo = exploreCertificate(firstCertificate); if (isSelfSigned2(certInfo)) { return firstCertificate; } const wantedIssuerKey = certInfo.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier; if (!wantedIssuerKey) { debugLog("Certificate has no extension 3"); return null; } const coercedChain = coerceCertificateChain(chain); const potentialIssuers = coercedChain.filter((c) => { const info = exploreCertificate(c); return info.tbsCertificate.extensions && info.tbsCertificate.extensions.subjectKeyIdentifier === wantedIssuerKey; }); if (potentialIssuers.length === 1) { return potentialIssuers[0]; } if (potentialIssuers.length > 1) { debugLog("findIssuerCertificateInChain: certificate is not self-signed but has several issuers"); return potentialIssuers[0]; } return null; } var configurationFileSimpleTemplate, fsWriteFile, forbiddenChars, ChainCompletionStatus, CertificateManager; var init_certificate_manager = __esm({ "packages/node-opcua-pki/lib/pki/certificate_manager.ts"() { "use strict"; init_esm_shims(); init_common2(); init_debug(); init_without_openssl(); init_simple_config_template_cnf(); configurationFileSimpleTemplate = simple_config_template_cnf_default; fsWriteFile = fs4.promises.writeFile; forbiddenChars = /[\x00-\x1F<>:"/\\|?*]/g; ChainCompletionStatus = /* @__PURE__ */ ((ChainCompletionStatus2) => { ChainCompletionStatus2["AlreadyComplete"] = "AlreadyComplete"; ChainCompletionStatus2["ChainCompleted"] = "ChainCompleted"; ChainCompletionStatus2["IssuerNotFound"] = "IssuerNotFound"; ChainCompletionStatus2["EmptyChain"] = "EmptyChain"; ChainCompletionStatus2["MaxDepthReached"] = "MaxDepthReached"; return ChainCompletionStatus2; })(ChainCompletionStatus || {}); CertificateManager = class _CertificateManager extends EventEmitter { // ── Global instance registry ───────────────────────────────── // Tracks all initialized CertificateManager instances so their // file watchers can be closed automatically on process exit, // even if the consumer forgets to call dispose(). static #activeInstances = /* @__PURE__ */ new Set(); static #cleanupInstalled = false; static #installProcessCleanup() { if (_CertificateManager.#cleanupInstalled) return; _CertificateManager.#cleanupInstalled = true; const closeDanglingWatchers = () => { for (const cm of _CertificateManager.#activeInstances) { for (const w of cm.#watchers) { try { w.close(); } catch { } } cm.#watchers.splice(0); cm.state = 4 /* Disposed */; } _CertificateManager.#activeInstances.clear(); }; process.on("beforeExit", closeDanglingWatchers); for (const signal of ["SIGINT", "SIGTERM"]) { process.once(signal, () => { closeDanglingWatchers(); process.exit(); }); } } /** * Dispose **all** active CertificateManager instances, * closing their file watchers and freeing resources. * * This is mainly useful in test tear-down to ensure the * Node.js process can exit cleanly. */ static async disposeAll() { const instances = [..._CertificateManager.#activeInstances]; await Promise.all(instances.map((cm) => _CertificateManager.prototype.dispose.call(cm))); } /** * Assert that all CertificateManager instances have been * properly disposed. Throws an Error listing the locations * of any leaked instances. * * Intended for use in test `afterAll()` / `afterEach()` * hooks to catch missing `dispose()` calls early. * * @example * ```ts * after(() => { * CertificateManager.checkAllDisposed(); * }); * ``` */ static checkAllDisposed() { if (_CertificateManager.#activeInstances.size === 0) return; const locations = [..._CertificateManager.#activeInstances].map((cm) => cm.rootDir); throw new Error( `${_CertificateManager.#activeInstances.size} CertificateManager instance(s) not disposed: - ${locations.join("\n - ")}` ); } // ───────────────────────────────────────────────────────────── /** * When `true` (the default), any certificate that is not * already in the trusted or rejected store is automatically * written to the rejected folder the first time it is seen. */ untrustUnknownCertificate = true; /** Current lifecycle state of this instance. */ state = 0 /* Uninitialized */; /** @deprecated Use {@link folderPollingInterval} instead (typo fix). */ folderPoolingInterval = 5e3; /** Interval in milliseconds for file-system polling (when enabled). */ get folderPollingInterval() { return this.folderPoolingInterval; } set folderPollingInterval(value) { this.folderPoolingInterval = value; } /** RSA key size used when generating the private key. */ keySize; #location; #watchers = []; #pendingUnrefs = /* @__PURE__ */ new Set(); #readCertificatesCalled = false; #filenameToHash = /* @__PURE__ */ new Map(); #initializingPromise; #addCertValidation; #disableFileWatchers; #thumbs = { rejected: /* @__PURE__ */ new Map(), trusted: /* @__PURE__ */ new Map(), issuers: { certs: /* @__PURE__ */ new Map() }, crl: /* @__PURE__ */ new Map(), issuersCrl: /* @__PURE__ */ new Map() }; /** * Create a new CertificateManager. * * The constructor creates the root directory if it does not * exist but does **not** initialise the PKI store — call * {@link initialize} before using any other method. * * @param options - configuration options */ constructor(options) { super(); options.keySize = options.keySize || 2048; if (!options.location) { throw new Error("CertificateManager: missing 'location' option"); } this.#location = makePath(options.location, ""); this.keySize = options.keySize; const v = options.addCertificateValidationOptions ?? {}; this.#addCertValidation = { acceptExpiredCertificate: v.acceptExpiredCertificate ?? false, acceptRevokedCertificate: v.acceptRevokedCertificate ?? false, ignoreMissingRevocationList: v.ignoreMissingRevocationList ?? false, maxChainLength: v.maxChainLength ?? 5 }; this.#disableFileWatchers = options.disableFileWatchers ?? process.env.OPCUA_PKI_DISABLE_FILE_WATCHERS === "true"; mkdirRecursiveSync(options.location); if (!fs4.existsSync(this.#location)) { throw new Error(`CertificateManager cannot access location ${this.#location}`); } } /** Path to the OpenSSL configuration file. */ get configFile() { return path2.join(this.rootDir, "own/openssl.cnf"); } /** Root directory of the PKI store. */ get rootDir() { return this.#location; } /** Path to the private key file (`own/private/private_key.pem`). */ get privateKey() { return path2.join(this.rootDir, "own/private/private_key.pem"); } /** Path to the OpenSSL random seed file. */ get randomFile() { return path2.join(this.rootDir, "./random.rnd"); } /** * Move a certificate to the rejected store. * If the certificate was previously trusted, it will be removed from the trusted folder. * @param certificateOrChain - the DER-encoded certificate or certificate chain */ async rejectCertificate(certificateOrChain) { await this.#moveCertificate(certificateOrChain, "rejected"); } /** * Move a certificate to the trusted store. * If the certificate was previously rejected, it will be removed from the rejected folder. * @param certificateOrChain - the DER-encoded certificate or certificate chain */ async trustCertificate(certificateOrChain) { await this.#moveCertificate(certificateOrChain, "trusted"); } /** * Check whether the trusted certificate store is empty. * * This inspects the in-memory index, which is kept in * sync with the `trusted/certs/` folder by file-system * watchers after {@link initialize} has been called. */ isTrustListEmpty() { return this.#thumbs.trusted.size === 0; } /** * Return the number of certificates currently in the * trusted store. */ getTrustedCertificateCount() { return this.#thumbs.trusted.size; } /** Path to the rejected certificates folder. */ get rejectedFolder() { return path2.join(this.rootDir, "rejected"); } /** Path to the trusted certificates folder. */ get trustedFolder() { return path2.join(this.rootDir, "trusted/certs"); } /** Path to the trusted CRL folder. */ get crlFolder() { return path2.join(this.rootDir, "trusted/crl"); } /** Path to the issuer (CA) certificates folder. */ get issuersCertFolder() { return path2.join(this.rootDir, "issuers/certs"); } /** Path to the issuer CRL folder. */ get issuersCrlFolder() { return path2.join(this.rootDir, "issuers/crl"); } /** Path to the own certificate folder. */ get ownCertFolder() { return path2.join(this.rootDir, "own/certs"); } get ownPrivateFolder() { return path2.join(this.rootDir, "own/private"); } /** * Check if a certificate is in the trusted store. * If the certificate is unknown and `untrustUnknownCertificate` is set, * it will be written to the rejected folder. * @param certificate - the DER-encoded certificate * @returns `"Good"` if trusted, `"BadCertificateUntrusted"` if rejected/unknown, * or `"BadCertificateInvalid"` if the certificate cannot be parsed. */ async isCertificateTrusted(certificateOrCertificateChain) { try { const chain = coerceCertificateChain(certificateOrCertificateChain); const leafCertificate = chain[0]; if (chain.length < 1) { return "BadCertificateInvalid"; } let fingerprint2; try { fingerprint2 = makeFingerprint(chain[0]); } catch (_err) { return "BadCertificateInvalid"; } if (this.#thumbs.trusted.has(fingerprint2)) { return "Good"; } if (!this.#thumbs.rejected.has(fingerprint2)) { if (!this.untrustUnknownCertificate) { return "Good"; } try { exploreCertificateInfo(chain[0]); } catch (_err) { return "BadCertificateInvalid"; } const filename = path2.join(this.rejectedFolder, `${buildIdealCertificateName(leafCertificate)}.pem`); debugLog("certificate has never been seen before and is now rejected (untrusted) ", filename); await fsWriteFile(filename, toPem(chain, "CERTIFICATE")); this.#thumbs.rejected.set(fingerprint2, { certificate: leafCertificate, filename }); } return "BadCertificateUntrusted"; } catch (_err) { return "BadCertificateInvalid"; } } async #innerVerifyCertificateAsync(certificateOrChain, _isIssuer, level, options) { if (level >= 5) { return "BadSecurityChecksFailed" /* BadSecurityChecksFailed */; } const chain = coerceCertificateChain(certificateOrChain); debugLog("NB CERTIFICATE IN CHAIN = ", chain.length); const info = exploreCertificate(chain[0]); let hasValidIssuer = false; let hasTrustedIssuer = false; const hasIssuerKey = info.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier; debugLog("Certificate as an Issuer Key", hasIssuerKey); if (hasIssuerKey) { const isSelfSigned = isSelfSigned2(info); debugLog("Is the Certificate self-signed ?", isSelfSigned); if (!isSelfSigned) { debugLog( "Is issuer found in the list of know issuers ?", "\n subjectKeyIdentifier = ", info.tbsCertificate.extensions?.subjectKeyIdentifier, "\n authorityKeyIdentifier = ", info.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier ); let issuerCertificate = await this.findIssuerCertificate(chain[0]); if (!issuerCertificate) { issuerCertificate = findIssuerCertificateInChain(chain[0], chain); if (!issuerCertificate) { debugLog( " the issuer has not been found in the chain itself nor in the issuer.cert list => the chain is incomplete!" ); return "BadCertificateChainIncomplete" /* BadCertificateChainIncomplete */; } debugLog(" the issuer certificate has been found in the chain itself ! the chain is complete !"); } else { debugLog(" the issuer certificate has been found in the issuer.cert folder !"); } const issuerStatus = await this.#innerVerifyCertificateAsync(issuerCertificate, true, level + 1, options); if (issuerStatus === "BadCertificateRevocationUnknown" /* BadCertificateRevocationUnknown */) { return "BadCertificateIssuerRevocationUnknown" /* BadCertificateIssuerRevocationUnknown */; } if (issuerStatus === "BadCertificateIssuerRevocationUnknown" /* BadCertificateIssuerRevocationUnknown */) { return "BadCertificateIssuerRevocationUnknown" /* BadCertificateIssuerRevocationUnknown */; } if (issuerStatus === "BadCertificateTimeInvalid" /* BadCertificateTimeInvalid */) { if (!options?.acceptOutDatedIssuerCertificate) { return "BadCertificateIssuerTimeInvalid" /* BadCertificateIssuerTimeInvalid */; } } if (issuerStatus === "BadCertificateUntrusted" /* BadCertificateUntrusted */) { debugLog("warning issuerStatus = ", issuerStatus.toString(), "the issuer certificate is not trusted"); } if (issuerStatus !== "Good" /* Good */ && issuerStatus !== "BadCertificateUntrusted" /* BadCertificateUntrusted */) { return "BadSecurityChecksFailed" /* BadSecurityChecksFailed */; } const isCertificateSignatureOK = verifyCertificateSignature(chain[0], issuerCertificate); if (!isCertificateSignatureOK) { debugLog(" the certificate was not signed by the issuer as it claim to be ! Danger"); return "BadSecurityChecksFailed" /* BadSecurityChecksFailed */; } hasValidIssuer = true; let revokedStatus = await this.isCertificateRevoked(chain, issuerCertificate); if (revokedStatus === "BadCertificateRevocationUnknown" /* BadCertificateRevocationUnknown */) { if (options?.ignoreMissingRevocationList) { revokedStatus = "Good" /* Good */; } } if (revokedStatus !== "Good" /* Good */) { debugLog("revokedStatus", revokedStatus); return revokedStatus; } const issuerTrustedStatus = await this.#checkRejectedOrTrusted(issuerCertificate); debugLog("issuerTrustedStatus", issuerTrustedStatus); if (issuerTrustedStatus === "unknown") { hasTrustedIssuer = false; } else if (issuerTrustedStatus === "trusted") { hasTrustedIssuer = true; } else if (issuerTrustedStatus === "rejected") { return "BadSecurityChecksFailed" /* BadSecurityChecksFailed */; } } else { const isCertificateSignatureOK = verifyCertificateSignature(chain[0], chain[0]); if (!isCertificateSignatureOK) { debugLog("Self-signed Certificate signature is not valid"); return "BadSecurityChecksFailed" /* BadSecurityChecksFailed */; } const revokedStatus = await this.isCertificateRevoked(chain); debugLog("revokedStatus of self signed certificate:", revokedStatus); } } const status = await this.#checkRejectedOrTrusted(chain[0]); if (status === "rejected") { if (!(options.acceptCertificateWithValidIssuerChain && hasValidIssuer && hasTrustedIssuer)) { return "BadCertificateUntrusted" /* BadCertificateUntrusted */; } } const _c2 = chain[1] ? exploreCertificateInfo(chain[1]) : "non"; debugLog("chain[1] info=", _c2); const certificateInfo = exploreCertificateInfo(chain[0]); const now = /* @__PURE__ */ new Date(); let isTimeInvalid = false; if (certificateInfo.notBefore.getTime() > now.getTime()) { debugLog( `${chalk3.red("certificate is invalid : certificate is not active yet !")} not before date =${certificateInfo.notBefore}` ); if (!options.acceptPendingCertificate) { isTimeInvalid = true; } } if (certificateInfo.notAfter.getTime() <= now.getTime()) { debugLog( `${chalk3.red("certificate is invalid : certificate has expired !")} not after date =${certificateInfo.notAfter}` ); if (!options.acceptOutdatedCertificate) { isTimeInvalid = true; } } if (status === "trusted") { return isTimeInvalid ? "BadCertificateTimeInvalid" /* BadCertificateTimeInvalid */ : "Good" /* Good */; } if (hasIssuerKey) { if (!hasTrustedIssuer) { return "BadCertificateUntrusted" /* BadCertificateUntrusted */; } if (!hasValidIssuer) { return "BadCertificateUntrusted" /* BadCertificateUntrusted */; } if (!options.acceptCertificateWithValidIssuerChain) { return "BadCertificateUntrusted" /* BadCertificateUntrusted */; } return isTimeInvalid ? "BadCertificateTimeInvalid" /* BadCertificateTimeInvalid */ : "Good" /* Good */; } else { return "BadCertificateUntrusted" /* BadCertificateUntrusted */; } } /** * Internal verification hook called by {@link verifyCertificate}. * * Subclasses can override this to inject additional validation * logic (e.g. application-level policy checks) while still * delegating to the default chain/CRL/trust verification. * * @param certificate - the DER-encoded certificate to verify * @param options - verification options forwarded from the * public API * @returns the verification status code */ async verifyCertificateAsync(certificate, options) { const chain = coerceCertificateChain(certificate); for (const element of chain) { try { exploreCertificateInfo(element); } catch (_err) { return "BadCertificateInvalid" /* BadCertificateInvalid */; } } const status1 = await this.#innerVerifyCertificateAsync(chain, false, 0, options); return status1; } /** * Verify a certificate against the PKI trust store. * * This performs a full validation including trust status, * issuer chain, CRL revocation checks, and time validity. * * @param certificate - the DER-encoded certificate to verify * @param options - optional flags to relax validation rules * @returns the verification status code */ async verifyCertificate(certificate, options) { if (!certificate) { return "BadSecurityChecksFailed" /* BadSecurityChecksFailed */; } try { const status = await this.verifyCertificateAsync(certificate, options || {}); return status; } catch (error) { warningLog(`verifyCertificate error: ${error.message}`); return "BadCertificateInvalid" /* BadCertificateInvalid */; } } /** * Initialize the PKI directory structure, generate the * private key (if missing), and start file-system watchers. * * This method is idempotent — subsequent calls are no-ops. * It must be called before any certificate operations. */ async initialize() { if (this.state !== 0 /* Uninitialized */) { return; } this.state = 1 /* Initializing */; this.#initializingPromise = this.#initialize(); await this.#initializingPromise; this.#initializingPromise = void 0; this.state = 2 /* Initialized */; _CertificateManager.#activeInstances.add(this); _CertificateManager.#installProcessCleanup(); } async #initialize() { this.state = 1 /* Initializing */; const pkiDir = this.#location; mkdirRecursiveSync(pkiDir); mkdirRecursiveSync(path2.join(pkiDir, "own")); mkdirRecursiveSync(path2.join(pkiDir, "own/certs")); mkdirRecursiveSync(path2.join(pkiDir, "own/private")); mkdirRecursiveSync(path2.join(pkiDir, "rejected")); mkdirRecursiveSync(path2.join(pkiDir, "trusted")); mkdirRecursiveSync(path2.join(pkiDir, "trusted/certs")); mkdirRecursiveSync(path2.join(pkiDir, "trusted/crl")); mkdirRecursiveSync(path2.join(pkiDir, "issuers")); mkdirRecursiveSync(path2.join(pkiDir, "issuers/certs")); mkdirRecursiveSync(path2.join(pkiDir, "issuers/crl")); if (!fs4.existsSync(this.configFile) || !fs4.existsSync(this.privateKey)) { return await this.withLock2(async () => { if (this.state === 3 /* Disposing */ || this.state === 4 /* Disposed */) { return; } if (!fs4.existsSync(this.configFile)) { fs4.writeFileSync(this.configFile, configurationFileSimpleTemplate); } if (!fs4.existsSync(this.privateKey)) { debugLog("generating private key ..."); await generatePrivateKeyFile(this.privateKey, this.keySize); await this.#readCertificates(); } else { await this.#readCertificates(); } }); } else { await this.#readCertificates(); } } /** * Dispose of the CertificateManager, releasing file watchers * and other resources. The instance should not be used after * calling this method. */ async dispose() { if (this.state === 3 /* Disposing */) { throw new Error("Already disposing"); } if (this.state === 0 /* Uninitialized */) { this.state = 4 /* Disposed */; return; } if (this.state === 1 /* Initializing */) { if (this.#initializingPromise) { await this.#initializingPromise; } } try { this.state = 3 /* Disposing */; await drainPendingLocks(); for (const unreff of this.#pendingUnrefs) { unreff(); } this.#pendingUnrefs.clear(); await Promise.all(this.#watchers.map((w) => w.close())); this.#watchers.forEach((w) => { w.removeAllListeners(); }); this.#watchers.splice(0); } finally { this.state = 4 /* Disposed */; _CertificateManager.#activeInstances.delete(this); } } /** * Force a full re-scan of all PKI folders, rebuilding * the in-memory `_thumbs` index from scratch. * * Call this after external processes have modified the * PKI folders (e.g. via `writeTrustList` or CLI tools) * to ensure the CertificateManager sees the latest * state without waiting for file-system events. */ async reloadCertificates() { await Promise.all(this.#watchers.map((w) => w.close())); for (const w of this.#watchers) { w.removeAllListeners(); } this.#watchers.splice(0); this.#thumbs.rejected.clear(); this.#thumbs.trusted.clear(); this.#thumbs.issuers.certs.clear(); this.#thumbs.crl.clear(); this.#thumbs.issuersCrl.clear(); this.#filenameToHash.clear(); this.#readCertificatesCalled = false; await this.#readCertificates(); } async withLock2(action) { const lockFileName = path2.join(this.rootDir, "mutex"); return withLock({ fileToLock: lockFileName }, async () => { return await action(); }); } /** * Create a self-signed certificate for this PKI's private key. * * The certificate is written to `params.outputFile` or * `own/certs/self_signed_certificate.pem` by default. * * @param params - certificate parameters (subject, SANs, * validity, etc.) */ async createSelfSignedCertificate(params) { if (typeof params.applicationUri !== "string") { throw new Error("createSelfSignedCertificate: expecting applicationUri to be a string"); } if (!fs4.existsSync(this.privateKey)) { throw new Error(`Cannot find private key ${this.privateKey}`); } let certificateFilename = path2.join(this.rootDir, "own/certs/self_signed_certificate.pem"); certificateFilename = params.outputFile || certificateFilename; const _params = params; _params.rootDir = this.rootDir; _params.configFile = this.configFile; _params.privateKey = this.privateKey; _params.subject = params.subject || "CN=FIXME"; await this.withLock2(async () => { await createSelfSignedCertificate(certificateFilename, _params); }); } /** * Create a Certificate Signing Request (CSR) using this * PKI's private key and configuration. * * The CSR file is written to `own/certs/` with a timestamped * filename. * * @param params - CSR parameters (subject, SANs) * @returns the filesystem path to the generated CSR file */ async createCertificateRequest(params) { if (!params) { throw new Error("params is required"); } const _params = params; if (Object.prototype.hasOwnProperty.call(_params, "rootDir")) { throw new Error("rootDir should not be specified "); } _params.rootDir = path2.resolve(this.rootDir); _params.configFile = path2.resolve(this.configFile); _params.privateKey = path2.resolve(this.privateKey); return await this.withLock2(async () => { const now = /* @__PURE__ */ new Date(); const today2 = `${now.toISOString().slice(0, 10)}_${now.getTime()}`; const certificateSigningRequestFilename = path2.join(this.rootDir, "own/certs", `certificate_${today2}.csr`); await createCertificateSigningRequestAsync(certificateSigningRequestFilename, _params); return certificateSigningRequestFilename; }); } /** * Add a CA (issuer) certificate to the issuers store. * If the certificate is already present, this is a no-op. * @param certificate - the DER-encoded CA certificate * @param validate - if `true`, verify the certificate before adding * @param addInTrustList - if `true`, also add to the trusted store * @returns `VerificationStatus.Good` on success */ async addIssuer(certificate, validate = false, addInTrustList = false) { if (validate) { const status = await this.verifyCertificate(certificate); if (status !== "Good" /* Good */ && status !== "BadCertificateUntrusted" /* BadCertificateUntrusted */) { return status; } } const pemCertificate = toPem(certificate, "CERTIFICATE"); const fingerprint2 = makeFingerprint(certificate); if (this.#thumbs.issuers.certs.has(fingerprint2)) { return "Good" /* Good */; } const filename = path2.join(this.issuersCertFolder, `issuer_${buildIdealCertificateName(certificate)}.pem`); await fs4.promises.writeFile(filename, pemCertificate, "ascii"); this.#thumbs.issuers.certs.set(fingerprint2, { certificate, filename }); if (addInTrustList) { await this.trustCertificate(certificate); } return "Good" /* Good */; } /** * Add multiple CA (issuer) certificates to the issuers store. * @param certificates - the DER-encoded CA certificates * @param validate - if `true`, verify each certificate before adding * @param addInTrustList - if `true`, also add each certificate to the trusted store * @returns `VerificationStatus.Good` on success */ async addIssuers(certificates, validate = false, addInTrustList = false) { for (const certificate of certificates) { if (!isIssuer(certificate)) { warningLog(`Certificate ${makeFingerprint(certificate)} is not a issuer certificate`); continue; } await this.addIssuer(certificate, validate, addInTrustList); } return "Good" /* Good */; } /** * Add a CRL to the certificate manager. * @param crl - the CRL to add * @param target - "issuers" (default) writes to issuers/crl, "trusted" writes to trusted/crl */ async addRevocationList(crl, target = "issuers") { return await this.withLock2(async () => { try { const index = target === "trusted" ? this.#thumbs.crl : this.#thumbs.issuersCrl; const folder = target === "trusted" ? this.crlFolder : this.issuersCrlFolder; const crlInfo = exploreCertificateRevocationList(crl); const key = crlInfo.tbsCertList.issuerFingerprint; if (!index.has(key)) { index.set(key, { crls: [], serialNumbers: {} }); } const pemCertificate = toPem(crl, "X509 CRL"); const sanitizedKey = key.replace(/:/g, ""); const filename = path2.join(folder, `crl_[${sanitizedKey}].pem`); await fs4.promises.writeFile(filename, pemCertificate, "ascii"); await this.#onCrlFileAdded(index, filename); await this.#waitAndCheckCRLProcessingStatus(); return "Good" /* Good */; } catch (err) { debugLog(err); return "BadSecurityChecksFailed" /* BadSecurityChecksFailed */; } }); } /** * Remove all CRL files from the specified folder(s) and clear the * corresponding in-memory index. * @param target - "issuers" clears issuers/crl, "trusted" clears * trusted/crl, "all" clears both. */ async clearRevocationLists(target) { const clearFolder = async (folder, index) => { try { const files = await fs4.promises.readdir(folder); for (const file of files) { const ext = path2.extname(file).toLowerCase(); if (ext === ".crl" || ext === ".pem" || ext === ".der") { await fs4.promises.unlink(path2.join(folder, file)); } } } catch (err) { if (err.code !== "ENOENT") { throw err; } } index.clear(); }; if (target === "issuers" || target === "all") { await clearFolder(this.issuersCrlFolder, this.#thumbs.issuersCrl); } if (target === "trusted" || target === "all") { await clearFolder(this.crlFolder, this.#thumbs.crl); } }