node-opcua-pki
Version:
PKI management for node-opcua
1,193 lines (1,180 loc) • 192 kB
JavaScript
#!/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);
}
}