node-opcua-pki 6.22.1 → 7.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/bin/install_prerequisite.ts +1 -1
  2. package/bin/pki.ts +1 -1
  3. package/dist/bin/install_prerequisite.d.ts +2 -0
  4. package/dist/bin/install_prerequisite.js +6 -0
  5. package/dist/bin/install_prerequisite.js.map +1 -0
  6. package/dist/bin/pki.d.ts +2 -0
  7. package/dist/bin/pki.js +4 -0
  8. package/dist/bin/pki.js.map +1 -0
  9. package/dist/lib/ca/backends/native_ca_backend.d.ts +49 -0
  10. package/dist/lib/ca/backends/native_ca_backend.js +365 -0
  11. package/dist/lib/ca/backends/native_ca_backend.js.map +1 -0
  12. package/dist/lib/ca/backends/openssl_ca_backend.d.ts +47 -0
  13. package/dist/lib/ca/backends/openssl_ca_backend.js +398 -0
  14. package/dist/lib/ca/backends/openssl_ca_backend.js.map +1 -0
  15. package/dist/lib/ca/certificate_authority.d.ts +62 -0
  16. package/dist/lib/ca/certificate_authority.js +91 -0
  17. package/dist/lib/ca/certificate_authority.js.map +1 -0
  18. package/dist/lib/ca/core/ca_backend.d.ts +89 -0
  19. package/dist/lib/ca/core/ca_backend.js +24 -0
  20. package/dist/lib/ca/core/ca_backend.js.map +1 -0
  21. package/dist/lib/ca/core/ca_database.d.ts +91 -0
  22. package/dist/lib/ca/core/ca_database.js +267 -0
  23. package/dist/lib/ca/core/ca_database.js.map +1 -0
  24. package/dist/{core-BwqSJPHc.d.mts → lib/ca/core/certificate_authority_core.d.ts} +45 -394
  25. package/dist/lib/ca/core/certificate_authority_core.js +1300 -0
  26. package/dist/lib/ca/core/certificate_authority_core.js.map +1 -0
  27. package/dist/lib/ca/core/index.d.ts +17 -0
  28. package/dist/lib/ca/core/index.js +25 -0
  29. package/dist/lib/ca/core/index.js.map +1 -0
  30. package/dist/lib/ca/crypto_create_CA.d.ts +1 -0
  31. package/dist/lib/ca/crypto_create_CA.js +821 -0
  32. package/dist/lib/ca/crypto_create_CA.js.map +1 -0
  33. package/dist/lib/ca/index.d.ts +1 -0
  34. package/dist/lib/ca/index.js +2 -0
  35. package/dist/lib/ca/index.js.map +1 -0
  36. package/dist/lib/ca/templates/ca_config_template.cnf.d.ts +2 -0
  37. package/dist/lib/ca/templates/ca_config_template.cnf.js +170 -0
  38. package/dist/lib/ca/templates/ca_config_template.cnf.js.map +1 -0
  39. package/dist/lib/core.d.ts +33 -0
  40. package/dist/lib/core.js +56 -0
  41. package/dist/lib/core.js.map +1 -0
  42. package/dist/lib/index.d.ts +11 -0
  43. package/dist/lib/index.js +37 -0
  44. package/dist/lib/index.js.map +1 -0
  45. package/dist/lib/misc/applicationurn.d.ts +1 -0
  46. package/dist/lib/misc/applicationurn.js +40 -0
  47. package/dist/lib/misc/applicationurn.js.map +1 -0
  48. package/dist/lib/misc/hostname.d.ts +8 -0
  49. package/dist/lib/misc/hostname.js +82 -0
  50. package/dist/lib/misc/hostname.js.map +1 -0
  51. package/dist/lib/misc/subject.d.ts +2 -0
  52. package/dist/lib/misc/subject.js +2 -0
  53. package/dist/lib/misc/subject.js.map +1 -0
  54. package/dist/{index.d.ts → lib/pki/certificate_manager.d.ts} +24 -248
  55. package/dist/lib/pki/certificate_manager.js +2302 -0
  56. package/dist/lib/pki/certificate_manager.js.map +1 -0
  57. package/dist/lib/pki/templates/simple_config_template.cnf.d.ts +2 -0
  58. package/dist/lib/pki/templates/simple_config_template.cnf.js +74 -0
  59. package/dist/lib/pki/templates/simple_config_template.cnf.js.map +1 -0
  60. package/dist/lib/pki/toolbox_pfx.d.ts +127 -0
  61. package/dist/lib/pki/toolbox_pfx.js +248 -0
  62. package/dist/lib/pki/toolbox_pfx.js.map +1 -0
  63. package/dist/lib/toolbox/common.d.ts +150 -0
  64. package/dist/lib/toolbox/common.js +82 -0
  65. package/dist/lib/toolbox/common.js.map +1 -0
  66. package/dist/lib/toolbox/common2.d.ts +25 -0
  67. package/dist/lib/toolbox/common2.js +97 -0
  68. package/dist/lib/toolbox/common2.js.map +1 -0
  69. package/dist/lib/toolbox/config.d.ts +5 -0
  70. package/dist/lib/toolbox/config.js +28 -0
  71. package/dist/lib/toolbox/config.js.map +1 -0
  72. package/dist/lib/toolbox/debug.d.ts +5 -0
  73. package/dist/lib/toolbox/debug.js +35 -0
  74. package/dist/lib/toolbox/debug.js.map +1 -0
  75. package/dist/lib/toolbox/display.d.ts +4 -0
  76. package/dist/lib/toolbox/display.js +56 -0
  77. package/dist/lib/toolbox/display.js.map +1 -0
  78. package/dist/lib/toolbox/index.d.ts +5 -0
  79. package/dist/lib/toolbox/index.js +28 -0
  80. package/dist/lib/toolbox/index.js.map +1 -0
  81. package/dist/lib/toolbox/with_openssl/_create_random_file.d.ts +4 -0
  82. package/dist/lib/toolbox/with_openssl/_create_random_file.js +50 -0
  83. package/dist/lib/toolbox/with_openssl/_create_random_file.js.map +1 -0
  84. package/dist/lib/toolbox/with_openssl/_env.d.ts +58 -0
  85. package/dist/lib/toolbox/with_openssl/_env.js +126 -0
  86. package/dist/lib/toolbox/with_openssl/_env.js.map +1 -0
  87. package/dist/lib/toolbox/with_openssl/create_certificate_signing_request.d.ts +5 -0
  88. package/dist/lib/toolbox/with_openssl/create_certificate_signing_request.js +72 -0
  89. package/dist/lib/toolbox/with_openssl/create_certificate_signing_request.js.map +1 -0
  90. package/dist/lib/toolbox/with_openssl/create_private_key.d.ts +5 -0
  91. package/dist/lib/toolbox/with_openssl/create_private_key.js +92 -0
  92. package/dist/lib/toolbox/with_openssl/create_private_key.js.map +1 -0
  93. package/dist/lib/toolbox/with_openssl/create_self_signed_certificate.d.ts +5 -0
  94. package/dist/lib/toolbox/with_openssl/create_self_signed_certificate.js +135 -0
  95. package/dist/lib/toolbox/with_openssl/create_self_signed_certificate.js.map +1 -0
  96. package/dist/lib/toolbox/with_openssl/execute_openssl.d.ts +67 -0
  97. package/dist/lib/toolbox/with_openssl/execute_openssl.js +246 -0
  98. package/dist/lib/toolbox/with_openssl/execute_openssl.js.map +1 -0
  99. package/dist/lib/toolbox/with_openssl/index.d.ts +5 -0
  100. package/dist/lib/toolbox/with_openssl/index.js +30 -0
  101. package/dist/lib/toolbox/with_openssl/index.js.map +1 -0
  102. package/dist/lib/toolbox/with_openssl/install_prerequisite.d.ts +7 -0
  103. package/dist/lib/toolbox/with_openssl/install_prerequisite.js +358 -0
  104. package/dist/lib/toolbox/with_openssl/install_prerequisite.js.map +1 -0
  105. package/dist/lib/toolbox/with_openssl/toolbox.d.ts +53 -0
  106. package/dist/lib/toolbox/with_openssl/toolbox.js +190 -0
  107. package/dist/lib/toolbox/with_openssl/toolbox.js.map +1 -0
  108. package/dist/lib/toolbox/without_openssl/create_certificate_signing_request.d.ts +5 -0
  109. package/dist/lib/toolbox/without_openssl/create_certificate_signing_request.js +69 -0
  110. package/dist/lib/toolbox/without_openssl/create_certificate_signing_request.js.map +1 -0
  111. package/dist/lib/toolbox/without_openssl/create_self_signed_certificate.d.ts +3 -0
  112. package/dist/lib/toolbox/without_openssl/create_self_signed_certificate.js +78 -0
  113. package/dist/lib/toolbox/without_openssl/create_self_signed_certificate.js.map +1 -0
  114. package/dist/lib/toolbox/without_openssl/index.d.ts +2 -0
  115. package/dist/lib/toolbox/without_openssl/index.js +25 -0
  116. package/dist/lib/toolbox/without_openssl/index.js.map +1 -0
  117. package/package.json +11 -30
  118. package/dist/bin/install_prerequisite.mjs +0 -18
  119. package/dist/bin/install_prerequisite.mjs.map +0 -1
  120. package/dist/bin/pki.mjs +0 -5932
  121. package/dist/bin/pki.mjs.map +0 -1
  122. package/dist/chunk-EQ4DGI4M.mjs +0 -1917
  123. package/dist/chunk-EQ4DGI4M.mjs.map +0 -1
  124. package/dist/chunk-GCHH54PS.mjs +0 -30
  125. package/dist/chunk-GCHH54PS.mjs.map +0 -1
  126. package/dist/core-BwqSJPHc.d.ts +0 -1041
  127. package/dist/core.d.mts +0 -2
  128. package/dist/core.d.ts +0 -2
  129. package/dist/core.js +0 -1902
  130. package/dist/core.js.map +0 -1
  131. package/dist/core.mjs +0 -21
  132. package/dist/core.mjs.map +0 -1
  133. package/dist/index.d.mts +0 -1003
  134. package/dist/index.js +0 -5009
  135. package/dist/index.js.map +0 -1
  136. package/dist/index.mjs +0 -3145
  137. package/dist/index.mjs.map +0 -1
