node-opcua-pki 6.12.1 → 6.13.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.
package/dist/bin/pki.mjs CHANGED
@@ -384,9 +384,10 @@ function short(stringToShorten) {
384
384
  return stringToShorten.substring(0, 10);
385
385
  }
386
386
  function buildIdealCertificateName(certificate) {
387
- const fingerprint2 = makeFingerprint(certificate);
387
+ const chain = coerceCertificateChain(certificate);
388
+ const fingerprint2 = makeFingerprint(chain);
388
389
  try {
389
- const commonName = exploreCertificate(certificate).tbsCertificate.subject.commonName || "";
390
+ const commonName = exploreCertificate(chain[0]).tbsCertificate.subject.commonName || "";
390
391
  const sanitizedCommonName = commonName.replace(forbiddenChars, "_");
391
392
  return `${sanitizedCommonName}[${fingerprint2}]`;
392
393
  } catch (_err) {
@@ -454,7 +455,7 @@ function findIssuerCertificateInChain(certificate, chain) {
454
455
  }
455
456
  return null;
456
457
  }
457
- var configurationFileSimpleTemplate, fsWriteFile, forbiddenChars, CertificateManager;
458
+ var configurationFileSimpleTemplate, fsWriteFile, forbiddenChars, ChainCompletionStatus, CertificateManager;
458
459
  var init_certificate_manager = __esm({
459
460
  "packages/node-opcua-pki/lib/pki/certificate_manager.ts"() {
460
461
  "use strict";
@@ -466,6 +467,14 @@ var init_certificate_manager = __esm({
466
467
  configurationFileSimpleTemplate = simple_config_template_cnf_default;
467
468
  fsWriteFile = fs4.promises.writeFile;
468
469
  forbiddenChars = /[\x00-\x1F<>:"/\\|?*]/g;
470
+ ChainCompletionStatus = /* @__PURE__ */ ((ChainCompletionStatus2) => {
471
+ ChainCompletionStatus2["AlreadyComplete"] = "AlreadyComplete";
472
+ ChainCompletionStatus2["ChainCompleted"] = "ChainCompleted";
473
+ ChainCompletionStatus2["IssuerNotFound"] = "IssuerNotFound";
474
+ ChainCompletionStatus2["EmptyChain"] = "EmptyChain";
475
+ ChainCompletionStatus2["MaxDepthReached"] = "MaxDepthReached";
476
+ return ChainCompletionStatus2;
477
+ })(ChainCompletionStatus || {});
469
478
  CertificateManager = class _CertificateManager extends EventEmitter {
470
479
  // ── Global instance registry ─────────────────────────────────
471
480
  // Tracks all initialized CertificateManager instances so their
@@ -558,6 +567,7 @@ var init_certificate_manager = __esm({
558
567
  #filenameToHash = /* @__PURE__ */ new Map();
559
568
  #initializingPromise;
560
569
  #addCertValidation;
570
+ #disableFileWatchers;
561
571
  #thumbs = {
562
572
  rejected: /* @__PURE__ */ new Map(),
563
573
  trusted: /* @__PURE__ */ new Map(),
@@ -591,6 +601,7 @@ var init_certificate_manager = __esm({
591
601
  ignoreMissingRevocationList: v.ignoreMissingRevocationList ?? false,
592
602
  maxChainLength: v.maxChainLength ?? 5
593
603
  };
604
+ this.#disableFileWatchers = options.disableFileWatchers ?? process.env.OPCUA_PKI_DISABLE_FILE_WATCHERS === "true";
594
605
  mkdirRecursiveSync(options.location);
595
606
  if (!fs4.existsSync(this.#location)) {
596
607
  throw new Error(`CertificateManager cannot access location ${this.#location}`);
@@ -615,18 +626,18 @@ var init_certificate_manager = __esm({
615
626
  /**
616
627
  * Move a certificate to the rejected store.
617
628
  * If the certificate was previously trusted, it will be removed from the trusted folder.
618
- * @param certificate - the DER-encoded certificate
629
+ * @param certificateOrChain - the DER-encoded certificate or certificate chain
619
630
  */
620
- async rejectCertificate(certificate) {
621
- await this.#moveCertificate(certificate, "rejected");
631
+ async rejectCertificate(certificateOrChain) {
632
+ await this.#moveCertificate(certificateOrChain, "rejected");
622
633
  }
623
634
  /**
624
635
  * Move a certificate to the trusted store.
625
636
  * If the certificate was previously rejected, it will be removed from the rejected folder.
626
- * @param certificate - the DER-encoded certificate
637
+ * @param certificateOrChain - the DER-encoded certificate or certificate chain
627
638
  */
628
- async trustCertificate(certificate) {
629
- await this.#moveCertificate(certificate, "trusted");
639
+ async trustCertificate(certificateOrChain) {
640
+ await this.#moveCertificate(certificateOrChain, "trusted");
630
641
  }
631
642
  /**
632
643
  * Check whether the trusted certificate store is empty.
@@ -680,31 +691,40 @@ var init_certificate_manager = __esm({
680
691
  * @returns `"Good"` if trusted, `"BadCertificateUntrusted"` if rejected/unknown,
681
692
  * or `"BadCertificateInvalid"` if the certificate cannot be parsed.
682
693
  */
683
- async isCertificateTrusted(certificate) {
684
- let fingerprint2;
694
+ async isCertificateTrusted(certificateOrCertificateChain) {
685
695
  try {
686
- fingerprint2 = makeFingerprint(certificate);
687
- } catch (_err) {
688
- return "BadCertificateInvalid";
689
- }
690
- if (this.#thumbs.trusted.has(fingerprint2)) {
691
- return "Good";
692
- }
693
- if (!this.#thumbs.rejected.has(fingerprint2)) {
694
- if (!this.untrustUnknownCertificate) {
695
- return "Good";
696
+ const chain = coerceCertificateChain(certificateOrCertificateChain);
697
+ const leafCertificate = chain[0];
698
+ if (chain.length < 1) {
699
+ return "BadCertificateInvalid";
696
700
  }
701
+ let fingerprint2;
697
702
  try {
698
- exploreCertificateInfo(certificate);
703
+ fingerprint2 = makeFingerprint(chain[0]);
699
704
  } catch (_err) {
700
705
  return "BadCertificateInvalid";
701
706
  }
702
- const filename = path2.join(this.rejectedFolder, `${buildIdealCertificateName(certificate)}.pem`);
703
- debugLog("certificate has never been seen before and is now rejected (untrusted) ", filename);
704
- await fsWriteFile(filename, toPem(certificate, "CERTIFICATE"));
705
- this.#thumbs.rejected.set(fingerprint2, { certificate, filename });
707
+ if (this.#thumbs.trusted.has(fingerprint2)) {
708
+ return "Good";
709
+ }
710
+ if (!this.#thumbs.rejected.has(fingerprint2)) {
711
+ if (!this.untrustUnknownCertificate) {
712
+ return "Good";
713
+ }
714
+ try {
715
+ exploreCertificateInfo(chain[0]);
716
+ } catch (_err) {
717
+ return "BadCertificateInvalid";
718
+ }
719
+ const filename = path2.join(this.rejectedFolder, `${buildIdealCertificateName(leafCertificate)}.pem`);
720
+ debugLog("certificate has never been seen before and is now rejected (untrusted) ", filename);
721
+ await fsWriteFile(filename, toPem(chain, "CERTIFICATE"));
722
+ this.#thumbs.rejected.set(fingerprint2, { certificate: leafCertificate, filename });
723
+ }
724
+ return "BadCertificateUntrusted";
725
+ } catch (_err) {
726
+ return "BadCertificateInvalid";
706
727
  }
707
- return "BadCertificateUntrusted";
708
728
  }
709
729
  async #innerVerifyCertificateAsync(certificateOrChain, _isIssuer, level, options) {
710
730
  if (level >= 5) {
@@ -1374,7 +1394,7 @@ var init_certificate_manager = __esm({
1374
1394
  return "BadCertificateInvalid" /* BadCertificateInvalid */;
1375
1395
  }
1376
1396
  }
1377
- await this.trustCertificate(leafCertificate);
1397
+ await this.trustCertificate(certificates);
1378
1398
  return "Good" /* Good */;
1379
1399
  }
1380
1400
  /**
@@ -1446,6 +1466,75 @@ var init_certificate_manager = __esm({
1446
1466
  }
1447
1467
  return selectedTrustedCertificates.length > 0 ? selectedTrustedCertificates[0].certificate : null;
1448
1468
  }
1469
+ /**
1470
+ * Outcome status for {@link CertificateManager.completeCertificateChain}.
1471
+ */
1472
+ static ChainCompletionStatus = ChainCompletionStatus;
1473
+ /**
1474
+ * Complete a certificate chain by walking the issuer store.
1475
+ *
1476
+ * Starting from the last certificate in the provided chain, this method
1477
+ * repeatedly calls {@link findIssuerCertificate} to locate the parent
1478
+ * certificate until it reaches a self-signed root or can no longer find
1479
+ * an issuer.
1480
+ *
1481
+ * @param chain - the (potentially partial) certificate chain, leaf first
1482
+ * @param maxDepth - maximum number of issuers to append (default: 10)
1483
+ * @returns a {@link ChainCompletionResult} containing the (possibly completed)
1484
+ * chain, a status code, and an optional diagnostic message.
1485
+ */
1486
+ async completeCertificateChain(chain, maxDepth = 10) {
1487
+ if (chain.length === 0) {
1488
+ return {
1489
+ chain,
1490
+ status: "EmptyChain" /* EmptyChain */,
1491
+ message: "Input chain is empty \u2014 nothing to complete."
1492
+ };
1493
+ }
1494
+ await this.#scanCertFolder(this.issuersCertFolder, this.#thumbs.issuers.certs);
1495
+ const result = [...chain];
1496
+ let depth = 0;
1497
+ while (depth < maxDepth) {
1498
+ const lastCert = result[result.length - 1];
1499
+ const lastInfo = exploreCertificate(lastCert);
1500
+ if (isSelfSigned2(lastInfo)) {
1501
+ const wasExtended = result.length > chain.length;
1502
+ return {
1503
+ chain: result,
1504
+ status: wasExtended ? "ChainCompleted" /* ChainCompleted */ : "AlreadyComplete" /* AlreadyComplete */,
1505
+ message: wasExtended ? `Chain completed: ${result.length - chain.length} issuer(s) appended, ending at self-signed root "${lastInfo.tbsCertificate.subject.commonName}".` : `Chain is already complete (self-signed root "${lastInfo.tbsCertificate.subject.commonName}").`
1506
+ };
1507
+ }
1508
+ const issuerCert = await this.findIssuerCertificate(lastCert);
1509
+ if (!issuerCert) {
1510
+ const cn = lastInfo.tbsCertificate.subject.commonName ?? "?";
1511
+ const akid = lastInfo.tbsCertificate.extensions?.authorityKeyIdentifier?.keyIdentifier ?? "?";
1512
+ const msg = `Cannot find issuer for "${cn}" (authorityKeyIdentifier: ${akid}). Ensure the CA certificate is present in the issuers/certs folder.`;
1513
+ warningLog(`completeCertificateChain: ${msg}`);
1514
+ return {
1515
+ chain: result,
1516
+ status: "IssuerNotFound" /* IssuerNotFound */,
1517
+ message: msg
1518
+ };
1519
+ }
1520
+ const issuerFingerprint = makeFingerprint(issuerCert);
1521
+ const alreadyInChain = result.some((c) => makeFingerprint(c) === issuerFingerprint);
1522
+ if (alreadyInChain) {
1523
+ return {
1524
+ chain: result,
1525
+ status: "AlreadyComplete" /* AlreadyComplete */,
1526
+ message: `Chain ends at root "${exploreCertificate(issuerCert).tbsCertificate.subject.commonName}" (already present in chain).`
1527
+ };
1528
+ }
1529
+ result.push(issuerCert);
1530
+ depth++;
1531
+ }
1532
+ return {
1533
+ chain: result,
1534
+ status: "MaxDepthReached" /* MaxDepthReached */,
1535
+ message: `Chain completion stopped after ${maxDepth} iterations \u2014 possible circular chain or very deep hierarchy.`
1536
+ };
1537
+ }
1449
1538
  /**
1450
1539
  *
1451
1540
  * check if the certificate explicitly appear in the trust list, the reject list or none.
@@ -1466,12 +1555,14 @@ var init_certificate_manager = __esm({
1466
1555
  }
1467
1556
  return "unknown";
1468
1557
  }
1469
- async #moveCertificate(certificate, newStatus) {
1558
+ async #moveCertificate(certificateOrChain, newStatus) {
1470
1559
  await this.withLock2(async () => {
1560
+ const chain = coerceCertificateChain(certificateOrChain);
1561
+ const certificate = chain[0];
1471
1562
  const fingerprint2 = makeFingerprint(certificate);
1472
1563
  let status = await this.#checkRejectedOrTrusted(certificate);
1473
1564
  if (status === "unknown") {
1474
- const pem = toPem(certificate, "CERTIFICATE");
1565
+ const pem = toPem(chain, "CERTIFICATE");
1475
1566
  const filename = path2.join(this.rejectedFolder, `${buildIdealCertificateName(certificate)}.pem`);
1476
1567
  await fs4.promises.writeFile(filename, pem);
1477
1568
  this.#thumbs.rejected.set(fingerprint2, { certificate, filename });
@@ -1639,11 +1730,15 @@ var init_certificate_manager = __esm({
1639
1730
  this.#scanCrlFolder(this.crlFolder, this.#thumbs.crl),
1640
1731
  this.#scanCrlFolder(this.issuersCrlFolder, this.#thumbs.issuersCrl)
1641
1732
  ]);
1642
- this.#startWatcher(this.trustedFolder, this.#thumbs.trusted, createUnreffedWatcher, "trusted");
1643
- this.#startWatcher(this.issuersCertFolder, this.#thumbs.issuers.certs, createUnreffedWatcher, "issuersCerts");
1644
- this.#startWatcher(this.rejectedFolder, this.#thumbs.rejected, createUnreffedWatcher, "rejected");
1645
- this.#startCrlWatcher(this.crlFolder, this.#thumbs.crl, createUnreffedWatcher, "crl");
1646
- this.#startCrlWatcher(this.issuersCrlFolder, this.#thumbs.issuersCrl, createUnreffedWatcher, "issuersCrl");
1733
+ if (this.#disableFileWatchers) {
1734
+ fs4.watch = origWatch;
1735
+ } else {
1736
+ this.#startWatcher(this.trustedFolder, this.#thumbs.trusted, createUnreffedWatcher, "trusted");
1737
+ this.#startWatcher(this.issuersCertFolder, this.#thumbs.issuers.certs, createUnreffedWatcher, "issuersCerts");
1738
+ this.#startWatcher(this.rejectedFolder, this.#thumbs.rejected, createUnreffedWatcher, "rejected");
1739
+ this.#startCrlWatcher(this.crlFolder, this.#thumbs.crl, createUnreffedWatcher, "crl");
1740
+ this.#startCrlWatcher(this.issuersCrlFolder, this.#thumbs.issuersCrl, createUnreffedWatcher, "issuersCrl");
1741
+ }
1647
1742
  }
1648
1743
  /**
1649
1744
  * Scan a certificate folder and populate the in-memory index.
@@ -1658,7 +1753,25 @@ var init_certificate_manager = __esm({
1658
1753
  try {
1659
1754
  const stat = await fs4.promises.stat(filename);
1660
1755
  if (!stat.isFile()) continue;
1661
- const certificate = (await readCertificateChainAsync(filename))[0];
1756
+ const certs = await readCertificateChainAsync(filename);
1757
+ if (certs.length === 0) continue;
1758
+ const certificate = certs[0];
1759
+ if (certs.length > 1) {
1760
+ try {
1761
+ await fs4.promises.writeFile(filename, toPem(certs, "CERTIFICATE"), "ascii");
1762
+ } catch (writeErr) {
1763
+ debugLog(`scanCertFolder: could not rewrite legacy PEM ${filename} (read-only fs?)`, writeErr);
1764
+ }
1765
+ for (let i = 1; i < certs.length; i++) {
1766
+ if (isIssuer(certs[i])) {
1767
+ try {
1768
+ await this.addIssuer(certs[i]);
1769
+ } catch (issuerErr) {
1770
+ debugLog(`scanCertFolder: could not auto-register issuer from ${filename}`, issuerErr);
1771
+ }
1772
+ }
1773
+ }
1774
+ }
1662
1775
  const info = exploreCertificate(certificate);
1663
1776
  const fingerprint2 = makeFingerprint(certificate);
1664
1777
  index.set(fingerprint2, { certificate, filename, info });