node-opcua-pki 6.21.0 → 6.22.1

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
@@ -93,6 +93,9 @@ var init_hostname = __esm({
93
93
 
94
94
  // packages/node-opcua-pki/lib/toolbox/common.ts
95
95
  import assert2 from "assert";
96
+ function isOpaqueSigner(privateKey) {
97
+ return typeof privateKey !== "string" && typeof privateKey.sign === "function";
98
+ }
96
99
  async function resolvePrivateKeyPassphrase(passphrase) {
97
100
  if (passphrase === void 0) {
98
101
  return void 0;
@@ -276,8 +279,9 @@ async function createCertificateSigningRequestAsync(certificateSigningRequestFil
276
279
  assert4(typeof certificateSigningRequestFilename === "string");
277
280
  const subject = params.subject ? new Subject(params.subject).toString() : void 0;
278
281
  displaySubtitle("- Creating a Certificate Signing Request with subtile");
279
- const privateKeyPem = typeof params.privateKey === "string" ? await fs2.promises.readFile(params.privateKey, "utf-8") : coercePrivateKeyPem(params.privateKey);
280
- const privateKey = await pemToPrivateKey(privateKeyPem);
282
+ const privateKey = isOpaqueSigner(params.privateKey) ? params.privateKey : await pemToPrivateKey(
283
+ typeof params.privateKey === "string" ? await fs2.promises.readFile(params.privateKey, "utf-8") : coercePrivateKeyPem(params.privateKey)
284
+ );
281
285
  const { csr } = await createCertificateSigningRequest({
282
286
  privateKey,
283
287
  dns: params.dns,
@@ -287,13 +291,16 @@ async function createCertificateSigningRequestAsync(certificateSigningRequestFil
287
291
  purpose: params.purpose
288
292
  });
289
293
  await fs2.promises.writeFile(certificateSigningRequestFilename, csr, "utf-8");
290
- display(`- privateKey ${typeof params.privateKey === "string" ? params.privateKey : "<in-memory>"}`);
294
+ display(
295
+ `- privateKey ${typeof params.privateKey === "string" ? params.privateKey : isOpaqueSigner(params.privateKey) ? "<opaque-signer>" : "<in-memory>"}`
296
+ );
291
297
  display(`- certificateSigningRequestFilename ${certificateSigningRequestFilename}`);
292
298
  }
293
299
  var init_create_certificate_signing_request = __esm({
294
300
  "packages/node-opcua-pki/lib/toolbox/without_openssl/create_certificate_signing_request.ts"() {
295
301
  "use strict";
296
302
  init_esm_shims();
303
+ init_common();
297
304
  init_display();
298
305
  }
299
306
  });
@@ -327,8 +334,9 @@ async function createSelfSignedCertificateAsync(certificate, params) {
327
334
  subject = subject.toString();
328
335
  const purpose = params.purpose;
329
336
  displayTitle("Generate a certificate request");
330
- const privateKeyPem = typeof params.privateKey === "string" ? await fs3.promises.readFile(params.privateKey, "utf-8") : coercePrivateKeyPem2(params.privateKey);
331
- const privateKey = await pemToPrivateKey2(privateKeyPem);
337
+ const privateKey = isOpaqueSigner(params.privateKey) ? params.privateKey : await pemToPrivateKey2(
338
+ typeof params.privateKey === "string" ? await fs3.promises.readFile(params.privateKey, "utf-8") : coercePrivateKeyPem2(params.privateKey)
339
+ );
332
340
  const { cert } = await createSelfSignedCertificate1({
333
341
  privateKey,
334
342
  notBefore: params.startDate,
@@ -383,11 +391,14 @@ import { drainPendingLocks, withLock } from "@ster5/global-mutex";
383
391
  import chalk3 from "chalk";
384
392
  import chokidar from "chokidar";
385
393
  import {
394
+ caSignerFromKeyOperations,
386
395
  exploreCertificate,
387
396
  exploreCertificateInfo,
388
397
  exploreCertificateRevocationList,
389
398
  generatePrivateKeyFile,
399
+ keyOperationsFromPrivateKey,
390
400
  makeSHA1Thumbprint,
401
+ PrivateKeyUnavailableError,
391
402
  readCertificateChain,
392
403
  readCertificateChainAsync,
393
404
  readCertificateRevocationList,
@@ -419,17 +430,30 @@ function makeFingerprint(certificate) {
419
430
  function short(stringToShorten) {
420
431
  return stringToShorten.substring(0, 10);
421
432
  }
433
+ function toFilenameLabel(commonName) {
434
+ const label = (commonName || "").normalize("NFKC").replace(disallowedInLabel, "_").replace(/[^\x20-\x7E]/g, "").slice(0, 64).trim();
435
+ return label || "certificate";
436
+ }
422
437
  function buildIdealCertificateName(certificate) {
423
438
  const chain = coerceCertificateChain(certificate);
424
439
  const fingerprint2 = makeFingerprint(chain);
425
440
  try {
426
441
  const commonName = exploreCertificate(chain[0]).tbsCertificate.subject.commonName || "";
427
- const sanitizedCommonName = commonName.replace(forbiddenChars, "_");
442
+ const sanitizedCommonName = toFilenameLabel(commonName);
428
443
  return `${sanitizedCommonName}[${fingerprint2}]`;
429
444
  } catch (_err) {
430
445
  return `invalid_certificate_[${fingerprint2}]`;
431
446
  }
432
447
  }
448
+ function safeStoreJoin(folder, filename) {
449
+ const full = path2.resolve(folder, filename);
450
+ const base = path2.resolve(folder);
451
+ if (full !== base && !full.startsWith(base + path2.sep)) {
452
+ warningLog("refusing to write certificate outside its store folder:", filename);
453
+ throw new Error(`certificate filename escapes its store folder: ${filename}`);
454
+ }
455
+ return full;
456
+ }
433
457
  function findMatchingIssuerKey(entries, wantedIssuerKey) {
434
458
  return entries.filter((entry) => {
435
459
  const info = getOrComputeInfo(entry);
@@ -491,7 +515,7 @@ function findIssuerCertificateInChain(certificate, chain) {
491
515
  }
492
516
  return null;
493
517
  }
494
- var configurationFileSimpleTemplate, fsWriteFile, forbiddenChars, ChainCompletionStatus, CertificateManager;
518
+ var configurationFileSimpleTemplate, fsWriteFile, disallowedInLabel, ChainCompletionStatus, CertificateManager;
495
519
  var init_certificate_manager = __esm({
496
520
  "packages/node-opcua-pki/lib/pki/certificate_manager.ts"() {
497
521
  "use strict";
@@ -503,7 +527,7 @@ var init_certificate_manager = __esm({
503
527
  init_simple_config_template_cnf();
504
528
  configurationFileSimpleTemplate = simple_config_template_cnf_default;
505
529
  fsWriteFile = fs4.promises.writeFile;
506
- forbiddenChars = /[\x00-\x1F<>:"/\\|?*]/g;
530
+ disallowedInLabel = /[^A-Za-z0-9._ -]/g;
507
531
  ChainCompletionStatus = /* @__PURE__ */ ((ChainCompletionStatus2) => {
508
532
  ChainCompletionStatus2["AlreadyComplete"] = "AlreadyComplete";
509
533
  ChainCompletionStatus2["ChainCompleted"] = "ChainCompleted";
@@ -640,6 +664,9 @@ var init_certificate_manager = __esm({
640
664
  #disableFileWatchers;
641
665
  #privateKeyPassphrase;
642
666
  #privateKeyProvider;
667
+ #keyOperations;
668
+ /** The stable lazy wrap handed out by {@link getKeyOperations} for non-opaque configurations. */
669
+ #lazyLocalKeyOperations;
643
670
  /**
644
671
  * The on-disk key, decrypted once and kept for the instance's lifetime,
645
672
  * so the passphrase (or its resolver function) is consulted at most
@@ -683,6 +710,17 @@ var init_certificate_manager = __esm({
683
710
  this.#disableFileWatchers = options.disableFileWatchers ?? process.env.OPCUA_PKI_DISABLE_FILE_WATCHERS === "true";
684
711
  this.#privateKeyPassphrase = options.privateKeyPassphrase;
685
712
  this.#privateKeyProvider = options.privateKeyProvider;
713
+ this.#keyOperations = options.keyOperations;
714
+ if (this.#keyOperations && this.#privateKeyProvider) {
715
+ throw new Error(
716
+ "CertificateManager: 'keyOperations' and 'privateKeyProvider' are mutually exclusive: one hides the key, the other sources its material"
717
+ );
718
+ }
719
+ if (this.#keyOperations && this.#privateKeyPassphrase) {
720
+ throw new Error(
721
+ "CertificateManager: 'privateKeyPassphrase' is meaningless with 'keyOperations': there is no key material for a passphrase to protect"
722
+ );
723
+ }
686
724
  mkdirRecursiveSync(options.location);
687
725
  if (!fs4.existsSync(this.#location)) {
688
726
  throw new Error(`CertificateManager cannot access location ${this.#location}`);
@@ -724,6 +762,11 @@ var init_certificate_manager = __esm({
724
762
  * authority on what the current key is.
725
763
  */
726
764
  async getPrivateKey() {
765
+ if (this.#keyOperations) {
766
+ throw new PrivateKeyUnavailableError(
767
+ "CertificateManager.getPrivateKey is not available when keyOperations is configured: the private key is held by the key-operations provider (HSM/KMS) and cannot be read \u2014 use getKeyOperations() instead"
768
+ );
769
+ }
727
770
  if (this.#privateKeyProvider) {
728
771
  return await this.#privateKeyProvider.getPrivateKey();
729
772
  }
@@ -749,6 +792,75 @@ var init_certificate_manager = __esm({
749
792
  }
750
793
  /** In-flight first read of the on-disk key, so concurrent callers share one passphrase resolution. */
751
794
  #privateKeyPromise;
795
+ /**
796
+ * True when this manager's key is opaque — configured through
797
+ * `keyOperations`, held by an HSM/KMS, never obtainable as material.
798
+ * When true, {@link getPrivateKey} throws `PrivateKeyUnavailableError`
799
+ * and {@link getKeyOperations} is the only way to use the key.
800
+ */
801
+ isPrivateKeyOpaque() {
802
+ return !!this.#keyOperations;
803
+ }
804
+ /**
805
+ * The key as an opaque {@link IKeyOperations} — the recommended way to
806
+ * *use* the private key regardless of where it lives.
807
+ *
808
+ * Returns the configured `keyOperations` object when the key is opaque.
809
+ * Otherwise returns a stable lazy wrap over {@link getPrivateKey}: its
810
+ * methods resolve the key on first use (disk read, passphrase,
811
+ * `privateKeyProvider` — all async), so the wrap offers no synchronous
812
+ * fast path; callers that need one resolve the key themselves and build
813
+ * a `LocalKeyOperations` over it. The wrap follows key rotation: a
814
+ * `privateKeyProvider` that starts returning a different key gets a
815
+ * fresh underlying `LocalKeyOperations`.
816
+ */
817
+ getKeyOperations() {
818
+ if (this.#keyOperations) {
819
+ return this.#keyOperations;
820
+ }
821
+ if (!this.#lazyLocalKeyOperations) {
822
+ let cached;
823
+ const resolve = async () => {
824
+ const key = await this.getPrivateKey();
825
+ if (!cached || cached.key !== key) {
826
+ cached = { key, ops: keyOperationsFromPrivateKey(key) };
827
+ }
828
+ return cached.ops;
829
+ };
830
+ this.#lazyLocalKeyOperations = {
831
+ sign: async (data, params) => (await resolve()).sign(data, params),
832
+ decryptBlock: async (block, params) => (await resolve()).decryptBlock(block, params),
833
+ getKeyMetadata: async () => (await resolve()).getKeyMetadata(),
834
+ getPublicKey: async () => (await resolve()).getPublicKey()
835
+ };
836
+ }
837
+ return this.#lazyLocalKeyOperations;
838
+ }
839
+ /**
840
+ * Fail closed at `initialize()` time, not on the first certificate
841
+ * operation, if the key cannot actually be used: wrong/missing
842
+ * passphrase on an encrypted key, a broken `privateKeyProvider`, or an
843
+ * unreachable `keyOperations` provider (probed via `getKeyMetadata`).
844
+ */
845
+ async #probePrivateKey() {
846
+ if (this.#keyOperations) {
847
+ await this.#keyOperations.getKeyMetadata();
848
+ return;
849
+ }
850
+ await this.getPrivateKey();
851
+ }
852
+ /**
853
+ * The key to hand to a certificate-issuance primitive: the raw
854
+ * {@link PrivateKey} for a local configuration, or a {@link CaSigner}
855
+ * adapted from `keyOperations` when the key is opaque — which requires
856
+ * the provider to implement `getPublicKey` (the adapter says so if not).
857
+ */
858
+ async #resolveSigningKey() {
859
+ if (this.#keyOperations) {
860
+ return caSignerFromKeyOperations(this.#keyOperations);
861
+ }
862
+ return await this.getPrivateKey();
863
+ }
752
864
  /**
753
865
  * Enable, disable, or rotate the passphrase protecting the on-disk
754
866
  * private key: decrypt with `oldPassphrase` (omit if the key is
@@ -770,6 +882,11 @@ var init_certificate_manager = __esm({
770
882
  * disk file for this method to rewrite).
771
883
  */
772
884
  async reencryptPrivateKey(oldPassphrase, newPassphrase) {
885
+ if (this.#keyOperations) {
886
+ throw new PrivateKeyUnavailableError(
887
+ "reencryptPrivateKey: not supported when keyOperations is configured \u2014 there is no key material to rewrite"
888
+ );
889
+ }
773
890
  if (this.#privateKeyProvider) {
774
891
  throw new Error("reencryptPrivateKey: not supported when a privateKeyProvider is configured");
775
892
  }
@@ -895,7 +1012,7 @@ var init_certificate_manager = __esm({
895
1012
  } catch (_err) {
896
1013
  return "BadCertificateInvalid";
897
1014
  }
898
- const filename = path2.join(this.rejectedFolder, `${buildIdealCertificateName(leafCertificate)}.pem`);
1015
+ const filename = safeStoreJoin(this.rejectedFolder, `${buildIdealCertificateName(leafCertificate)}.pem`);
899
1016
  debugLog("certificate has never been seen before and is now rejected (untrusted) ", filename);
900
1017
  await fsWriteFile(filename, toPem(chain, "CERTIFICATE"));
901
1018
  this.#thumbs.rejected.set(fingerprint2, { certificate: leafCertificate, filename });
@@ -1118,7 +1235,7 @@ var init_certificate_manager = __esm({
1118
1235
  mkdirRecursiveSync(path2.join(pkiDir, "issuers"));
1119
1236
  mkdirRecursiveSync(path2.join(pkiDir, "issuers/certs"));
1120
1237
  mkdirRecursiveSync(path2.join(pkiDir, "issuers/crl"));
1121
- const ownsDiskKey = !this.#privateKeyProvider;
1238
+ const ownsDiskKey = !this.#privateKeyProvider && !this.#keyOperations;
1122
1239
  const needsKeyGeneration = ownsDiskKey && !fs4.existsSync(this.privateKey);
1123
1240
  const needsKeyEncryption = ownsDiskKey && !needsKeyGeneration && this.#privateKeyPassphrase !== void 0 && !isEncryptedPrivateKeyFile(this.privateKey);
1124
1241
  if (!fs4.existsSync(this.configFile) || needsKeyGeneration || needsKeyEncryption) {
@@ -1144,14 +1261,14 @@ var init_certificate_manager = __esm({
1144
1261
  if (ownsDiskKey) {
1145
1262
  restrictPrivateFilePermissions(this.privateKey, 384);
1146
1263
  }
1147
- await this.getPrivateKey();
1264
+ await this.#probePrivateKey();
1148
1265
  await this.#readCertificates();
1149
1266
  });
1150
1267
  } else {
1151
1268
  if (ownsDiskKey) {
1152
1269
  restrictPrivateFilePermissions(this.privateKey, 384);
1153
1270
  }
1154
- await this.getPrivateKey();
1271
+ await this.#probePrivateKey();
1155
1272
  await this.#readCertificates();
1156
1273
  }
1157
1274
  }
@@ -1236,7 +1353,7 @@ var init_certificate_manager = __esm({
1236
1353
  if (typeof params.applicationUri !== "string") {
1237
1354
  throw new Error("createSelfSignedCertificate: expecting applicationUri to be a string");
1238
1355
  }
1239
- if (!this.#privateKeyProvider && !fs4.existsSync(this.privateKey)) {
1356
+ if (!this.#privateKeyProvider && !this.#keyOperations && !fs4.existsSync(this.privateKey)) {
1240
1357
  throw new Error(`Cannot find private key ${this.privateKey}`);
1241
1358
  }
1242
1359
  let certificateFilename = path2.join(this.rootDir, "own/certs/self_signed_certificate.pem");
@@ -1245,7 +1362,7 @@ var init_certificate_manager = __esm({
1245
1362
  ...params,
1246
1363
  rootDir: this.rootDir,
1247
1364
  configFile: this.configFile,
1248
- privateKey: await this.getPrivateKey(),
1365
+ privateKey: await this.#resolveSigningKey(),
1249
1366
  subject: params.subject || "CN=FIXME"
1250
1367
  };
1251
1368
  await this.withLock2(async () => {
@@ -1273,7 +1390,7 @@ var init_certificate_manager = __esm({
1273
1390
  ...params,
1274
1391
  rootDir: path2.resolve(this.rootDir),
1275
1392
  configFile: path2.resolve(this.configFile),
1276
- privateKey: await this.getPrivateKey()
1393
+ privateKey: await this.#resolveSigningKey()
1277
1394
  };
1278
1395
  return await this.withLock2(async () => {
1279
1396
  const now = /* @__PURE__ */ new Date();
@@ -1303,7 +1420,7 @@ var init_certificate_manager = __esm({
1303
1420
  if (this.#thumbs.issuers.certs.has(fingerprint2)) {
1304
1421
  return "Good" /* Good */;
1305
1422
  }
1306
- const filename = path2.join(this.issuersCertFolder, `issuer_${buildIdealCertificateName(certificate)}.pem`);
1423
+ const filename = safeStoreJoin(this.issuersCertFolder, `issuer_${buildIdealCertificateName(certificate)}.pem`);
1307
1424
  await fs4.promises.writeFile(filename, pemCertificate, "ascii");
1308
1425
  this.#thumbs.issuers.certs.set(fingerprint2, { certificate, filename });
1309
1426
  if (addInTrustList) {
@@ -1762,7 +1879,7 @@ var init_certificate_manager = __esm({
1762
1879
  let status = await this.#checkRejectedOrTrusted(certificate);
1763
1880
  if (status === "unknown") {
1764
1881
  const pem = toPem(chain, "CERTIFICATE");
1765
- const filename = path2.join(this.rejectedFolder, `${buildIdealCertificateName(certificate)}.pem`);
1882
+ const filename = safeStoreJoin(this.rejectedFolder, `${buildIdealCertificateName(certificate)}.pem`);
1766
1883
  await fs4.promises.writeFile(filename, pem);
1767
1884
  this.#thumbs.rejected.set(fingerprint2, { certificate, filename });
1768
1885
  status = "rejected";
@@ -1779,7 +1896,7 @@ var init_certificate_manager = __esm({
1779
1896
  throw new Error(`#moveCertificate: certificate ${fingerprint2.substring(0, 10)} not found in ${status} index`);
1780
1897
  }
1781
1898
  const destFolder = newStatus === "trusted" ? this.trustedFolder : this.rejectedFolder;
1782
- const certificateDest = path2.join(destFolder, path2.basename(srcEntry.filename));
1899
+ const certificateDest = safeStoreJoin(destFolder, path2.basename(srcEntry.filename));
1783
1900
  debugLog("#moveCertificate", fingerprint2.substring(0, 10), "old name", srcEntry.filename);
1784
1901
  debugLog("#moveCertificate", fingerprint2.substring(0, 10), "new name", certificateDest);
1785
1902
  await fs4.promises.rename(srcEntry.filename, certificateDest);
@@ -2536,8 +2653,32 @@ function passinArg(passphrase = "") {
2536
2653
  function renderForDisplay(file, args) {
2537
2654
  return [file, ...args].map((a) => a === "" || /[\s"'`$\\]/.test(a) ? JSON.stringify(a) : a).join(" ");
2538
2655
  }
2656
+ function hasUnsafeArgChar(value) {
2657
+ for (const ch of value) {
2658
+ const code = ch.codePointAt(0);
2659
+ if (code < 32 || code === 127) {
2660
+ return true;
2661
+ }
2662
+ if (code < 128) {
2663
+ continue;
2664
+ }
2665
+ if (/[\p{L}\p{N}\p{M}]/u.test(ch)) {
2666
+ continue;
2667
+ }
2668
+ return true;
2669
+ }
2670
+ return false;
2671
+ }
2672
+ function checkArgs(file, args) {
2673
+ for (const value of [file, ...args]) {
2674
+ if (typeof value === "string" && hasUnsafeArgChar(value)) {
2675
+ throw new Error(`execute_openssl: refusing argument with an unexpected character: ${JSON.stringify(value)}`);
2676
+ }
2677
+ }
2678
+ }
2539
2679
  async function execute2(file, args, options) {
2540
2680
  const from = new Error();
2681
+ checkArgs(file, args);
2541
2682
  options.cwd = options.cwd || process.cwd();
2542
2683
  if (!g_config.silent) {
2543
2684
  warningLog(chalk5.cyan(" CWD "), options.cwd);