@@ -0,0 +1,2302 @@
1
+ // ---------------------------------------------------------------------------------------------------------------------
2
+ // node-opcua-pki — CertificateManager
3
+ // ---------------------------------------------------------------------------------------------------------------------
4
+ // Copyright (c) 2014-2026 - Etienne Rossignon - etienne.rossignon (at) gadz.org
5
+ // Copyright (c) 2022-2026 - Sterfive.com
6
+ // ---------------------------------------------------------------------------------------------------------------------
7
+ // This project is licensed under the terms of the MIT license.
8
+ // ---------------------------------------------------------------------------------------------------------------------
9
+ import { EventEmitter } from "node:events";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+ import { drainPendingLocks, withLock } from "@ster5/global-mutex";
13
+ import chalk from "chalk";
14
+ import chokidar from "chokidar";
15
+ import { caSignerFromKeyOperations, exploreCertificate, exploreCertificateInfo, exploreCertificateRevocationList, generatePrivateKeyFile, keyOperationsFromPrivateKey, makeSHA1Thumbprint, PrivateKeyUnavailableError, readCertificateChain, readCertificateChainAsync, readCertificateRevocationList, readPrivateKey, split_der, toPem, verifyCertificateSignature, writePrivateKeyFile } from "node-opcua-crypto";
16
+ import { resolvePrivateKeyPassphrase } from "../toolbox/common.js";
17
+ import { ensurePrivateDirectory, isEncryptedPrivateKeyFile, makePath, mkdirRecursiveSync, restrictPrivateFilePermissions } from "../toolbox/common2.js";
18
+ import { debugLog, warningLog } from "../toolbox/debug.js";
19
+ import { createCertificateSigningRequestAsync, createSelfSignedCertificate } from "../toolbox/without_openssl/index.js";
20
+ import _simple_config_template from "./templates/simple_config_template.cnf.js";
21
+ /**
22
+ *
23
+ * a minimalist config file for openssl that allows
24
+ * self-signed certificate to be generated.
25
+ *
26
+ */
27
+ const configurationFileSimpleTemplate = _simple_config_template;
28
+ const fsWriteFile = fs.promises.writeFile;
29
+ /** Return the cached `info` or compute and cache it. */
30
+ function getOrComputeInfo(entry) {
31
+ if (!entry.info) {
32
+ entry.info = exploreCertificate(entry.certificate);
33
+ }
34
+ return entry.info;
35
+ }
36
+ /**
37
+ * OPC UA certificate verification status codes.
38
+ *
39
+ * These mirror the OPC UA `StatusCode` values for certificate
40
+ * validation results.
41
+ */
42
+ export var VerificationStatus;
43
+ (function (VerificationStatus) {
44
+ /** The certificate provided as a parameter is not valid. */
45
+ VerificationStatus["BadCertificateInvalid"] = "BadCertificateInvalid";
46
+ /** An error occurred verifying security. */
47
+ VerificationStatus["BadSecurityChecksFailed"] = "BadSecurityChecksFailed";
48
+ /** The certificate does not meet the requirements of the security policy. */
49
+ VerificationStatus["BadCertificatePolicyCheckFailed"] = "BadCertificatePolicyCheckFailed";
50
+ /** The certificate has expired or is not yet valid. */
51
+ VerificationStatus["BadCertificateTimeInvalid"] = "BadCertificateTimeInvalid";
52
+ /** An issuer certificate has expired or is not yet valid. */
53
+ VerificationStatus["BadCertificateIssuerTimeInvalid"] = "BadCertificateIssuerTimeInvalid";
54
+ /** The HostName used to connect to a server does not match a HostName in the certificate. */
55
+ VerificationStatus["BadCertificateHostNameInvalid"] = "BadCertificateHostNameInvalid";
56
+ /** The URI specified in the ApplicationDescription does not match the URI in the certificate. */
57
+ VerificationStatus["BadCertificateUriInvalid"] = "BadCertificateUriInvalid";
58
+ /** The certificate may not be used for the requested operation. */
59
+ VerificationStatus["BadCertificateUseNotAllowed"] = "BadCertificateUseNotAllowed";
60
+ /** The issuer certificate may not be used for the requested operation. */
61
+ VerificationStatus["BadCertificateIssuerUseNotAllowed"] = "BadCertificateIssuerUseNotAllowed";
62
+ /** The certificate is not trusted. */
63
+ VerificationStatus["BadCertificateUntrusted"] = "BadCertificateUntrusted";
64
+ /** It was not possible to determine if the certificate has been revoked. */
65
+ VerificationStatus["BadCertificateRevocationUnknown"] = "BadCertificateRevocationUnknown";
66
+ /** It was not possible to determine if the issuer certificate has been revoked. */
67
+ VerificationStatus["BadCertificateIssuerRevocationUnknown"] = "BadCertificateIssuerRevocationUnknown";
68
+ /** The certificate has been revoked. */
69
+ VerificationStatus["BadCertificateRevoked"] = "BadCertificateRevoked";
70
+ /** The issuer certificate has been revoked. */
71
+ VerificationStatus["BadCertificateIssuerRevoked"] = "BadCertificateIssuerRevoked";
72
+ /** The certificate chain is incomplete. */
73
+ VerificationStatus["BadCertificateChainIncomplete"] = "BadCertificateChainIncomplete";
74
+ /** Validation OK. */
75
+ VerificationStatus["Good"] = "Good";
76
+ })(VerificationStatus || (VerificationStatus = {}));
77
+ export function coerceCertificateChain(certificate) {
78
+ if (Array.isArray(certificate)) {
79
+ if (certificate.length === 0)
80
+ return [];
81
+ return certificate.reduce((acc, cert) => {
82
+ return acc.concat(split_der(cert));
83
+ }, []);
84
+ }
85
+ return split_der(certificate);
86
+ }
87
+ export function makeFingerprint(certificate) {
88
+ // When the buffer contains a certificate chain (multiple
89
+ // concatenated DER structures), the thumbprint must be
90
+ // computed on the leaf certificate only (first element).
91
+ const chain = coerceCertificateChain(certificate);
92
+ return makeSHA1Thumbprint(chain[0]).toString("hex");
93
+ }
94
+ function short(stringToShorten) {
95
+ return stringToShorten.substring(0, 10);
96
+ }
97
+ // Characters we keep in the human-readable part of a stored certificate
98
+ // filename: plain ASCII letters, digits, and a few separators. Everything else
99
+ // is dropped. The Common Name is free text supplied in the certificate, so it
100
+ // is only ever decorative here — the fingerprint in brackets is what actually
101
+ // identifies the file — and keeping the label to a small, known set makes the
102
+ // name portable and predictable across file systems.
103
+ const disallowedInLabel = /[^A-Za-z0-9._ -]/g;
104
+ // Exported for tests; not re-exported from the package index (internal helper).
105
+ export function toFilenameLabel(commonName) {
106
+ // Normalising to NFKC first lets common "wide"/decorative letter forms fold
107
+ // to their plain ASCII equivalent, so a legitimate name stays readable
108
+ // instead of collapsing into underscores. Anything left outside the allowed
109
+ // set becomes "_", any remaining non-ASCII is dropped, and the length is
110
+ // capped since the fingerprint already guarantees uniqueness.
111
+ const label = (commonName || "")
112
+ .normalize("NFKC")
113
+ .replace(disallowedInLabel, "_")
114
+ .replace(/[^\x20-\x7E]/g, "")
115
+ .slice(0, 64)
116
+ .trim();
117
+ return label || "certificate";
118
+ }
119
+ function buildIdealCertificateName(certificate) {
120
+ const chain = coerceCertificateChain(certificate);
121
+ const fingerprint = makeFingerprint(chain);
122
+ try {
123
+ const commonName = exploreCertificate(chain[0]).tbsCertificate.subject.commonName || "";
124
+ const sanitizedCommonName = toFilenameLabel(commonName);
125
+ return `${sanitizedCommonName}[${fingerprint}]`;
126
+ }
127
+ catch (_err) {
128
+ // make be certificate is incorrect !
129
+ return `invalid_certificate_[${fingerprint}]`;
130
+ }
131
+ }
132
+ // Defense-in-depth for the store layout: a certificate filename is always
133
+ // `<label>.pem` with no path separators — `toFilenameLabel` guarantees this. This
134
+ // helper joins a store folder with such a filename and verifies the *resolved*
135
+ // path stays inside that folder, so a future sanitizer regression, a new call
136
+ // site, or an unforeseen encoding can never write a certificate outside its
137
+ // store. It fails closed (throws) rather than silently writing elsewhere.
138
+ // Exported for tests; not re-exported from the package index (internal helper).
139
+ export function safeStoreJoin(folder, filename) {
140
+ const full = path.resolve(folder, filename);
141
+ const base = path.resolve(folder);
142
+ if (full !== base && !full.startsWith(base + path.sep)) {
143
+ warningLog("refusing to write certificate outside its store folder:", filename);
144
+ throw new Error(`certificate filename escapes its store folder: ${filename}`);
145
+ }
146
+ return full;
147
+ }
148
+ function findMatchingIssuerKey(entries, wantedIssuerKey) {
149
+ return entries.filter((entry) => {
150
+ const info = getOrComputeInfo(entry);
151
+ return info.tbsCertificate.extensions && info.tbsCertificate.extensions.subjectKeyIdentifier === wantedIssuerKey;
152
+ });
153
+ }
154
+ function isSelfSigned2(info) {
155
+ return (info.tbsCertificate.extensions?.subjectKeyIdentifier ===
156
+ info.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier);
157
+ }
158
+ function isSelfSigned3(certificate) {
159
+ const info = exploreCertificate(certificate);
160
+ return isSelfSigned2(info);
161
+ }
162
+ function _isIssuerInfo(info) {
163
+ const basicConstraints = info.tbsCertificate.extensions?.basicConstraints;
164
+ if (basicConstraints?.cA) {
165
+ return true;
166
+ }
167
+ const keyUsage = info.tbsCertificate.extensions?.keyUsage;
168
+ if (keyUsage?.keyCertSign) {
169
+ return true;
170
+ }
171
+ return false;
172
+ }
173
+ /**
174
+ * Check if the provided certificate acts as an issuer (CA)
175
+ * @param certificate - the DER-encoded certificate
176
+ * @returns true if the certificate has CA basicConstraints or keyCertSign keyUsage
177
+ */
178
+ export function isIssuer(certificate) {
179
+ try {
180
+ const info = exploreCertificate(certificate);
181
+ return _isIssuerInfo(info);
182
+ }
183
+ catch (_err) {
184
+ return false;
185
+ }
186
+ }
187
+ /**
188
+ * Check if the provided certificate acts as an intermediate issuer.
189
+ * An intermediate issuer is a CA certificate that is not a root CA (not self-signed).
190
+ * @param certificate - the DER-encoded certificate
191
+ * @returns true if the certificate is a CA and is not self-signed
192
+ */
193
+ export function isIntermediateIssuer(certificate) {
194
+ try {
195
+ const info = exploreCertificate(certificate);
196
+ if (!_isIssuerInfo(info)) {
197
+ return false;
198
+ }
199
+ // A root CA is self-signed. If it's not self-signed, it's an intermediate CA.
200
+ return !isSelfSigned2(info);
201
+ }
202
+ catch (_err) {
203
+ return false;
204
+ }
205
+ }
206
+ /**
207
+ * Check if the provided certificate acts as a root issuer.
208
+ * A root issuer is a CA certificate that is self-signed.
209
+ * @param certificate - the DER-encoded certificate
210
+ * @returns true if the certificate is a CA and is self-signed
211
+ */
212
+ export function isRootIssuer(certificate) {
213
+ try {
214
+ const info = exploreCertificate(certificate);
215
+ if (!_isIssuerInfo(info)) {
216
+ return false;
217
+ }
218
+ // A root CA is securely self-signed
219
+ return isSelfSigned2(info);
220
+ }
221
+ catch (_err) {
222
+ return false;
223
+ }
224
+ }
225
+ /**
226
+ * Find the issuer certificate for a given certificate within
227
+ * a provided certificate chain.
228
+ *
229
+ * @param certificate - the DER-encoded certificate whose issuer to find
230
+ * @param chain - candidate issuer certificates to search
231
+ * @returns the matching issuer certificate, or `null` if not found
232
+ */
233
+ export function findIssuerCertificateInChain(certificate, chain) {
234
+ const coercedCertificate = coerceCertificateChain(certificate);
235
+ const firstCertificate = coercedCertificate[0];
236
+ if (!firstCertificate) {
237
+ return null;
238
+ }
239
+ const certInfo = exploreCertificate(firstCertificate);
240
+ // istanbul ignore next
241
+ if (isSelfSigned2(certInfo)) {
242
+ // the certificate is self signed so is it's own issuer.
243
+ return firstCertificate;
244
+ }
245
+ const wantedIssuerKey = certInfo.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier;
246
+ // istanbul ignore next
247
+ if (!wantedIssuerKey) {
248
+ // Certificate has no extension 3 ! the certificate might have been generated by an old system
249
+ debugLog("Certificate has no extension 3");
250
+ return null;
251
+ }
252
+ const coercedChain = coerceCertificateChain(chain);
253
+ const potentialIssuers = coercedChain.filter((c) => {
254
+ const info = exploreCertificate(c);
255
+ return info.tbsCertificate.extensions && info.tbsCertificate.extensions.subjectKeyIdentifier === wantedIssuerKey;
256
+ });
257
+ if (potentialIssuers.length === 1) {
258
+ return potentialIssuers[0];
259
+ }
260
+ if (potentialIssuers.length > 1) {
261
+ debugLog("findIssuerCertificateInChain: certificate is not self-signed but has several issuers");
262
+ return potentialIssuers[0];
263
+ }
264
+ return null;
265
+ }
266
+ /**
267
+ * Lifecycle state of a {@link CertificateManager} instance.
268
+ */
269
+ export var CertificateManagerState;
270
+ (function (CertificateManagerState) {
271
+ CertificateManagerState[CertificateManagerState["Uninitialized"] = 0] = "Uninitialized";
272
+ CertificateManagerState[CertificateManagerState["Initializing"] = 1] = "Initializing";
273
+ CertificateManagerState[CertificateManagerState["Initialized"] = 2] = "Initialized";
274
+ CertificateManagerState[CertificateManagerState["Disposing"] = 3] = "Disposing";
275
+ CertificateManagerState[CertificateManagerState["Disposed"] = 4] = "Disposed";
276
+ })(CertificateManagerState || (CertificateManagerState = {}));
277
+ /**
278
+ * Manages a GDS-compliant PKI directory structure for an OPC UA
279
+ * application.
280
+ *
281
+ * The PKI store layout follows the OPC UA specification:
282
+ *
283
+ * ```
284
+ * <location>/
285
+ * ├── own/
286
+ * │ ├── certs/ Own certificate(s)
287
+ * │ └── private/ Own private key
288
+ * ├── trusted/
289
+ * │ ├── certs/ Trusted peer certificates
290
+ * │ └── crl/ CRLs for trusted certs
291
+ * ├── rejected/ Untrusted / rejected certificates
292
+ * └── issuers/
293
+ * ├── certs/ CA (issuer) certificates
294
+ * └── crl/ CRLs for issuer certificates
295
+ * ```
296
+ *
297
+ * File-system watchers keep the in-memory indexes in sync with
298
+ * on-disk changes. Call {@link dispose} when the instance is no
299
+ * longer needed to release watchers and allow the process to
300
+ * exit cleanly.
301
+ *
302
+ * ## Environment Variables
303
+ *
304
+ * - **`OPCUA_PKI_USE_POLLING`** — set to `"true"` to use
305
+ * polling-based file watching instead of native OS events.
306
+ * Useful for NFS, CIFS, Docker volumes, or other remote /
307
+ * virtual file systems where native events are unreliable.
308
+ *
309
+ * - **`OPCUA_PKI_POLLING_INTERVAL`** — polling interval in
310
+ * milliseconds (only effective when polling is enabled).
311
+ * Clamped to the range [100, 600 000]. Defaults to
312
+ * {@link folderPollingInterval} (5 000 ms).
313
+ *
314
+ * @example
315
+ * ```ts
316
+ * const cm = new CertificateManager({ location: "/var/pki" });
317
+ * await cm.initialize();
318
+ * const status = await cm.verifyCertificate(cert);
319
+ * await cm.dispose();
320
+ * ```
321
+ */
322
+ // ── Chain completion result types ─────────────────────────────
323
+ /**
324
+ * Status codes returned by {@link CertificateManager.completeCertificateChain}.
325
+ */
326
+ export var ChainCompletionStatus;
327
+ (function (ChainCompletionStatus) {
328
+ /** The chain already reached a self-signed root — no action was needed. */
329
+ ChainCompletionStatus["AlreadyComplete"] = "AlreadyComplete";
330
+ /** One or more issuer certificates were successfully appended. */
331
+ ChainCompletionStatus["ChainCompleted"] = "ChainCompleted";
332
+ /** The issuer for the last certificate in the chain could not be found
333
+ * in the issuers or trusted stores. The chain is still partial. */
334
+ ChainCompletionStatus["IssuerNotFound"] = "IssuerNotFound";
335
+ /** The input chain was empty. */
336
+ ChainCompletionStatus["EmptyChain"] = "EmptyChain";
337
+ /** Chain completion was stopped because the maximum depth was reached. */
338
+ ChainCompletionStatus["MaxDepthReached"] = "MaxDepthReached";
339
+ })(ChainCompletionStatus || (ChainCompletionStatus = {}));
340
+ // ── CertificateManager ───────────────────────────────────────
341
+ export class CertificateManager extends EventEmitter {
342
+ // ── Global instance registry ─────────────────────────────────
343
+ // Tracks all initialized CertificateManager instances so their
344
+ // file watchers can be closed automatically on process exit,
345
+ // even if the consumer forgets to call dispose().
346
+ static #activeInstances = new Set();
347
+ static #cleanupInstalled = false;
348
+ static #exitHandler;
349
+ /**
350
+ * Install a best-effort `exit` hook that closes any watcher
351
+ * still open when the process terminates.
352
+ *
353
+ * **This library never terminates the host process.** No
354
+ * SIGINT/SIGTERM handler is installed: deciding how (and
355
+ * whether) to shut down on a signal is the application's
356
+ * responsibility, and a listener registered here would both
357
+ * pre-empt the application's own graceful shutdown and
358
+ * silently suppress Node's default signal behaviour.
359
+ *
360
+ * Nothing here is load-bearing for process exit. The native
361
+ * `fs.watch` handles are `unref()`'d when the watchers are
362
+ * created (see `#readCertificates`), so an undisposed
363
+ * CertificateManager never keeps the event loop alive. This
364
+ * hook is only tidiness on the way out.
365
+ *
366
+ * `exit` rather than `beforeExit`: `beforeExit` fires when
367
+ * the loop merely drains and the loop can subsequently be
368
+ * resurrected, which would leave a still-in-use instance
369
+ * marked Disposed. `exit` is terminal, synchronous-only and
370
+ * cannot alter the exit code.
371
+ */
372
+ static #installExitCleanup() {
373
+ if (CertificateManager.#cleanupInstalled)
374
+ return;
375
+ CertificateManager.#cleanupInstalled = true;
376
+ const closeDanglingWatchers = () => {
377
+ for (const cm of CertificateManager.#activeInstances) {
378
+ for (const w of cm.#watchers) {
379
+ try {
380
+ w.close();
381
+ }
382
+ catch {
383
+ /* best-effort */
384
+ }
385
+ }
386
+ cm.#watchers.splice(0);
387
+ cm.state = CertificateManagerState.Disposed;
388
+ }
389
+ CertificateManager.#activeInstances.clear();
390
+ };
391
+ CertificateManager.#exitHandler = closeDanglingWatchers;
392
+ process.on("exit", closeDanglingWatchers);
393
+ }
394
+ /**
395
+ * Remove the `exit` hook once the last instance is disposed,
396
+ * so a library that is initialized and disposed repeatedly
397
+ * does not accumulate process listeners. A later
398
+ * `initialize()` re-arms it.
399
+ */
400
+ static #uninstallExitCleanupIfIdle() {
401
+ if (CertificateManager.#activeInstances.size > 0)
402
+ return;
403
+ if (CertificateManager.#exitHandler) {
404
+ process.removeListener("exit", CertificateManager.#exitHandler);
405
+ CertificateManager.#exitHandler = undefined;
406
+ }
407
+ CertificateManager.#cleanupInstalled = false;
408
+ }
409
+ /**
410
+ * Dispose **all** active CertificateManager instances,
411
+ * closing their file watchers and freeing resources.
412
+ *
413
+ * This is mainly useful in test tear-down to ensure the
414
+ * Node.js process can exit cleanly.
415
+ */
416
+ static async disposeAll() {
417
+ const instances = [...CertificateManager.#activeInstances];
418
+ await Promise.all(instances.map((cm) => CertificateManager.prototype.dispose.call(cm)));
419
+ }
420
+ /**
421
+ * Assert that all CertificateManager instances have been
422
+ * properly disposed. Throws an Error listing the locations
423
+ * of any leaked instances.
424
+ *
425
+ * Intended for use in test `afterAll()` / `afterEach()`
426
+ * hooks to catch missing `dispose()` calls early.
427
+ *
428
+ * @example
429
+ * ```ts
430
+ * after(() => {
431
+ * CertificateManager.checkAllDisposed();
432
+ * });
433
+ * ```
434
+ */
435
+ static checkAllDisposed() {
436
+ if (CertificateManager.#activeInstances.size === 0)
437
+ return;
438
+ const locations = [...CertificateManager.#activeInstances].map((cm) => cm.rootDir);
439
+ throw new Error(`${CertificateManager.#activeInstances.size} CertificateManager instance(s) not disposed:\n - ${locations.join("\n - ")}`);
440
+ }
441
+ // ─────────────────────────────────────────────────────────────
442
+ /**
443
+ * When `true` (the default), any certificate that is not
444
+ * already in the trusted or rejected store is automatically
445
+ * written to the rejected folder the first time it is seen.
446
+ */
447
+ untrustUnknownCertificate = true;
448
+ /** Current lifecycle state of this instance. */
449
+ state = CertificateManagerState.Uninitialized;
450
+ /** @deprecated Use {@link folderPollingInterval} instead (typo fix). */
451
+ folderPoolingInterval = 5000;
452
+ /** Interval in milliseconds for file-system polling (when enabled). */
453
+ get folderPollingInterval() {
454
+ return this.folderPoolingInterval;
455
+ }
456
+ set folderPollingInterval(value) {
457
+ this.folderPoolingInterval = value;
458
+ }
459
+ /** RSA key size used when generating the private key. */
460
+ keySize;
461
+ #location;
462
+ #watchers = [];
463
+ #pendingUnrefs = new Set();
464
+ #readCertificatesCalled = false;
465
+ #filenameToHash = new Map();
466
+ #initializingPromise;
467
+ #addCertValidation;
468
+ #disableFileWatchers;
469
+ #privateKeyPassphrase;
470
+ #privateKeyProvider;
471
+ #keyOperations;
472
+ /** The stable lazy wrap handed out by {@link getKeyOperations} for non-opaque configurations. */
473
+ #lazyLocalKeyOperations;
474
+ /**
475
+ * The on-disk key, decrypted once and kept for the instance's lifetime,
476
+ * so the passphrase (or its resolver function) is consulted at most
477
+ * once. Cleared by `dispose()`. Not used when a provider is configured:
478
+ * the provider is the authority on the current key.
479
+ */
480
+ #cachedPrivateKey;
481
+ #thumbs = {
482
+ rejected: new Map(),
483
+ trusted: new Map(),
484
+ issuers: {
485
+ certs: new Map()
486
+ },
487
+ crl: new Map(),
488
+ issuersCrl: new Map()
489
+ };
490
+ /**
491
+ * Create a new CertificateManager.
492
+ *
493
+ * The constructor creates the root directory if it does not
494
+ * exist but does **not** initialise the PKI store — call
495
+ * {@link initialize} before using any other method.
496
+ *
497
+ * @param options - configuration options
498
+ */
499
+ constructor(options) {
500
+ super();
501
+ options.keySize = options.keySize || 2048;
502
+ if (!options.location) {
503
+ throw new Error("CertificateManager: missing 'location' option");
504
+ }
505
+ this.#location = makePath(options.location, "");
506
+ this.keySize = options.keySize;
507
+ const v = options.addCertificateValidationOptions ?? {};
508
+ this.#addCertValidation = {
509
+ acceptExpiredCertificate: v.acceptExpiredCertificate ?? false,
510
+ acceptRevokedCertificate: v.acceptRevokedCertificate ?? false,
511
+ ignoreMissingRevocationList: v.ignoreMissingRevocationList ?? false,
512
+ maxChainLength: v.maxChainLength ?? 5
513
+ };
514
+ this.#disableFileWatchers = options.disableFileWatchers ?? process.env.OPCUA_PKI_DISABLE_FILE_WATCHERS === "true";
515
+ this.#privateKeyPassphrase = options.privateKeyPassphrase;
516
+ this.#privateKeyProvider = options.privateKeyProvider;
517
+ this.#keyOperations = options.keyOperations;
518
+ if (this.#keyOperations && this.#privateKeyProvider) {
519
+ throw new Error("CertificateManager: 'keyOperations' and 'privateKeyProvider' are mutually exclusive:" +
520
+ " one hides the key, the other sources its material");
521
+ }
522
+ if (this.#keyOperations && this.#privateKeyPassphrase) {
523
+ throw new Error("CertificateManager: 'privateKeyPassphrase' is meaningless with 'keyOperations':" +
524
+ " there is no key material for a passphrase to protect");
525
+ }
526
+ mkdirRecursiveSync(options.location);
527
+ if (!fs.existsSync(this.#location)) {
528
+ throw new Error(`CertificateManager cannot access location ${this.#location}`);
529
+ }
530
+ }
531
+ /** Path to the OpenSSL configuration file. */
532
+ get configFile() {
533
+ return path.join(this.rootDir, "own/openssl.cnf");
534
+ }
535
+ /** Root directory of the PKI store. */
536
+ get rootDir() {
537
+ return this.#location;
538
+ }
539
+ /**
540
+ * Path to the private key file (`own/private/private_key.pem`).
541
+ *
542
+ * Kept for backward compatibility with code that reads the key
543
+ * directly from disk. When a passphrase or a `privateKeyProvider` is
544
+ * configured, prefer {@link getPrivateKey} instead — this getter still
545
+ * returns the on-disk path even if a provider is configured (there may
546
+ * be no meaningful file in that case).
547
+ */
548
+ get privateKey() {
549
+ return path.join(this.rootDir, "own/private/private_key.pem");
550
+ }
551
+ /**
552
+ * Resolve the private key: from `privateKeyProvider` if configured,
553
+ * otherwise from disk (decrypting with `privateKeyPassphrase` if the
554
+ * key is encrypted). Fails closed — throws
555
+ * `PrivateKeyPassphraseRequiredError` — if the on-disk key is encrypted
556
+ * and no passphrase is configured, or if the wrong passphrase is
557
+ * configured.
558
+ *
559
+ * The on-disk key is read and decrypted once and then cached for the
560
+ * lifetime of this instance, so a `privateKeyPassphrase` function is
561
+ * called at most once (concurrent first calls share the same read). A
562
+ * failed read is not cached, so a caller can fix the passphrase and
563
+ * retry. A `privateKeyProvider` is consulted on every call: it is the
564
+ * authority on what the current key is.
565
+ */
566
+ async getPrivateKey() {
567
+ if (this.#keyOperations) {
568
+ throw new PrivateKeyUnavailableError("CertificateManager.getPrivateKey is not available when keyOperations is configured:" +
569
+ " the private key is held by the key-operations provider (HSM/KMS) and cannot be read" +
570
+ " — use getKeyOperations() instead");
571
+ }
572
+ if (this.#privateKeyProvider) {
573
+ return await this.#privateKeyProvider.getPrivateKey();
574
+ }
575
+ if (this.#cachedPrivateKey) {
576
+ return this.#cachedPrivateKey;
577
+ }
578
+ if (!this.#privateKeyPromise) {
579
+ this.#privateKeyPromise = (async () => {
580
+ const passphrase = await resolvePrivateKeyPassphrase(this.#privateKeyPassphrase);
581
+ return readPrivateKey(this.privateKey, passphrase);
582
+ })().then((key) => {
583
+ this.#cachedPrivateKey = key;
584
+ return key;
585
+ }, (err) => {
586
+ this.#privateKeyPromise = undefined; // not cached: allow a retry
587
+ throw err;
588
+ });
589
+ }
590
+ return await this.#privateKeyPromise;
591
+ }
592
+ /** In-flight first read of the on-disk key, so concurrent callers share one passphrase resolution. */
593
+ #privateKeyPromise;
594
+ /**
595
+ * True when this manager's key is opaque — configured through
596
+ * `keyOperations`, held by an HSM/KMS, never obtainable as material.
597
+ * When true, {@link getPrivateKey} throws `PrivateKeyUnavailableError`
598
+ * and {@link getKeyOperations} is the only way to use the key.
599
+ */
600
+ isPrivateKeyOpaque() {
601
+ return !!this.#keyOperations;
602
+ }
603
+ /**
604
+ * The key as an opaque {@link IKeyOperations} — the recommended way to
605
+ * *use* the private key regardless of where it lives.
606
+ *
607
+ * Returns the configured `keyOperations` object when the key is opaque.
608
+ * Otherwise returns a stable lazy wrap over {@link getPrivateKey}: its
609
+ * methods resolve the key on first use (disk read, passphrase,
610
+ * `privateKeyProvider` — all async), so the wrap offers no synchronous
611
+ * fast path; callers that need one resolve the key themselves and build
612
+ * a `LocalKeyOperations` over it. The wrap follows key rotation: a
613
+ * `privateKeyProvider` that starts returning a different key gets a
614
+ * fresh underlying `LocalKeyOperations`.
615
+ */
616
+ getKeyOperations() {
617
+ if (this.#keyOperations) {
618
+ return this.#keyOperations;
619
+ }
620
+ if (!this.#lazyLocalKeyOperations) {
621
+ let cached;
622
+ const resolve = async () => {
623
+ const key = await this.getPrivateKey();
624
+ // keyed by identity: the disk path always returns the cached envelope,
625
+ // while a provider that rotates the key yields a fresh wrap
626
+ if (!cached || cached.key !== key) {
627
+ cached = { key, ops: keyOperationsFromPrivateKey(key) };
628
+ }
629
+ return cached.ops;
630
+ };
631
+ this.#lazyLocalKeyOperations = {
632
+ sign: async (data, params) => (await resolve()).sign(data, params),
633
+ decryptBlock: async (block, params) => (await resolve()).decryptBlock(block, params),
634
+ getKeyMetadata: async () => (await resolve()).getKeyMetadata(),
635
+ getPublicKey: async () => (await resolve()).getPublicKey()
636
+ };
637
+ }
638
+ return this.#lazyLocalKeyOperations;
639
+ }
640
+ /**
641
+ * Fail closed at `initialize()` time, not on the first certificate
642
+ * operation, if the key cannot actually be used: wrong/missing
643
+ * passphrase on an encrypted key, a broken `privateKeyProvider`, or an
644
+ * unreachable `keyOperations` provider (probed via `getKeyMetadata`).
645
+ */
646
+ async #probePrivateKey() {
647
+ if (this.#keyOperations) {
648
+ await this.#keyOperations.getKeyMetadata();
649
+ return;
650
+ }
651
+ await this.getPrivateKey();
652
+ }
653
+ /**
654
+ * The key to hand to a certificate-issuance primitive: the raw
655
+ * {@link PrivateKey} for a local configuration, or a {@link CaSigner}
656
+ * adapted from `keyOperations` when the key is opaque — which requires
657
+ * the provider to implement `getPublicKey` (the adapter says so if not).
658
+ */
659
+ async #resolveSigningKey() {
660
+ if (this.#keyOperations) {
661
+ return caSignerFromKeyOperations(this.#keyOperations);
662
+ }
663
+ return await this.getPrivateKey();
664
+ }
665
+ /**
666
+ * Enable, disable, or rotate the passphrase protecting the on-disk
667
+ * private key: decrypt with `oldPassphrase` (omit if the key is
668
+ * currently unencrypted), then write back encrypted with
669
+ * `newPassphrase` (omit to leave it unencrypted). The write goes to a
670
+ * temporary file in the same directory and is atomically renamed into
671
+ * place, so a crash mid-rotation cannot leave a partially-written key;
672
+ * the temporary file is removed if anything fails, so a rotation *to*
673
+ * plaintext can never leave a stray cleartext copy behind. Runs under
674
+ * the same lock as `initialize()`.
675
+ *
676
+ * This only rewrites the on-disk file — it does not update this
677
+ * instance's own `privateKeyPassphrase` (set at construction), and it
678
+ * drops this instance's cached key so that disk stays the source of
679
+ * truth. Construct a new `CertificateManager` with the new passphrase to
680
+ * continue using it afterward.
681
+ *
682
+ * Not supported when a `privateKeyProvider` is configured (there is no
683
+ * disk file for this method to rewrite).
684
+ */
685
+ async reencryptPrivateKey(oldPassphrase, newPassphrase) {
686
+ if (this.#keyOperations) {
687
+ throw new PrivateKeyUnavailableError("reencryptPrivateKey: not supported when keyOperations is configured — there is no key material to rewrite");
688
+ }
689
+ if (this.#privateKeyProvider) {
690
+ throw new Error("reencryptPrivateKey: not supported when a privateKeyProvider is configured");
691
+ }
692
+ const oldPass = await resolvePrivateKeyPassphrase(oldPassphrase);
693
+ const newPass = await resolvePrivateKeyPassphrase(newPassphrase);
694
+ await this.withLock2(async () => {
695
+ const privateKey = readPrivateKey(this.privateKey, oldPass);
696
+ await this.#rewritePrivateKeyFile(privateKey, newPass);
697
+ });
698
+ this.#cachedPrivateKey = undefined;
699
+ this.#privateKeyPromise = undefined;
700
+ }
701
+ /**
702
+ * Atomically replace the on-disk private key with `privateKey`, written
703
+ * as PKCS#8 (encrypted with `passphrase` if given). Temp file next to the
704
+ * target, `0600`, renamed into place; the temp file is unlinked on any
705
+ * failure so no partial or cleartext copy can be left behind.
706
+ * Caller must hold the lock.
707
+ */
708
+ async #rewritePrivateKeyFile(privateKey, passphrase) {
709
+ const tmpFilename = `${this.privateKey}.${process.pid}-${Date.now()}.tmp`;
710
+ try {
711
+ await writePrivateKeyFile(tmpFilename, privateKey, { passphrase });
712
+ await fs.promises.rename(tmpFilename, this.privateKey);
713
+ }
714
+ finally {
715
+ await fs.promises.rm(tmpFilename, { force: true });
716
+ }
717
+ }
718
+ /** Path to the OpenSSL random seed file. */
719
+ get randomFile() {
720
+ return path.join(this.rootDir, "./random.rnd");
721
+ }
722
+ /**
723
+ * Move a certificate to the rejected store.
724
+ * If the certificate was previously trusted, it will be removed from the trusted folder.
725
+ * @param certificateOrChain - the DER-encoded certificate or certificate chain
726
+ */
727
+ async rejectCertificate(certificateOrChain) {
728
+ await this.#moveCertificate(certificateOrChain, "rejected");
729
+ }
730
+ /**
731
+ * Move a certificate to the trusted store.
732
+ * If the certificate was previously rejected, it will be removed from the rejected folder.
733
+ * @param certificateOrChain - the DER-encoded certificate or certificate chain
734
+ */
735
+ async trustCertificate(certificateOrChain) {
736
+ await this.#moveCertificate(certificateOrChain, "trusted");
737
+ }
738
+ /**
739
+ * Check whether the trusted certificate store is empty.
740
+ *
741
+ * This inspects the in-memory index, which is kept in
742
+ * sync with the `trusted/certs/` folder by file-system
743
+ * watchers after {@link initialize} has been called.
744
+ */
745
+ isTrustListEmpty() {
746
+ return this.#thumbs.trusted.size === 0;
747
+ }
748
+ /**
749
+ * Return the number of certificates currently in the
750
+ * trusted store.
751
+ */
752
+ getTrustedCertificateCount() {
753
+ return this.#thumbs.trusted.size;
754
+ }
755
+ /** Path to the rejected certificates folder. */
756
+ get rejectedFolder() {
757
+ return path.join(this.rootDir, "rejected");
758
+ }
759
+ /** Path to the trusted certificates folder. */
760
+ get trustedFolder() {
761
+ return path.join(this.rootDir, "trusted/certs");
762
+ }
763
+ /** Path to the trusted CRL folder. */
764
+ get crlFolder() {
765
+ return path.join(this.rootDir, "trusted/crl");
766
+ }
767
+ /** Path to the issuer (CA) certificates folder. */
768
+ get issuersCertFolder() {
769
+ return path.join(this.rootDir, "issuers/certs");
770
+ }
771
+ /** Path to the issuer CRL folder. */
772
+ get issuersCrlFolder() {
773
+ return path.join(this.rootDir, "issuers/crl");
774
+ }
775
+ /** Path to the own certificate folder. */
776
+ get ownCertFolder() {
777
+ return path.join(this.rootDir, "own/certs");
778
+ }
779
+ get ownPrivateFolder() {
780
+ return path.join(this.rootDir, "own/private");
781
+ }
782
+ /**
783
+ * Check if a certificate is in the trusted store.
784
+ * If the certificate is unknown and `untrustUnknownCertificate` is set,
785
+ * it will be written to the rejected folder.
786
+ * @param certificate - the DER-encoded certificate
787
+ * @returns `"Good"` if trusted, `"BadCertificateUntrusted"` if rejected/unknown,
788
+ * or `"BadCertificateInvalid"` if the certificate cannot be parsed.
789
+ */
790
+ async isCertificateTrusted(certificateOrCertificateChain) {
791
+ try {
792
+ const chain = coerceCertificateChain(certificateOrCertificateChain);
793
+ const leafCertificate = chain[0];
794
+ if (chain.length < 1) {
795
+ return "BadCertificateInvalid";
796
+ }
797
+ let fingerprint;
798
+ try {
799
+ fingerprint = makeFingerprint(chain[0]);
800
+ }
801
+ catch (_err) {
802
+ return "BadCertificateInvalid";
803
+ }
804
+ if (this.#thumbs.trusted.has(fingerprint)) {
805
+ return "Good";
806
+ }
807
+ if (!this.#thumbs.rejected.has(fingerprint)) {
808
+ if (!this.untrustUnknownCertificate) {
809
+ return "Good";
810
+ }
811
+ // Verify structure before writing — don't persist invalid data
812
+ try {
813
+ exploreCertificateInfo(chain[0]);
814
+ }
815
+ catch (_err) {
816
+ return "BadCertificateInvalid";
817
+ }
818
+ const filename = safeStoreJoin(this.rejectedFolder, `${buildIdealCertificateName(leafCertificate)}.pem`);
819
+ debugLog("certificate has never been seen before and is now rejected (untrusted) ", filename);
820
+ await fsWriteFile(filename, toPem(chain, "CERTIFICATE"));
821
+ this.#thumbs.rejected.set(fingerprint, { certificate: leafCertificate, filename });
822
+ }
823
+ return "BadCertificateUntrusted";
824
+ }
825
+ catch (_err) {
826
+ return "BadCertificateInvalid";
827
+ }
828
+ }
829
+ async #innerVerifyCertificateAsync(certificateOrChain, _isIssuer, level, options) {
830
+ if (level >= 5) {
831
+ // maximum level of certificate in chain reached !
832
+ return VerificationStatus.BadSecurityChecksFailed;
833
+ }
834
+ const chain = coerceCertificateChain(certificateOrChain);
835
+ debugLog("NB CERTIFICATE IN CHAIN = ", chain.length);
836
+ const info = exploreCertificate(chain[0]);
837
+ let hasValidIssuer = false;
838
+ let hasTrustedIssuer = false;
839
+ // check if certificate is attached to a issuer
840
+ const hasIssuerKey = info.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier;
841
+ debugLog("Certificate as an Issuer Key", hasIssuerKey);
842
+ if (hasIssuerKey) {
843
+ const isSelfSigned = isSelfSigned2(info);
844
+ debugLog("Is the Certificate self-signed ?", isSelfSigned);
845
+ if (!isSelfSigned) {
846
+ debugLog("Is issuer found in the list of know issuers ?", "\n subjectKeyIdentifier = ", info.tbsCertificate.extensions?.subjectKeyIdentifier, "\n authorityKeyIdentifier = ", info.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier);
847
+ let issuerCertificate = await this.findIssuerCertificate(chain[0]);
848
+ if (!issuerCertificate) {
849
+ // the issuer has not been found in the list of trusted certificate
850
+ // may be the issuer certificate is in the chain itself ?
851
+ issuerCertificate = findIssuerCertificateInChain(chain[0], chain);
852
+ if (!issuerCertificate) {
853
+ debugLog(" the issuer has not been found in the chain itself nor in the issuer.cert list => the chain is incomplete!");
854
+ return VerificationStatus.BadCertificateChainIncomplete;
855
+ }
856
+ debugLog(" the issuer certificate has been found in the chain itself ! the chain is complete !");
857
+ }
858
+ else {
859
+ debugLog(" the issuer certificate has been found in the issuer.cert folder !");
860
+ }
861
+ const issuerStatus = await this.#innerVerifyCertificateAsync(issuerCertificate, true, level + 1, options);
862
+ if (issuerStatus === VerificationStatus.BadCertificateRevocationUnknown) {
863
+ // the issuer must have a CRL available .... !
864
+ return VerificationStatus.BadCertificateIssuerRevocationUnknown;
865
+ }
866
+ if (issuerStatus === VerificationStatus.BadCertificateIssuerRevocationUnknown) {
867
+ // the issuer must have a CRL available .... !
868
+ return VerificationStatus.BadCertificateIssuerRevocationUnknown;
869
+ }
870
+ if (issuerStatus === VerificationStatus.BadCertificateTimeInvalid) {
871
+ if (!options?.acceptOutDatedIssuerCertificate) {
872
+ // the issuer must have valid dates ....
873
+ return VerificationStatus.BadCertificateIssuerTimeInvalid;
874
+ }
875
+ }
876
+ if (issuerStatus === VerificationStatus.BadCertificateUntrusted) {
877
+ debugLog("warning issuerStatus = ", issuerStatus.toString(), "the issuer certificate is not trusted");
878
+ // return VerificationStatus.BadSecurityChecksFailed;
879
+ }
880
+ if (issuerStatus !== VerificationStatus.Good && issuerStatus !== VerificationStatus.BadCertificateUntrusted) {
881
+ // if the issuer has other issue => let's drop!
882
+ return VerificationStatus.BadSecurityChecksFailed;
883
+ }
884
+ // verify that certificate was signed by issuer
885
+ const isCertificateSignatureOK = verifyCertificateSignature(chain[0], issuerCertificate);
886
+ if (!isCertificateSignatureOK) {
887
+ debugLog(" the certificate was not signed by the issuer as it claim to be ! Danger");
888
+ return VerificationStatus.BadSecurityChecksFailed;
889
+ }
890
+ hasValidIssuer = true;
891
+ // let detected if our certificate is in the revocation list
892
+ let revokedStatus = await this.isCertificateRevoked(chain, issuerCertificate);
893
+ if (revokedStatus === VerificationStatus.BadCertificateRevocationUnknown) {
894
+ if (options?.ignoreMissingRevocationList) {
895
+ // continue as if the certificate was not revoked
896
+ revokedStatus = VerificationStatus.Good;
897
+ }
898
+ }
899
+ if (revokedStatus !== VerificationStatus.Good) {
900
+ // certificate is revoked !!!
901
+ debugLog("revokedStatus", revokedStatus);
902
+ return revokedStatus;
903
+ }
904
+ // let check if the issuer is explicitly trusted
905
+ const issuerTrustedStatus = await this.#checkRejectedOrTrusted(issuerCertificate);
906
+ debugLog("issuerTrustedStatus", issuerTrustedStatus);
907
+ if (issuerTrustedStatus === "unknown") {
908
+ hasTrustedIssuer = false;
909
+ }
910
+ else if (issuerTrustedStatus === "trusted") {
911
+ hasTrustedIssuer = true;
912
+ }
913
+ else if (issuerTrustedStatus === "rejected") {
914
+ // we should never get there: this should have been detected before !!!
915
+ return VerificationStatus.BadSecurityChecksFailed;
916
+ }
917
+ }
918
+ else {
919
+ // verify that certificate was signed by issuer (self in this case)
920
+ const isCertificateSignatureOK = verifyCertificateSignature(chain[0], chain[0]);
921
+ if (!isCertificateSignatureOK) {
922
+ debugLog("Self-signed Certificate signature is not valid");
923
+ return VerificationStatus.BadSecurityChecksFailed;
924
+ }
925
+ const revokedStatus = await this.isCertificateRevoked(chain);
926
+ debugLog("revokedStatus of self signed certificate:", revokedStatus);
927
+ }
928
+ }
929
+ const status = await this.#checkRejectedOrTrusted(chain[0]);
930
+ if (status === "rejected") {
931
+ if (!(options.acceptCertificateWithValidIssuerChain && hasValidIssuer && hasTrustedIssuer)) {
932
+ return VerificationStatus.BadCertificateUntrusted;
933
+ }
934
+ }
935
+ // Has SoftwareCertificate passed its issue date and has it not expired ?
936
+ // check dates
937
+ //
938
+ // exploreCertificate rather than exploreCertificateInfo: only the two
939
+ // dates are wanted, and exploreCertificateInfo additionally insists on
940
+ // an RSA-sized public key. It threw on an EC certificate, which the
941
+ // caller then saw as BadCertificateInvalid - a verdict about the key
942
+ // type, reported as if the certificate were malformed. An issuer
943
+ // parsed the same way, purely to be written to the debug log, made
944
+ // that fatal for any chain with an EC issuer in it.
945
+ const { validity } = exploreCertificate(chain[0]).tbsCertificate;
946
+ const now = new Date();
947
+ let isTimeInvalid = false;
948
+ // check that certificate is active
949
+ if (validity.notBefore.getTime() > now.getTime()) {
950
+ // certificate is not active yet
951
+ debugLog(`${chalk.red("certificate is invalid : certificate is not active yet !")} not before date =${validity.notBefore}`);
952
+ if (!options.acceptPendingCertificate) {
953
+ isTimeInvalid = true;
954
+ }
955
+ }
956
+ // check that certificate has not expired
957
+ if (validity.notAfter.getTime() <= now.getTime()) {
958
+ // certificate is obsolete
959
+ debugLog(`${chalk.red("certificate is invalid : certificate has expired !")} not after date =${validity.notAfter}`);
960
+ if (!options.acceptOutdatedCertificate) {
961
+ isTimeInvalid = true;
962
+ }
963
+ }
964
+ if (status === "trusted") {
965
+ return isTimeInvalid ? VerificationStatus.BadCertificateTimeInvalid : VerificationStatus.Good;
966
+ }
967
+ // status should be "unknown" or "rejected" (bypassed) at this point
968
+ if (hasIssuerKey) {
969
+ if (!hasTrustedIssuer) {
970
+ return VerificationStatus.BadCertificateUntrusted;
971
+ }
972
+ if (!hasValidIssuer) {
973
+ return VerificationStatus.BadCertificateUntrusted;
974
+ }
975
+ if (!options.acceptCertificateWithValidIssuerChain) {
976
+ // strict mode: the leaf cert is not in the trusted store
977
+ return VerificationStatus.BadCertificateUntrusted;
978
+ }
979
+ return isTimeInvalid ? VerificationStatus.BadCertificateTimeInvalid : VerificationStatus.Good;
980
+ }
981
+ else {
982
+ return VerificationStatus.BadCertificateUntrusted;
983
+ }
984
+ }
985
+ /**
986
+ * Internal verification hook called by {@link verifyCertificate}.
987
+ *
988
+ * Subclasses can override this to inject additional validation
989
+ * logic (e.g. application-level policy checks) while still
990
+ * delegating to the default chain/CRL/trust verification.
991
+ *
992
+ * @param certificate - the DER-encoded certificate to verify
993
+ * @param options - verification options forwarded from the
994
+ * public API
995
+ * @returns the verification status code
996
+ */
997
+ async verifyCertificateAsync(certificate, options) {
998
+ const chain = coerceCertificateChain(certificate);
999
+ for (const element of chain) {
1000
+ try {
1001
+ // exploreCertificate throws if the DER element is not a
1002
+ // valid X.509 certificate (e.g. it is a CRL or some other
1003
+ // ASN.1 structure), which is the only question being asked
1004
+ // here. exploreCertificateInfo used to stand in for it, but
1005
+ // it also insists on an RSA-sized public key, so every chain
1006
+ // containing an EC certificate was reported invalid on
1007
+ // structural grounds it never actually failed.
1008
+ exploreCertificate(element);
1009
+ }
1010
+ catch (_err) {
1011
+ return VerificationStatus.BadCertificateInvalid;
1012
+ }
1013
+ }
1014
+ const status1 = await this.#innerVerifyCertificateAsync(chain, false, 0, options);
1015
+ return status1;
1016
+ }
1017
+ /**
1018
+ * Verify a certificate against the PKI trust store.
1019
+ *
1020
+ * This performs a full validation including trust status,
1021
+ * issuer chain, CRL revocation checks, and time validity.
1022
+ *
1023
+ * @param certificate - the DER-encoded certificate to verify
1024
+ * @param options - optional flags to relax validation rules
1025
+ * @returns the verification status code
1026
+ */
1027
+ async verifyCertificate(certificate, options) {
1028
+ // Is the signature on the SoftwareCertificate valid .?
1029
+ if (!certificate) {
1030
+ // missing certificate
1031
+ return VerificationStatus.BadSecurityChecksFailed;
1032
+ }
1033
+ try {
1034
+ const status = await this.verifyCertificateAsync(certificate, options || {});
1035
+ return status;
1036
+ }
1037
+ catch (error) {
1038
+ warningLog(`verifyCertificate error: ${error.message}`);
1039
+ return VerificationStatus.BadCertificateInvalid;
1040
+ }
1041
+ }
1042
+ /**
1043
+ * Initialize the PKI directory structure, generate the
1044
+ * private key (if missing), and start file-system watchers.
1045
+ *
1046
+ * This method is idempotent — subsequent calls are no-ops.
1047
+ * It must be called before any certificate operations.
1048
+ */
1049
+ async initialize() {
1050
+ if (this.state !== CertificateManagerState.Uninitialized) {
1051
+ return;
1052
+ }
1053
+ this.state = CertificateManagerState.Initializing;
1054
+ this.#initializingPromise = this.#initialize();
1055
+ try {
1056
+ await this.#initializingPromise;
1057
+ }
1058
+ catch (err) {
1059
+ // Fail closed but not stuck: a failed initialize() (typically a
1060
+ // wrong or missing private-key passphrase) must leave the
1061
+ // instance re-initializable, not parked in Initializing where a
1062
+ // retry would silently no-op with empty trust indexes.
1063
+ this.#initializingPromise = undefined;
1064
+ this.state = CertificateManagerState.Uninitialized;
1065
+ throw err;
1066
+ }
1067
+ this.#initializingPromise = undefined;
1068
+ this.state = CertificateManagerState.Initialized;
1069
+ // Register for automatic cleanup on process exit
1070
+ CertificateManager.#activeInstances.add(this);
1071
+ CertificateManager.#installExitCleanup();
1072
+ }
1073
+ async #initialize() {
1074
+ const pkiDir = this.#location;
1075
+ mkdirRecursiveSync(pkiDir);
1076
+ mkdirRecursiveSync(path.join(pkiDir, "own"));
1077
+ mkdirRecursiveSync(path.join(pkiDir, "own/certs"));
1078
+ ensurePrivateDirectory(path.join(pkiDir, "own/private"));
1079
+ mkdirRecursiveSync(path.join(pkiDir, "rejected"));
1080
+ mkdirRecursiveSync(path.join(pkiDir, "trusted"));
1081
+ mkdirRecursiveSync(path.join(pkiDir, "trusted/certs"));
1082
+ mkdirRecursiveSync(path.join(pkiDir, "trusted/crl"));
1083
+ mkdirRecursiveSync(path.join(pkiDir, "issuers"));
1084
+ mkdirRecursiveSync(path.join(pkiDir, "issuers/certs")); // contains Trusted CA certificates
1085
+ mkdirRecursiveSync(path.join(pkiDir, "issuers/crl")); // contains CRL of revoked CA certificates
1086
+ // when a privateKeyProvider or keyOperations is configured it overrides disk
1087
+ // entirely, so there is no on-disk key to generate, encrypt, or check for existence
1088
+ const ownsDiskKey = !this.#privateKeyProvider && !this.#keyOperations;
1089
+ const needsKeyGeneration = ownsDiskKey && !fs.existsSync(this.privateKey);
1090
+ // Secure by default: a passphrase configured on an install whose key
1091
+ // is still plaintext means "protect this key", not "ignore me". Node's
1092
+ // createPrivateKey would silently accept the plaintext key with any
1093
+ // passphrase, so detect it here and encrypt in place.
1094
+ const needsKeyEncryption = ownsDiskKey &&
1095
+ !needsKeyGeneration &&
1096
+ this.#privateKeyPassphrase !== undefined &&
1097
+ !isEncryptedPrivateKeyFile(this.privateKey);
1098
+ if (!fs.existsSync(this.configFile) || needsKeyGeneration || needsKeyEncryption) {
1099
+ return await this.withLock2(async () => {
1100
+ if (this.state === CertificateManagerState.Disposing || this.state === CertificateManagerState.Disposed) {
1101
+ return;
1102
+ }
1103
+ if (!fs.existsSync(this.configFile)) {
1104
+ fs.writeFileSync(this.configFile, configurationFileSimpleTemplate);
1105
+ }
1106
+ // note : openssl 1.1.1 has a bug that causes a failure if
1107
+ // random file cannot be found. (should be fixed in 1.1.1.a)
1108
+ // if this issue become important we may have to consider checking that rndFile exists and recreate
1109
+ // it if not . this could be achieved with the command :
1110
+ // "openssl rand -writerand ${this.randomFile}"
1111
+ //
1112
+ // cf: https://github.com/node-opcua/node-opcua/issues/554
1113
+ if (ownsDiskKey && !fs.existsSync(this.privateKey)) {
1114
+ debugLog("generating private key ...");
1115
+ // setEnv("RANDFILE", this.randomFile);
1116
+ const passphrase = await resolvePrivateKeyPassphrase(this.#privateKeyPassphrase);
1117
+ await generatePrivateKeyFile(this.privateKey, this.keySize, { passphrase });
1118
+ // seed the cache from the passphrase we already resolved,
1119
+ // so a passphrase function is not called a second time below
1120
+ this.#cachedPrivateKey = readPrivateKey(this.privateKey, passphrase);
1121
+ }
1122
+ else if (ownsDiskKey && this.#privateKeyPassphrase !== undefined && !isEncryptedPrivateKeyFile(this.privateKey)) {
1123
+ // (re-checked under the lock: another instance may have done it first)
1124
+ warningLog("initialize: private key is plaintext but a passphrase is configured; encrypting it in place");
1125
+ const passphrase = await resolvePrivateKeyPassphrase(this.#privateKeyPassphrase);
1126
+ const plaintextKey = readPrivateKey(this.privateKey);
1127
+ await this.#rewritePrivateKeyFile(plaintextKey, passphrase);
1128
+ this.#cachedPrivateKey = plaintextKey;
1129
+ }
1130
+ if (ownsDiskKey) {
1131
+ // repair permissions on installs created before this hardening,
1132
+ // and confirm them on a freshly generated key
1133
+ restrictPrivateFilePermissions(this.privateKey, 0o600);
1134
+ }
1135
+ await this.#probePrivateKey();
1136
+ await this.#readCertificates();
1137
+ });
1138
+ }
1139
+ else {
1140
+ if (ownsDiskKey) {
1141
+ restrictPrivateFilePermissions(this.privateKey, 0o600);
1142
+ }
1143
+ await this.#probePrivateKey();
1144
+ await this.#readCertificates();
1145
+ }
1146
+ }
1147
+ /**
1148
+ * Dispose of the CertificateManager, releasing file watchers
1149
+ * and other resources. The instance should not be used after
1150
+ * calling this method.
1151
+ */
1152
+ async dispose() {
1153
+ if (this.state === CertificateManagerState.Disposing) {
1154
+ throw new Error("Already disposing");
1155
+ }
1156
+ if (this.state === CertificateManagerState.Uninitialized) {
1157
+ this.state = CertificateManagerState.Disposed;
1158
+ return;
1159
+ }
1160
+ // Wait for initialization to complete before disposing
1161
+ if (this.state === CertificateManagerState.Initializing) {
1162
+ if (this.#initializingPromise) {
1163
+ await this.#initializingPromise;
1164
+ }
1165
+ }
1166
+ try {
1167
+ this.state = CertificateManagerState.Disposing;
1168
+ // Wait for any in-flight withLock operations (e.g.
1169
+ // fire-and-forget trustCertificate calls) to complete
1170
+ // so their setInterval timers are properly cleared.
1171
+ await drainPendingLocks();
1172
+ // Ensure all fs.watch handles are unref'd even if
1173
+ // chokidar hasn't reached "ready" yet.
1174
+ for (const unreff of this.#pendingUnrefs) {
1175
+ unreff();
1176
+ }
1177
+ this.#pendingUnrefs.clear();
1178
+ await Promise.all(this.#watchers.map((w) => w.close()));
1179
+ this.#watchers.forEach((w) => {
1180
+ w.removeAllListeners();
1181
+ });
1182
+ this.#watchers.splice(0);
1183
+ }
1184
+ finally {
1185
+ this.state = CertificateManagerState.Disposed;
1186
+ this.#cachedPrivateKey = undefined;
1187
+ this.#privateKeyPromise = undefined;
1188
+ CertificateManager.#activeInstances.delete(this);
1189
+ CertificateManager.#uninstallExitCleanupIfIdle();
1190
+ }
1191
+ }
1192
+ /**
1193
+ * Force a full re-scan of all PKI folders, rebuilding
1194
+ * the in-memory `_thumbs` index from scratch.
1195
+ *
1196
+ * Call this after external processes have modified the
1197
+ * PKI folders (e.g. via `writeTrustList` or CLI tools)
1198
+ * to ensure the CertificateManager sees the latest
1199
+ * state without waiting for file-system events.
1200
+ */
1201
+ async reloadCertificates() {
1202
+ // Close existing watchers
1203
+ await Promise.all(this.#watchers.map((w) => w.close()));
1204
+ for (const w of this.#watchers) {
1205
+ w.removeAllListeners();
1206
+ }
1207
+ this.#watchers.splice(0);
1208
+ // Clear in-memory indexes
1209
+ this.#thumbs.rejected.clear();
1210
+ this.#thumbs.trusted.clear();
1211
+ this.#thumbs.issuers.certs.clear();
1212
+ this.#thumbs.crl.clear();
1213
+ this.#thumbs.issuersCrl.clear();
1214
+ this.#filenameToHash.clear();
1215
+ // Re-scan all folders
1216
+ this.#readCertificatesCalled = false;
1217
+ await this.#readCertificates();
1218
+ }
1219
+ async withLock2(action) {
1220
+ const lockFileName = path.join(this.rootDir, "mutex");
1221
+ return withLock({ fileToLock: lockFileName }, async () => {
1222
+ return await action();
1223
+ });
1224
+ }
1225
+ /**
1226
+ * Create a self-signed certificate for this PKI's private key.
1227
+ *
1228
+ * The certificate is written to `params.outputFile` or
1229
+ * `own/certs/self_signed_certificate.pem` by default.
1230
+ *
1231
+ * @param params - certificate parameters (subject, SANs,
1232
+ * validity, etc.)
1233
+ */
1234
+ async createSelfSignedCertificate(params) {
1235
+ if (typeof params.applicationUri !== "string") {
1236
+ throw new Error("createSelfSignedCertificate: expecting applicationUri to be a string");
1237
+ }
1238
+ if (!this.#privateKeyProvider && !this.#keyOperations && !fs.existsSync(this.privateKey)) {
1239
+ throw new Error(`Cannot find private key ${this.privateKey}`);
1240
+ }
1241
+ let certificateFilename = path.join(this.rootDir, "own/certs/self_signed_certificate.pem");
1242
+ certificateFilename = params.outputFile || certificateFilename;
1243
+ // a copy, not an in-place mutation: the caller's object must never
1244
+ // end up holding the resolved private key material
1245
+ const _params = {
1246
+ ...params,
1247
+ rootDir: this.rootDir,
1248
+ configFile: this.configFile,
1249
+ privateKey: await this.#resolveSigningKey(),
1250
+ subject: params.subject || "CN=FIXME"
1251
+ };
1252
+ await this.withLock2(async () => {
1253
+ await createSelfSignedCertificate(certificateFilename, _params);
1254
+ });
1255
+ }
1256
+ /**
1257
+ * Create a Certificate Signing Request (CSR) using this
1258
+ * PKI's private key and configuration.
1259
+ *
1260
+ * The CSR file is written to `own/certs/` with a timestamped
1261
+ * filename.
1262
+ *
1263
+ * @param params - CSR parameters (subject, SANs)
1264
+ * @returns the filesystem path to the generated CSR file
1265
+ */
1266
+ async createCertificateRequest(params) {
1267
+ if (!params) {
1268
+ throw new Error("params is required");
1269
+ }
1270
+ if (Object.prototype.hasOwnProperty.call(params, "rootDir")) {
1271
+ throw new Error("rootDir should not be specified ");
1272
+ }
1273
+ // a copy, not an in-place mutation: the caller's object must never
1274
+ // end up holding the resolved private key material
1275
+ const _params = {
1276
+ ...params,
1277
+ rootDir: path.resolve(this.rootDir),
1278
+ configFile: path.resolve(this.configFile),
1279
+ privateKey: await this.#resolveSigningKey()
1280
+ };
1281
+ return await this.withLock2(async () => {
1282
+ // compose a file name for the request
1283
+ const now = new Date();
1284
+ const today = `${now.toISOString().slice(0, 10)}_${now.getTime()}`;
1285
+ const certificateSigningRequestFilename = path.join(this.rootDir, "own/certs", `certificate_${today}.csr`);
1286
+ await createCertificateSigningRequestAsync(certificateSigningRequestFilename, _params);
1287
+ return certificateSigningRequestFilename;
1288
+ });
1289
+ }
1290
+ /**
1291
+ * Add a CA (issuer) certificate to the issuers store.
1292
+ * If the certificate is already present, this is a no-op.
1293
+ * @param certificate - the DER-encoded CA certificate
1294
+ * @param validate - if `true`, verify the certificate before adding
1295
+ * @param addInTrustList - if `true`, also add to the trusted store
1296
+ * @returns `VerificationStatus.Good` on success
1297
+ */
1298
+ async addIssuer(certificate, validate = false, addInTrustList = false) {
1299
+ if (validate) {
1300
+ const status = await this.verifyCertificate(certificate);
1301
+ if (status !== VerificationStatus.Good && status !== VerificationStatus.BadCertificateUntrusted) {
1302
+ return status;
1303
+ }
1304
+ }
1305
+ const pemCertificate = toPem(certificate, "CERTIFICATE");
1306
+ const fingerprint = makeFingerprint(certificate);
1307
+ if (this.#thumbs.issuers.certs.has(fingerprint)) {
1308
+ // already in .. simply ignore
1309
+ return VerificationStatus.Good;
1310
+ }
1311
+ // write certificate
1312
+ const filename = safeStoreJoin(this.issuersCertFolder, `issuer_${buildIdealCertificateName(certificate)}.pem`);
1313
+ await fs.promises.writeFile(filename, pemCertificate, "ascii");
1314
+ // first time seen, let's save it.
1315
+ this.#thumbs.issuers.certs.set(fingerprint, { certificate, filename });
1316
+ if (addInTrustList) {
1317
+ // add certificate in the trust list as well
1318
+ await this.trustCertificate(certificate);
1319
+ }
1320
+ return VerificationStatus.Good;
1321
+ }
1322
+ /**
1323
+ * Add multiple CA (issuer) certificates to the issuers store.
1324
+ * @param certificates - the DER-encoded CA certificates
1325
+ * @param validate - if `true`, verify each certificate before adding
1326
+ * @param addInTrustList - if `true`, also add each certificate to the trusted store
1327
+ * @returns `VerificationStatus.Good` on success
1328
+ */
1329
+ async addIssuers(certificates, validate = false, addInTrustList = false) {
1330
+ for (const certificate of certificates) {
1331
+ // check that certificate is a issuer certificate
1332
+ if (!isIssuer(certificate)) {
1333
+ warningLog(`Certificate ${makeFingerprint(certificate)} is not a issuer certificate`);
1334
+ continue;
1335
+ }
1336
+ await this.addIssuer(certificate, validate, addInTrustList);
1337
+ }
1338
+ return VerificationStatus.Good;
1339
+ }
1340
+ /**
1341
+ * Add a CRL to the certificate manager.
1342
+ * @param crl - the CRL to add
1343
+ * @param target - "issuers" (default) writes to issuers/crl, "trusted" writes to trusted/crl
1344
+ */
1345
+ async addRevocationList(crl, target = "issuers") {
1346
+ return await this.withLock2(async () => {
1347
+ try {
1348
+ const index = target === "trusted" ? this.#thumbs.crl : this.#thumbs.issuersCrl;
1349
+ const folder = target === "trusted" ? this.crlFolder : this.issuersCrlFolder;
1350
+ const crlInfo = exploreCertificateRevocationList(crl);
1351
+ const key = crlInfo.tbsCertList.issuerFingerprint;
1352
+ if (!index.has(key)) {
1353
+ index.set(key, { crls: [], serialNumbers: {} });
1354
+ }
1355
+ const pemCertificate = toPem(crl, "X509 CRL");
1356
+ // Use the issuer fingerprint for the filename — NOT buildIdealCertificateName()
1357
+ // which expects a certificate, not a CRL. Passing a CRL causes
1358
+ // exploreCertificate() to throw, producing "invalid_certificate_" names.
1359
+ const sanitizedKey = key.replace(/:/g, "");
1360
+ const filename = path.join(folder, `crl_[${sanitizedKey}].pem`);
1361
+ await fs.promises.writeFile(filename, pemCertificate, "ascii");
1362
+ await this.#onCrlFileAdded(index, filename);
1363
+ await this.#waitAndCheckCRLProcessingStatus();
1364
+ return VerificationStatus.Good;
1365
+ }
1366
+ catch (err) {
1367
+ debugLog(err);
1368
+ return VerificationStatus.BadSecurityChecksFailed;
1369
+ }
1370
+ });
1371
+ }
1372
+ /**
1373
+ * Remove all CRL files from the specified folder(s) and clear the
1374
+ * corresponding in-memory index.
1375
+ * @param target - "issuers" clears issuers/crl, "trusted" clears
1376
+ * trusted/crl, "all" clears both.
1377
+ */
1378
+ async clearRevocationLists(target) {
1379
+ const clearFolder = async (folder, index) => {
1380
+ try {
1381
+ const files = await fs.promises.readdir(folder);
1382
+ for (const file of files) {
1383
+ const ext = path.extname(file).toLowerCase();
1384
+ if (ext === ".crl" || ext === ".pem" || ext === ".der") {
1385
+ await fs.promises.unlink(path.join(folder, file));
1386
+ }
1387
+ }
1388
+ }
1389
+ catch (err) {
1390
+ if (err.code !== "ENOENT") {
1391
+ throw err;
1392
+ }
1393
+ }
1394
+ index.clear();
1395
+ };
1396
+ if (target === "issuers" || target === "all") {
1397
+ await clearFolder(this.issuersCrlFolder, this.#thumbs.issuersCrl);
1398
+ }
1399
+ if (target === "trusted" || target === "all") {
1400
+ await clearFolder(this.crlFolder, this.#thumbs.crl);
1401
+ }
1402
+ }
1403
+ /**
1404
+ * Check whether an issuer certificate with the given thumbprint
1405
+ * is already registered.
1406
+ * @param thumbprint - hex-encoded SHA-1 thumbprint (lowercase)
1407
+ */
1408
+ async hasIssuer(thumbprint) {
1409
+ await this.#readCertificates();
1410
+ const normalized = thumbprint.toLowerCase();
1411
+ return this.#thumbs.issuers.certs.has(normalized);
1412
+ }
1413
+ /**
1414
+ * Remove a trusted certificate identified by its SHA-1 thumbprint.
1415
+ * Deletes the file on disk and removes the entry from the
1416
+ * in-memory index.
1417
+ * @param thumbprint - hex-encoded SHA-1 thumbprint (lowercase)
1418
+ * @returns the removed certificate buffer, or `null` if not found
1419
+ */
1420
+ async removeTrustedCertificate(thumbprint) {
1421
+ await this.#readCertificates();
1422
+ const normalized = thumbprint.toLowerCase();
1423
+ const entry = this.#thumbs.trusted.get(normalized);
1424
+ if (!entry) {
1425
+ return null;
1426
+ }
1427
+ try {
1428
+ await fs.promises.unlink(entry.filename);
1429
+ }
1430
+ catch (err) {
1431
+ if (err.code !== "ENOENT") {
1432
+ throw err;
1433
+ }
1434
+ }
1435
+ this.#thumbs.trusted.delete(normalized);
1436
+ return entry.certificate;
1437
+ }
1438
+ /**
1439
+ * Remove an issuer certificate identified by its SHA-1 thumbprint.
1440
+ * Deletes the file on disk and removes the entry from the
1441
+ * in-memory index.
1442
+ * @param thumbprint - hex-encoded SHA-1 thumbprint (lowercase)
1443
+ * @returns the removed certificate buffer, or `null` if not found
1444
+ */
1445
+ async removeIssuer(thumbprint) {
1446
+ await this.#readCertificates();
1447
+ const normalized = thumbprint.toLowerCase();
1448
+ const entry = this.#thumbs.issuers.certs.get(normalized);
1449
+ if (!entry) {
1450
+ return null;
1451
+ }
1452
+ try {
1453
+ await fs.promises.unlink(entry.filename);
1454
+ }
1455
+ catch (err) {
1456
+ if (err.code !== "ENOENT") {
1457
+ throw err;
1458
+ }
1459
+ }
1460
+ this.#thumbs.issuers.certs.delete(normalized);
1461
+ return entry.certificate;
1462
+ }
1463
+ /**
1464
+ * Remove all CRL files that were issued by the given CA certificate
1465
+ * from the specified folder (or both).
1466
+ * @param issuerCertificate - the CA certificate whose CRLs to remove
1467
+ * @param target - "issuers", "trusted", or "all" (default "all")
1468
+ */
1469
+ async removeRevocationListsForIssuer(issuerCertificate, target = "all") {
1470
+ const issuerInfo = exploreCertificate(issuerCertificate);
1471
+ const issuerFingerprint = issuerInfo.tbsCertificate.subjectFingerPrint;
1472
+ const processIndex = async (index) => {
1473
+ const crlData = index.get(issuerFingerprint);
1474
+ if (!crlData)
1475
+ return;
1476
+ for (const crlEntry of crlData.crls) {
1477
+ try {
1478
+ await fs.promises.unlink(crlEntry.filename);
1479
+ }
1480
+ catch (err) {
1481
+ if (err.code !== "ENOENT") {
1482
+ throw err;
1483
+ }
1484
+ }
1485
+ }
1486
+ index.delete(issuerFingerprint);
1487
+ };
1488
+ if (target === "issuers" || target === "all") {
1489
+ await processIndex(this.#thumbs.issuersCrl);
1490
+ }
1491
+ if (target === "trusted" || target === "all") {
1492
+ await processIndex(this.#thumbs.crl);
1493
+ }
1494
+ }
1495
+ /**
1496
+ * Validate a certificate (optionally with its chain) and add
1497
+ * the leaf certificate to the trusted store.
1498
+ *
1499
+ * Performs OPC UA Part 4, Table 100 validation:
1500
+ *
1501
+ * 1. **Certificate Structure** — parse the DER encoding.
1502
+ * 2. **Build Certificate Chain** — walk from the leaf to a
1503
+ * self-signed root CA, using the provided chain and the
1504
+ * issuers store.
1505
+ * 3. **Signature** — verify each certificate's signature
1506
+ * against its issuer.
1507
+ * 4. **Issuer Presence** — every issuer in the chain must
1508
+ * already be registered in the issuers store (per GDS
1509
+ * 7.8.2.6).
1510
+ * 5. **Validity Period** — each certificate must be within
1511
+ * its validity window (overridable via
1512
+ * {@link AddCertificateValidationOptions.acceptExpiredCertificate}).
1513
+ * 6. **Revocation Check** — each certificate is checked
1514
+ * against its issuer's CRL (overridable via
1515
+ * {@link AddCertificateValidationOptions.acceptRevokedCertificate}
1516
+ * and {@link AddCertificateValidationOptions.ignoreMissingRevocationList}).
1517
+ *
1518
+ * Only the leaf certificate is added to the trusted store.
1519
+ *
1520
+ * @param certificateChain - DER-encoded certificate or chain
1521
+ * @returns `VerificationStatus.Good` on success, or an error
1522
+ * status indicating why the certificate was rejected.
1523
+ */
1524
+ async addTrustedCertificateFromChain(certificateChain) {
1525
+ // Top-level guard: never let an unexpected error escape.
1526
+ // Every code path returns a VerificationStatus; unexpected
1527
+ // throws (corrupt buffers, crypto failures, etc.) are
1528
+ // caught here and mapped to BadCertificateInvalid.
1529
+ try {
1530
+ return await this.#addTrustedCertificateFromChainImpl(certificateChain);
1531
+ }
1532
+ catch (_err) {
1533
+ warningLog("addTrustedCertificateFromChain: unexpected error", _err);
1534
+ return VerificationStatus.BadCertificateInvalid;
1535
+ }
1536
+ }
1537
+ async #addTrustedCertificateFromChainImpl(certificateChain) {
1538
+ let certificates;
1539
+ try {
1540
+ certificates = coerceCertificateChain(certificateChain);
1541
+ }
1542
+ catch (_err) {
1543
+ return VerificationStatus.BadCertificateInvalid;
1544
+ }
1545
+ if (certificates.length === 0) {
1546
+ return VerificationStatus.BadCertificateInvalid;
1547
+ }
1548
+ const leafCertificate = certificates[0];
1549
+ const opts = this.#addCertValidation;
1550
+ // ── Step 1: Certificate Structure ────────────────────────
1551
+ let leafInfo;
1552
+ try {
1553
+ leafInfo = exploreCertificate(leafCertificate);
1554
+ }
1555
+ catch (_err) {
1556
+ return VerificationStatus.BadCertificateInvalid;
1557
+ }
1558
+ // Re-scan the issuers folder to pick up certificates
1559
+ // added directly to disk (e.g. by GDS push or external
1560
+ // tooling) that the file-system watcher may not have
1561
+ // delivered yet.
1562
+ await this.#scanCertFolder(this.issuersCertFolder, this.#thumbs.issuers.certs);
1563
+ // ── Step 2–6: Walk the chain from leaf to root ───────────
1564
+ // depth counts the number of certificates validated in the
1565
+ // chain. maxChainLength=1 → only self-signed certs;
1566
+ // maxChainLength=2 → leaf + root CA; etc.
1567
+ let currentCert = leafCertificate;
1568
+ let currentInfo = leafInfo;
1569
+ let depth = 0;
1570
+ while (true) {
1571
+ depth++;
1572
+ if (depth > opts.maxChainLength) {
1573
+ // Chain depth exceeded before reaching root
1574
+ return VerificationStatus.BadSecurityChecksFailed;
1575
+ }
1576
+ // ── Step 5: Validity Period ──────────────────────────
1577
+ if (!opts.acceptExpiredCertificate) {
1578
+ // currentInfo is kept in lockstep with currentCert, so the
1579
+ // dates are already parsed. Re-reading them through
1580
+ // exploreCertificateInfo cost a second parse and rejected any
1581
+ // EC certificate outright, since that helper also insists on
1582
+ // an RSA-sized public key.
1583
+ const { validity } = currentInfo.tbsCertificate;
1584
+ const now = new Date();
1585
+ if (validity.notBefore.getTime() > now.getTime()) {
1586
+ return VerificationStatus.BadCertificateTimeInvalid;
1587
+ }
1588
+ if (validity.notAfter.getTime() <= now.getTime()) {
1589
+ return depth === 1
1590
+ ? VerificationStatus.BadCertificateTimeInvalid
1591
+ : VerificationStatus.BadCertificateIssuerTimeInvalid;
1592
+ }
1593
+ }
1594
+ // ── Self-signed certificate ──────────────────────────
1595
+ if (isSelfSigned2(currentInfo)) {
1596
+ // Step 3: Verify self-signature
1597
+ try {
1598
+ if (!verifyCertificateSignature(currentCert, currentCert)) {
1599
+ return VerificationStatus.BadCertificateInvalid;
1600
+ }
1601
+ }
1602
+ catch (_err) {
1603
+ return VerificationStatus.BadCertificateInvalid;
1604
+ }
1605
+ // Self-signed certificates don't need revocation
1606
+ // or issuer checks — we're at the root.
1607
+ break;
1608
+ }
1609
+ // ── Step 2: Find issuer ──────────────────────────────
1610
+ // First try findIssuerCertificate (checks issuers store
1611
+ // and trusted store), then fall back to the chain.
1612
+ let issuerCert = await this.findIssuerCertificate(currentCert);
1613
+ if (!issuerCert) {
1614
+ // The issuer is not in the issuers store — try
1615
+ // the explicitly provided chain.
1616
+ issuerCert = findIssuerCertificateInChain(currentCert, certificates);
1617
+ if (!issuerCert || issuerCert === currentCert) {
1618
+ return VerificationStatus.BadCertificateChainIncomplete;
1619
+ }
1620
+ }
1621
+ // ── Step 3: Signature verification ───────────────────
1622
+ try {
1623
+ if (!verifyCertificateSignature(currentCert, issuerCert)) {
1624
+ return VerificationStatus.BadCertificateInvalid;
1625
+ }
1626
+ }
1627
+ catch (_err) {
1628
+ return VerificationStatus.BadCertificateInvalid;
1629
+ }
1630
+ // ── Step 4: Issuer must be in the issuers store ──────
1631
+ // Per GDS 7.8.2.6: "This Method will return a
1632
+ // validation error if the Certificate is issued by a CA
1633
+ // and the Certificate for the issuer is not in the
1634
+ // TrustList"
1635
+ const issuerThumbprint = makeFingerprint(issuerCert);
1636
+ if (!(await this.hasIssuer(issuerThumbprint))) {
1637
+ return VerificationStatus.BadCertificateChainIncomplete;
1638
+ }
1639
+ // ── Step 6: Revocation check ─────────────────────────
1640
+ const revokedStatus = await this.isCertificateRevoked(currentCert, issuerCert);
1641
+ if (revokedStatus === VerificationStatus.BadCertificateRevoked) {
1642
+ if (!opts.acceptRevokedCertificate) {
1643
+ return VerificationStatus.BadCertificateRevoked;
1644
+ }
1645
+ }
1646
+ else if (revokedStatus === VerificationStatus.BadCertificateRevocationUnknown) {
1647
+ if (!opts.ignoreMissingRevocationList) {
1648
+ return VerificationStatus.BadCertificateRevocationUnknown;
1649
+ }
1650
+ }
1651
+ // Move up the chain
1652
+ currentCert = issuerCert;
1653
+ try {
1654
+ currentInfo = exploreCertificate(currentCert);
1655
+ }
1656
+ catch (_err) {
1657
+ return VerificationStatus.BadCertificateInvalid;
1658
+ }
1659
+ }
1660
+ // All checks passed — trust the leaf certificate.
1661
+ // Pass the full chain so the PEM on disk preserves
1662
+ // intermediate CA certificates (chain-on-disk,
1663
+ // leaf-only-in-memory principle).
1664
+ await this.trustCertificate(certificates);
1665
+ return VerificationStatus.Good;
1666
+ }
1667
+ /**
1668
+ * Check whether an issuer certificate is still needed by any
1669
+ * certificate in the trusted store.
1670
+ *
1671
+ * This is used before removing an issuer to ensure that
1672
+ * doing so would not break the chain of any trusted
1673
+ * certificate.
1674
+ *
1675
+ * @param issuerCertificate - the CA certificate to check
1676
+ * @returns `true` if at least one trusted certificate was
1677
+ * signed by this issuer.
1678
+ */
1679
+ async isIssuerInUseByTrustedCertificate(issuerCertificate) {
1680
+ await this.#readCertificates();
1681
+ for (const entry of this.#thumbs.trusted.values()) {
1682
+ if (!entry.certificate)
1683
+ continue;
1684
+ try {
1685
+ if (verifyCertificateSignature(entry.certificate, issuerCertificate)) {
1686
+ return true;
1687
+ }
1688
+ }
1689
+ catch (_err) {
1690
+ // Skip certificates that can't be verified
1691
+ }
1692
+ }
1693
+ return false;
1694
+ }
1695
+ /**
1696
+ * find the issuer certificate among the trusted issuer certificates.
1697
+ *
1698
+ * The findIssuerCertificate method is an asynchronous method that attempts to find
1699
+ * the issuer certificate for a given certificate from the list of issuer certificate declared in the PKI
1700
+ *
1701
+ * - If the certificate is self-signed, it returns the certificate itself.
1702
+ *
1703
+ * - If the certificate has no extension 3, it is assumed to be generated by an old system, and a null value is returned.
1704
+ *
1705
+ * - the method checks both issuer and trusted certificates and returns the appropriate issuercertificate,
1706
+ * if found. If multiple matching certificates are found, a warning is logged to the console.
1707
+ *
1708
+ */
1709
+ async findIssuerCertificate(certificate) {
1710
+ const firstCertificate = coerceCertificateChain(certificate)[0];
1711
+ const certInfo = exploreCertificate(firstCertificate);
1712
+ if (isSelfSigned2(certInfo)) {
1713
+ // the certificate is self signed so is it's own issuer.
1714
+ return firstCertificate;
1715
+ }
1716
+ const wantedIssuerKey = certInfo.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier;
1717
+ if (!wantedIssuerKey) {
1718
+ // Certificate has no extension 3 ! the certificate might have been generated by an old system
1719
+ debugLog("Certificate has no extension 3");
1720
+ return null;
1721
+ }
1722
+ const issuerCertificates = [...this.#thumbs.issuers.certs.values()];
1723
+ const selectedIssuerCertificates = findMatchingIssuerKey(issuerCertificates, wantedIssuerKey);
1724
+ if (selectedIssuerCertificates.length > 0) {
1725
+ if (selectedIssuerCertificates.length > 1) {
1726
+ warningLog("Warning more than one issuer certificate exists with subjectKeyIdentifier ", wantedIssuerKey);
1727
+ }
1728
+ return selectedIssuerCertificates[0].certificate || null;
1729
+ }
1730
+ // check also in trusted list
1731
+ const trustedCertificates = [...this.#thumbs.trusted.values()];
1732
+ const selectedTrustedCertificates = findMatchingIssuerKey(trustedCertificates, wantedIssuerKey);
1733
+ if (selectedTrustedCertificates.length > 1) {
1734
+ warningLog("Warning more than one certificate exists with subjectKeyIdentifier in trusted certificate list ", wantedIssuerKey, selectedTrustedCertificates.length);
1735
+ }
1736
+ return selectedTrustedCertificates.length > 0 ? selectedTrustedCertificates[0].certificate : null;
1737
+ }
1738
+ /**
1739
+ * Outcome status for {@link CertificateManager.completeCertificateChain}.
1740
+ */
1741
+ static ChainCompletionStatus = ChainCompletionStatus;
1742
+ /**
1743
+ * Complete a certificate chain by walking the issuer store.
1744
+ *
1745
+ * Starting from the last certificate in the provided chain, this method
1746
+ * repeatedly calls {@link findIssuerCertificate} to locate the parent
1747
+ * certificate until it reaches a self-signed root or can no longer find
1748
+ * an issuer.
1749
+ *
1750
+ * @param chain - the (potentially partial) certificate chain, leaf first
1751
+ * @param maxDepth - maximum number of issuers to append (default: 10)
1752
+ * @returns a {@link ChainCompletionResult} containing the (possibly completed)
1753
+ * chain, a status code, and an optional diagnostic message.
1754
+ */
1755
+ async completeCertificateChain(chain, maxDepth = 10) {
1756
+ if (chain.length === 0) {
1757
+ return {
1758
+ chain,
1759
+ status: ChainCompletionStatus.EmptyChain,
1760
+ message: "Input chain is empty — nothing to complete."
1761
+ };
1762
+ }
1763
+ // Re-scan the issuers folder to ensure we have the latest
1764
+ await this.#scanCertFolder(this.issuersCertFolder, this.#thumbs.issuers.certs);
1765
+ const result = [...chain];
1766
+ let depth = 0;
1767
+ while (depth < maxDepth) {
1768
+ const lastCert = result[result.length - 1];
1769
+ const lastInfo = exploreCertificate(lastCert);
1770
+ // Stop if the last certificate is self-signed (root)
1771
+ if (isSelfSigned2(lastInfo)) {
1772
+ const wasExtended = result.length > chain.length;
1773
+ return {
1774
+ chain: result,
1775
+ status: wasExtended ? ChainCompletionStatus.ChainCompleted : ChainCompletionStatus.AlreadyComplete,
1776
+ message: wasExtended
1777
+ ? `Chain completed: ${result.length - chain.length} issuer(s) appended, ending at self-signed root "${lastInfo.tbsCertificate.subject.commonName}".`
1778
+ : `Chain is already complete (self-signed root "${lastInfo.tbsCertificate.subject.commonName}").`
1779
+ };
1780
+ }
1781
+ const issuerCert = await this.findIssuerCertificate(lastCert);
1782
+ if (!issuerCert) {
1783
+ // Cannot find the issuer — chain remains partial
1784
+ const cn = lastInfo.tbsCertificate.subject.commonName ?? "?";
1785
+ const akid = lastInfo.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier ?? "?";
1786
+ const msg = `Cannot find issuer for "${cn}" ` +
1787
+ `(authorityKeyIdentifier: ${akid}). ` +
1788
+ `Ensure the CA certificate is present in the issuers/certs folder.`;
1789
+ warningLog(`completeCertificateChain: ${msg}`);
1790
+ return {
1791
+ chain: result,
1792
+ status: ChainCompletionStatus.IssuerNotFound,
1793
+ message: msg
1794
+ };
1795
+ }
1796
+ // Avoid loops: don't add the certificate if it's already in the chain
1797
+ const issuerFingerprint = makeFingerprint(issuerCert);
1798
+ const alreadyInChain = result.some((c) => makeFingerprint(c) === issuerFingerprint);
1799
+ if (alreadyInChain) {
1800
+ return {
1801
+ chain: result,
1802
+ status: ChainCompletionStatus.AlreadyComplete,
1803
+ message: `Chain ends at root "${exploreCertificate(issuerCert).tbsCertificate.subject.commonName}" (already present in chain).`
1804
+ };
1805
+ }
1806
+ result.push(issuerCert);
1807
+ depth++;
1808
+ }
1809
+ // maxDepth exceeded
1810
+ return {
1811
+ chain: result,
1812
+ status: ChainCompletionStatus.MaxDepthReached,
1813
+ message: `Chain completion stopped after ${maxDepth} iterations — possible circular chain or very deep hierarchy.`
1814
+ };
1815
+ }
1816
+ /**
1817
+ *
1818
+ * check if the certificate explicitly appear in the trust list, the reject list or none.
1819
+ * In case of being in the reject and trusted list at the same time is consider: rejected.
1820
+ * @internal
1821
+ * @private
1822
+ */
1823
+ async #checkRejectedOrTrusted(certificate) {
1824
+ const firstCertificate = coerceCertificateChain(certificate)[0];
1825
+ const fingerprint = makeFingerprint(firstCertificate);
1826
+ debugLog("#checkRejectedOrTrusted fingerprint ", short(fingerprint));
1827
+ await this.#readCertificates();
1828
+ if (this.#thumbs.rejected.has(fingerprint)) {
1829
+ return "rejected";
1830
+ }
1831
+ if (this.#thumbs.trusted.has(fingerprint)) {
1832
+ return "trusted";
1833
+ }
1834
+ return "unknown";
1835
+ }
1836
+ async #moveCertificate(certificateOrChain, newStatus) {
1837
+ await this.withLock2(async () => {
1838
+ const chain = coerceCertificateChain(certificateOrChain);
1839
+ const certificate = chain[0]; // leaf — used for indexing
1840
+ const fingerprint = makeFingerprint(certificate);
1841
+ let status = await this.#checkRejectedOrTrusted(certificate);
1842
+ if (status === "unknown") {
1843
+ // # unknown means rejected — write full chain to disk
1844
+ const pem = toPem(chain, "CERTIFICATE");
1845
+ const filename = safeStoreJoin(this.rejectedFolder, `${buildIdealCertificateName(certificate)}.pem`);
1846
+ await fs.promises.writeFile(filename, pem);
1847
+ this.#thumbs.rejected.set(fingerprint, { certificate, filename });
1848
+ status = "rejected";
1849
+ }
1850
+ debugLog("#moveCertificate", fingerprint.substring(0, 10), "from", status, "to", newStatus);
1851
+ if (status !== "rejected" && status !== "trusted") {
1852
+ throw new Error(`#moveCertificate: unexpected status '${status}' for certificate ${fingerprint.substring(0, 10)}`);
1853
+ }
1854
+ if (status !== newStatus) {
1855
+ const indexSrc = status === "rejected" ? this.#thumbs.rejected : this.#thumbs.trusted;
1856
+ const srcEntry = indexSrc.get(fingerprint);
1857
+ if (!srcEntry) {
1858
+ debugLog(" cannot find certificate ", fingerprint.substring(0, 10), " in", status);
1859
+ throw new Error(`#moveCertificate: certificate ${fingerprint.substring(0, 10)} not found in ${status} index`);
1860
+ }
1861
+ const destFolder = newStatus === "trusted" ? this.trustedFolder : this.rejectedFolder;
1862
+ const certificateDest = safeStoreJoin(destFolder, path.basename(srcEntry.filename));
1863
+ debugLog("#moveCertificate", fingerprint.substring(0, 10), "old name", srcEntry.filename);
1864
+ debugLog("#moveCertificate", fingerprint.substring(0, 10), "new name", certificateDest);
1865
+ await fs.promises.rename(srcEntry.filename, certificateDest);
1866
+ indexSrc.delete(fingerprint);
1867
+ const indexDest = newStatus === "trusted" ? this.#thumbs.trusted : this.#thumbs.rejected;
1868
+ indexDest.set(fingerprint, { certificate, filename: certificateDest });
1869
+ }
1870
+ });
1871
+ }
1872
+ #findAssociatedCRLs(issuerCertificate) {
1873
+ const issuerCertificateInfo = exploreCertificate(issuerCertificate);
1874
+ const key = issuerCertificateInfo.tbsCertificate.subjectFingerPrint;
1875
+ return this.#thumbs.issuersCrl.get(key) ?? this.#thumbs.crl.get(key) ?? null;
1876
+ }
1877
+ /**
1878
+ * Check whether a certificate has been revoked by its issuer's CRL.
1879
+ *
1880
+ * - Self-signed certificates are never considered revoked.
1881
+ * - If no `issuerCertificate` is provided, the method attempts
1882
+ * to find it via {@link findIssuerCertificate}.
1883
+ *
1884
+ * @param certificate - the DER-encoded certificate to check
1885
+ * @param issuerCertificate - optional issuer certificate; looked
1886
+ * up automatically when omitted
1887
+ * @returns `Good` if not revoked, `BadCertificateRevoked` if the
1888
+ * serial number appears in a CRL,
1889
+ * `BadCertificateRevocationUnknown` if no CRL is available,
1890
+ * or `BadCertificateChainIncomplete` if the issuer cannot be
1891
+ * found.
1892
+ */
1893
+ async isCertificateRevoked(certificate, issuerCertificate) {
1894
+ const chain = coerceCertificateChain(certificate);
1895
+ const firstCertificate = chain[0];
1896
+ if (isSelfSigned3(firstCertificate)) {
1897
+ return VerificationStatus.Good;
1898
+ }
1899
+ if (!issuerCertificate) {
1900
+ issuerCertificate = await this.findIssuerCertificate(firstCertificate);
1901
+ }
1902
+ if (!issuerCertificate) {
1903
+ issuerCertificate = findIssuerCertificateInChain(firstCertificate, chain);
1904
+ }
1905
+ if (!issuerCertificate) {
1906
+ return VerificationStatus.BadCertificateChainIncomplete;
1907
+ }
1908
+ const crls = this.#findAssociatedCRLs(issuerCertificate);
1909
+ if (!crls) {
1910
+ return VerificationStatus.BadCertificateRevocationUnknown;
1911
+ }
1912
+ const certInfo = exploreCertificate(firstCertificate);
1913
+ const serialNumber = certInfo.tbsCertificate.serialNumber || certInfo.tbsCertificate.extensions?.authorityKeyIdentifier?.serial || "";
1914
+ const key = certInfo.tbsCertificate.extensions?.authorityKeyIdentifier?.authorityCertIssuerFingerPrint || "<unknown>";
1915
+ const crl2 = this.#thumbs.crl.get(key) ?? null;
1916
+ if (crls.serialNumbers[serialNumber] || crl2?.serialNumbers[serialNumber]) {
1917
+ return VerificationStatus.BadCertificateRevoked;
1918
+ }
1919
+ return VerificationStatus.Good;
1920
+ }
1921
+ #pendingCrlToProcess = 0;
1922
+ #onCrlProcessWaiters = [];
1923
+ #queue = [];
1924
+ #onCrlFileAdded(index, filename) {
1925
+ this.#queue.push({ index, filename });
1926
+ this.#pendingCrlToProcess += 1;
1927
+ if (this.#pendingCrlToProcess === 1) {
1928
+ this.#processNextCrl();
1929
+ }
1930
+ }
1931
+ async #processNextCrl() {
1932
+ try {
1933
+ const nextCRL = this.#queue.shift();
1934
+ if (!nextCRL)
1935
+ return;
1936
+ const { index, filename } = nextCRL;
1937
+ const crl = await readCertificateRevocationList(filename);
1938
+ const crlInfo = exploreCertificateRevocationList(crl);
1939
+ debugLog(chalk.cyan("add CRL in folder "), filename);
1940
+ const fingerprint = crlInfo.tbsCertList.issuerFingerprint;
1941
+ if (!index.has(fingerprint)) {
1942
+ index.set(fingerprint, { crls: [], serialNumbers: {} });
1943
+ }
1944
+ const data = index.get(fingerprint) || { crls: [], serialNumbers: {} };
1945
+ data.crls.push({ crlInfo, filename });
1946
+ // now inject serial numbers
1947
+ for (const revokedCertificate of crlInfo.tbsCertList.revokedCertificates) {
1948
+ const serialNumber = revokedCertificate.userCertificate;
1949
+ if (!data.serialNumbers[serialNumber]) {
1950
+ data.serialNumbers[serialNumber] = revokedCertificate.revocationDate;
1951
+ }
1952
+ }
1953
+ debugLog(chalk.cyan("CRL"), fingerprint, "serial numbers = ", Object.keys(data.serialNumbers));
1954
+ }
1955
+ catch (err) {
1956
+ debugLog("CRL filename error =");
1957
+ debugLog(err);
1958
+ }
1959
+ this.#pendingCrlToProcess -= 1;
1960
+ if (this.#pendingCrlToProcess === 0) {
1961
+ for (const waiter of this.#onCrlProcessWaiters) {
1962
+ waiter();
1963
+ }
1964
+ this.#onCrlProcessWaiters.length = 0;
1965
+ }
1966
+ else {
1967
+ this.#processNextCrl();
1968
+ }
1969
+ }
1970
+ async #readCertificates() {
1971
+ if (this.#readCertificatesCalled) {
1972
+ return;
1973
+ }
1974
+ this.#readCertificatesCalled = true;
1975
+ // Chokidar configuration choices:
1976
+ //
1977
+ // usePolling: false (default)
1978
+ // Use native OS file-system events (inotify on Linux,
1979
+ // FSEvents on macOS, ReadDirectoryChangesW on Windows)
1980
+ // for near-real-time detection of cert/CRL additions
1981
+ // and removals. This is significantly faster than
1982
+ // polling (milliseconds vs seconds).
1983
+ //
1984
+ // Set OPCUA_PKI_USE_POLLING=true to revert to polling
1985
+ // for environments where native events are unreliable
1986
+ // (NFS, CIFS, Docker volumes, or other remote/virtual
1987
+ // file systems).
1988
+ //
1989
+ // persistent: false
1990
+ // Watchers do NOT keep the Node.js event loop alive.
1991
+ // This prevents the process from hanging if the
1992
+ // CertificateManager is not properly disposed. The
1993
+ // trade-off is that watchers stop receiving events if
1994
+ // there are no other active handles — acceptable since
1995
+ // CertificateManager always runs alongside a server.
1996
+ //
1997
+ // awaitWriteFinish: not set
1998
+ // Certificate and CRL files are small (typically < 5 KB)
1999
+ // and written atomically (fs.writeFile). No need to
2000
+ // wait for write stabilization, which would add a 2s+
2001
+ // delay before the in-memory index is updated.
2002
+ //
2003
+ const usePolling = process.env.OPCUA_PKI_USE_POLLING === "true";
2004
+ const envInterval = process.env.OPCUA_PKI_POLLING_INTERVAL
2005
+ ? parseInt(process.env.OPCUA_PKI_POLLING_INTERVAL, 10)
2006
+ : undefined;
2007
+ const pollingInterval = Math.min(10 * 60 * 1000, Math.max(100, envInterval ?? this.folderPollingInterval));
2008
+ // depth: 0
2009
+ // PKI store folders are flat — certificates and CRLs sit
2010
+ // directly in them. Without an explicit depth chokidar
2011
+ // recurses without bound (handler.js: `oDepth == null`)
2012
+ // and descends into any subdirectory that appears, which
2013
+ // routes through NodeFsHandler._handleRead ->
2014
+ // FSWatcher._throttle. That schedules *ref'd* 1s timers;
2015
+ // a directory churning under the watcher can spin
2016
+ // thousands of them and keep the event loop alive
2017
+ // indefinitely, so an undisposed CertificateManager stops
2018
+ // the process from exiting. Note this is a timer problem,
2019
+ // not a handle problem: the fs.watch handles themselves
2020
+ // come back correctly unref'd. Flat watching keeps
2021
+ // _handleRead off subdirectories entirely.
2022
+ //
2023
+ const chokidarOptions = {
2024
+ usePolling,
2025
+ ...(usePolling ? { interval: pollingInterval } : {}),
2026
+ depth: 0,
2027
+ persistent: false
2028
+ };
2029
+ // Workaround for two problems with persistent:false:
2030
+ //
2031
+ // 1. Chokidar does forward persistent:false into fs.watch
2032
+ // (handler.js, createFsWatchInstance) and on current
2033
+ // Node the resulting handles do come back unref'd —
2034
+ // but that was not enough in practice on Windows when
2035
+ // 0fbe111 was written, where undisposed instances
2036
+ // pinned the loop open. The explicit .unref() is kept
2037
+ // as the guarantee rather than trusting the platform.
2038
+ //
2039
+ // 2. Chokidar does not register an 'error' handler on
2040
+ // fs.watch when persistent:false (handler.js l.160-168).
2041
+ // On Windows + Node < 22, the native handle fires EPERM
2042
+ // when the watched directory is removed, which becomes
2043
+ // an uncaught exception that crashes the process.
2044
+ //
2045
+ // We install a single shared fs.watch() interception BEFORE
2046
+ // creating all 5 watchers. Every captured handle gets both
2047
+ // an error handler (fix #2) and is later .unref()'d (fix #1).
2048
+ //
2049
+ // The interception stays active until ALL watchers have
2050
+ // emitted "ready" — chokidar creates fs.watch handles
2051
+ // asynchronously during directory scanning, so we must keep
2052
+ // the interception alive until that completes.
2053
+ const allCapturedHandles = [];
2054
+ const origWatch = fs.watch;
2055
+ let watcherReadyCount = 0;
2056
+ const totalWatchers = 5;
2057
+ fs.watch = ((...args) => {
2058
+ const handle = origWatch.apply(fs, args);
2059
+ handle.setMaxListeners(handle.getMaxListeners() + 1);
2060
+ handle.on("error", () => {
2061
+ /* swallow – watched directory was removed */
2062
+ });
2063
+ allCapturedHandles.push(handle);
2064
+ return handle;
2065
+ });
2066
+ const createUnreffedWatcher = (folder) => {
2067
+ const startIdx = allCapturedHandles.length;
2068
+ const w = chokidar.watch(folder, chokidarOptions);
2069
+ const unreffAll = () => {
2070
+ // Unref only handles created for THIS watcher
2071
+ for (let i = startIdx; i < allCapturedHandles.length; i++) {
2072
+ allCapturedHandles[i].unref();
2073
+ }
2074
+ // Restore fs.watch once ALL watchers are ready
2075
+ watcherReadyCount++;
2076
+ if (watcherReadyCount >= totalWatchers) {
2077
+ fs.watch = origWatch;
2078
+ }
2079
+ };
2080
+ return { w, capturedHandles: allCapturedHandles.slice(startIdx), unreffAll };
2081
+ };
2082
+ // ── Phase 1: Async scan ─────────────────────────────────
2083
+ // Populate the in-memory indexes by reading existing
2084
+ // files. Uses async readdir/stat to yield the event loop
2085
+ // between files. All 5 folders are scanned in parallel.
2086
+ await Promise.all([
2087
+ this.#scanCertFolder(this.trustedFolder, this.#thumbs.trusted),
2088
+ this.#scanCertFolder(this.issuersCertFolder, this.#thumbs.issuers.certs),
2089
+ this.#scanCertFolder(this.rejectedFolder, this.#thumbs.rejected),
2090
+ this.#scanCrlFolder(this.crlFolder, this.#thumbs.crl),
2091
+ this.#scanCrlFolder(this.issuersCrlFolder, this.#thumbs.issuersCrl)
2092
+ ]);
2093
+ // ── Phase 2: Deferred file watchers ─────────────────────
2094
+ // Start chokidar watchers in the background. We do NOT
2095
+ // await "ready" so initialize() returns immediately after
2096
+ // the sync scan. Chokidar will re-discover existing files
2097
+ // (harmless Map overwrites) then watch for live changes.
2098
+ //
2099
+ // When disableFileWatchers is set, skip the watcher setup
2100
+ // entirely. The in-memory indexes are already populated
2101
+ // from the scan above. Also restore fs.watch immediately
2102
+ // since no watchers will be created.
2103
+ if (this.#disableFileWatchers) {
2104
+ fs.watch = origWatch;
2105
+ }
2106
+ else {
2107
+ this.#startWatcher(this.trustedFolder, this.#thumbs.trusted, createUnreffedWatcher, "trusted");
2108
+ this.#startWatcher(this.issuersCertFolder, this.#thumbs.issuers.certs, createUnreffedWatcher, "issuersCerts");
2109
+ this.#startWatcher(this.rejectedFolder, this.#thumbs.rejected, createUnreffedWatcher, "rejected");
2110
+ this.#startCrlWatcher(this.crlFolder, this.#thumbs.crl, createUnreffedWatcher, "crl");
2111
+ this.#startCrlWatcher(this.issuersCrlFolder, this.#thumbs.issuersCrl, createUnreffedWatcher, "issuersCrl");
2112
+ }
2113
+ }
2114
+ /**
2115
+ * Scan a certificate folder and populate the in-memory index.
2116
+ * Uses async readdir/stat to yield the event loop between
2117
+ * file reads, preventing main-loop stalls with large folders.
2118
+ */
2119
+ async #scanCertFolder(folder, index) {
2120
+ if (!fs.existsSync(folder))
2121
+ return;
2122
+ const files = await fs.promises.readdir(folder);
2123
+ for (const file of files) {
2124
+ const filename = path.join(folder, file);
2125
+ try {
2126
+ const stat = await fs.promises.stat(filename);
2127
+ if (!stat.isFile())
2128
+ continue;
2129
+ const certs = await readCertificateChainAsync(filename);
2130
+ if (certs.length === 0)
2131
+ continue;
2132
+ const certificate = certs[0];
2133
+ // Legacy migration: if the file contained multiple
2134
+ // certs (e.g. from old buggy toPem that wrapped a
2135
+ // concatenated DER in a single PEM block), re-write
2136
+ // with proper multi-block PEM and auto-register any
2137
+ // intermediate CA certs in the issuers store.
2138
+ // Best-effort: if the filesystem is read-only the
2139
+ // migration is skipped — the leaf is still indexed.
2140
+ if (certs.length > 1) {
2141
+ try {
2142
+ await fs.promises.writeFile(filename, toPem(certs, "CERTIFICATE"), "ascii");
2143
+ }
2144
+ catch (writeErr) {
2145
+ debugLog(`scanCertFolder: could not rewrite legacy PEM ${filename} (read-only fs?)`, writeErr);
2146
+ }
2147
+ for (let i = 1; i < certs.length; i++) {
2148
+ if (isIssuer(certs[i])) {
2149
+ try {
2150
+ await this.addIssuer(certs[i]);
2151
+ }
2152
+ catch (issuerErr) {
2153
+ debugLog(`scanCertFolder: could not auto-register issuer from ${filename}`, issuerErr);
2154
+ }
2155
+ }
2156
+ }
2157
+ }
2158
+ const info = exploreCertificate(certificate);
2159
+ const fingerprint = makeFingerprint(certificate);
2160
+ index.set(fingerprint, { certificate, filename, info });
2161
+ this.#filenameToHash.set(filename, fingerprint);
2162
+ }
2163
+ catch (err) {
2164
+ debugLog(`scanCertFolder: skipping ${filename}`, err);
2165
+ }
2166
+ }
2167
+ }
2168
+ /**
2169
+ * Scan a CRL folder and populate the in-memory CRL index.
2170
+ */
2171
+ async #scanCrlFolder(folder, index) {
2172
+ if (!fs.existsSync(folder))
2173
+ return;
2174
+ const files = await fs.promises.readdir(folder);
2175
+ for (const file of files) {
2176
+ const filename = path.join(folder, file);
2177
+ try {
2178
+ const stat = await fs.promises.stat(filename);
2179
+ if (!stat.isFile())
2180
+ continue;
2181
+ this.#onCrlFileAdded(index, filename);
2182
+ }
2183
+ catch (err) {
2184
+ debugLog(`scanCrlFolder: skipping ${filename}`, err);
2185
+ }
2186
+ }
2187
+ await this.#waitAndCheckCRLProcessingStatus();
2188
+ }
2189
+ /**
2190
+ * Start a chokidar watcher for a CRL folder.
2191
+ * Non-blocking — does NOT await "ready".
2192
+ */
2193
+ #startCrlWatcher(folder, index, createUnreffedWatcher, store) {
2194
+ const { w, unreffAll } = createUnreffedWatcher(folder);
2195
+ w.on("error", (err) => {
2196
+ debugLog(`chokidar CRL watcher error on ${folder}:`, err);
2197
+ });
2198
+ let ready = false;
2199
+ w.on("unlink", (filename) => {
2200
+ for (const [key, data] of index.entries()) {
2201
+ data.crls = data.crls.filter((c) => c.filename !== filename);
2202
+ if (data.crls.length === 0) {
2203
+ index.delete(key);
2204
+ }
2205
+ }
2206
+ if (ready) {
2207
+ this.emit("crlRemoved", { store, filename });
2208
+ }
2209
+ });
2210
+ w.on("add", (filename) => {
2211
+ if (ready) {
2212
+ this.#onCrlFileAdded(index, filename);
2213
+ this.emit("crlAdded", { store, filename });
2214
+ }
2215
+ });
2216
+ w.on("change", (changedPath) => {
2217
+ debugLog("change in folder ", folder, changedPath);
2218
+ });
2219
+ this.#watchers.push(w);
2220
+ this.#pendingUnrefs.add(unreffAll);
2221
+ w.on("ready", () => {
2222
+ ready = true;
2223
+ this.#pendingUnrefs.delete(unreffAll);
2224
+ unreffAll();
2225
+ });
2226
+ }
2227
+ /**
2228
+ * Start a chokidar watcher for a certificate folder.
2229
+ * Non-blocking — does NOT await "ready".
2230
+ */
2231
+ #startWatcher(folder, index, createUnreffedWatcher, store) {
2232
+ const { w, unreffAll } = createUnreffedWatcher(folder);
2233
+ w.on("error", (err) => {
2234
+ debugLog(`chokidar cert watcher error on ${folder}:`, err);
2235
+ });
2236
+ let ready = false;
2237
+ w.on("unlink", (filename) => {
2238
+ debugLog(chalk.cyan(`unlink in folder ${folder}`), filename);
2239
+ const h = this.#filenameToHash.get(filename);
2240
+ if (h && index.has(h)) {
2241
+ index.delete(h);
2242
+ this.emit("certificateRemoved", { store, fingerprint: h, filename });
2243
+ }
2244
+ });
2245
+ w.on("add", (filename) => {
2246
+ debugLog(chalk.cyan(`add in folder ${folder}`), filename);
2247
+ try {
2248
+ const certificate = readCertificateChain(filename)[0];
2249
+ const info = exploreCertificate(certificate);
2250
+ const fingerprint = makeFingerprint(certificate);
2251
+ const isNew = !index.has(fingerprint);
2252
+ index.set(fingerprint, { certificate, filename, info });
2253
+ this.#filenameToHash.set(filename, fingerprint);
2254
+ debugLog(chalk.magenta("CERT"), info.tbsCertificate.subjectFingerPrint, info.tbsCertificate.serialNumber, info.tbsCertificate.extensions?.authorityKeyIdentifier?.authorityCertIssuerFingerPrint);
2255
+ if (ready || isNew) {
2256
+ this.emit("certificateAdded", { store, certificate, fingerprint, filename });
2257
+ }
2258
+ }
2259
+ catch (err) {
2260
+ debugLog(`Walk files in folder ${folder} with file ${filename}`);
2261
+ debugLog(err);
2262
+ }
2263
+ });
2264
+ w.on("change", (changedPath) => {
2265
+ debugLog(chalk.cyan(`change in folder ${folder}`), changedPath);
2266
+ try {
2267
+ const certificate = readCertificateChain(changedPath)[0];
2268
+ const newFingerprint = makeFingerprint(certificate);
2269
+ const oldHash = this.#filenameToHash.get(changedPath);
2270
+ if (oldHash && oldHash !== newFingerprint) {
2271
+ index.delete(oldHash);
2272
+ }
2273
+ index.set(newFingerprint, { certificate, filename: changedPath, info: exploreCertificate(certificate) });
2274
+ this.#filenameToHash.set(changedPath, newFingerprint);
2275
+ this.emit("certificateChange", { store, certificate, fingerprint: newFingerprint, filename: changedPath });
2276
+ }
2277
+ catch (err) {
2278
+ debugLog(`change event: failed to re-read ${changedPath}`, err);
2279
+ }
2280
+ });
2281
+ this.#watchers.push(w);
2282
+ this.#pendingUnrefs.add(unreffAll);
2283
+ w.on("ready", () => {
2284
+ ready = true;
2285
+ this.#pendingUnrefs.delete(unreffAll);
2286
+ unreffAll();
2287
+ debugLog("ready");
2288
+ debugLog([...index.keys()].map((k) => k.substring(0, 10)));
2289
+ });
2290
+ }
2291
+ // make sure that all crls have been processed.
2292
+ async #waitAndCheckCRLProcessingStatus() {
2293
+ return new Promise((resolve, _reject) => {
2294
+ if (this.#pendingCrlToProcess === 0) {
2295
+ setImmediate(resolve);
2296
+ return;
2297
+ }
2298
+ this.#onCrlProcessWaiters.push(resolve);
2299
+ });
2300
+ }
2301
+ }
2302
+ //# sourceMappingURL=certificate_manager.js.map