node-opcua-server-configuration 2.186.4 → 2.186.6

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.
@@ -16,12 +16,19 @@ import {
16
16
  resolvePrivateKeyProviderIfNeeded
17
17
  } from "node-opcua-common";
18
18
  import { readPrivateKey } from "node-opcua-crypto";
19
- import { type Certificate, exploreCertificateInfo, split_der } from "node-opcua-crypto/web";
19
+ import {
20
+ type Certificate,
21
+ exploreCertificateInfo,
22
+ makeSHA1Thumbprint,
23
+ split_der,
24
+ verifyCertificateSignature
25
+ } from "node-opcua-crypto/web";
20
26
  import { checkDebugFlag, make_debugLog, make_errorLog, make_warningLog } from "node-opcua-debug";
21
27
  import type { OPCUAServer, OPCUAServerEndPoint } from "node-opcua-server";
22
28
  import { type StatusCode, StatusCodes } from "node-opcua-status-code";
23
29
  import { type ApplicationDescriptionOptions, ServerState } from "node-opcua-types";
24
30
 
31
+ import { hasConfirmedTrust, leafThumbprintsIn, recordProvisionalTrust, withdrawProvisionalTrust } from "./application_setup.js";
25
32
  import { installPushCertificateManagement } from "./push_certificate_manager_helpers.js";
26
33
  import type { ActionQueue, PushCertificateManagerServerImpl } from "./push_certificate_manager_server_impl.js";
27
34
 
@@ -239,6 +246,42 @@ export interface InstallPushCertificateManagementOnServerOptions {
239
246
  * @defaultValue true
240
247
  */
241
248
  closeChannelsOnApplyChanges?: boolean;
249
+
250
+ /**
251
+ * How the server leaves the application setup state
252
+ * (`ServerState.NoConfiguration`, OPC 10000-12 §G.2).
253
+ */
254
+ applicationSetup?: ApplicationSetupOptions;
255
+ }
256
+
257
+ export interface ApplicationSetupOptions {
258
+ /**
259
+ * Leave the application setup state automatically once the server is
260
+ * configured. §G.2: "Once an application has been configured it
261
+ * automatically leaves the application setup state. This step is
262
+ * necessary to ensure that security is not compromised."
263
+ *
264
+ * While the server reports `NoConfiguration`, push certificate
265
+ * management admits any client and trusts its certificate, so that a
266
+ * CertificateManager can reach it for the first time (see
267
+ * `installCertificateRelaxationHook`). With this option:
268
+ *
269
+ * - the server moves to `Running` when ApplyChanges has put a
270
+ * CA-issued certificate into service and the TrustList holds a
271
+ * certificate that was not trusted automatically;
272
+ * - on leaving `NoConfiguration` (this way or when the application sets
273
+ * the state itself), the certificates trusted automatically are
274
+ * removed again, except those a TrustList write kept (CloseAndUpdate
275
+ * of the trusted list, AddCertificate). Whoever configured the server
276
+ * stays trusted through the TrustList it wrote; anyone else who
277
+ * connected during the setup does not.
278
+ *
279
+ * Without it the application decides when the setup ends, and the
280
+ * certificates trusted automatically stay trusted.
281
+ *
282
+ * @defaultValue false
283
+ */
284
+ leaveWhenConfigured?: boolean;
242
285
  }
243
286
 
244
287
  export async function installPushCertificateManagementOnServer(
@@ -246,6 +289,7 @@ export async function installPushCertificateManagementOnServer(
246
289
  options?: InstallPushCertificateManagementOnServerOptions
247
290
  ): Promise<void> {
248
291
  const closeChannelsOnApplyChanges = options?.closeChannelsOnApplyChanges ?? true;
292
+ const leaveWhenConfigured = options?.applicationSetup?.leaveWhenConfigured ?? false;
249
293
  if (!server.engine?.addressSpace) {
250
294
  throw new Error(
251
295
  "Server must have a valid address space. " +
@@ -309,6 +353,9 @@ export async function installPushCertificateManagementOnServer(
309
353
  setImmediate(async () => {
310
354
  try {
311
355
  await onApplyChangesCompleted(server, closeChannelsOnApplyChanges);
356
+ if (leaveWhenConfigured) {
357
+ await leaveApplicationSetupIfConfigured(server, cm);
358
+ }
312
359
  } catch (err) {
313
360
  errorLog("onApplyChangesCompleted error:", (err as Error).message);
314
361
  }
@@ -323,8 +370,10 @@ export async function installPushCertificateManagementOnServer(
323
370
  // write a certificate straight into serverCertificateManager's
324
371
  // trusted-certs folder. Only the method path emits "trustListUpdated",
325
372
  // so a consumer waiting on it to learn that the server has been
326
- // provisioned - to leave NoConfiguration, say - would never hear about
327
- // a TrustList populated the other way.
373
+ // provisioned would never hear about a TrustList populated the other
374
+ // way. It is not a signal to leave NoConfiguration: the first client
375
+ // trusted automatically populates the TrustList too. Use
376
+ // `applicationSetup.leaveWhenConfigured` for that.
328
377
  //
329
378
  // This deliberately does NOT emit "applyChangesCompleted": that event
330
379
  // means "a certificate rotation was applied" and carries channel
@@ -375,7 +424,164 @@ export async function installPushCertificateManagementOnServer(
375
424
  //
376
425
  // This hook is ONLY installed when push certificate management
377
426
  // is active — bare servers are completely unaffected.
378
- installCertificateRelaxationHook(server);
427
+ installCertificateRelaxationHook(server, leaveWhenConfigured);
428
+
429
+ // ── Re-evaluate open channels when the TrustList changes ──
430
+ //
431
+ // §7.8.2.5: "When the TrustList changes the Server shall re-evaluate the
432
+ // Certificate associated with any open Sessions and SecureChannels.
433
+ // Sessions or SecureChannels with an untrusted or revoked Certificate
434
+ // shall be closed." The event fires inside the method, before its
435
+ // response is on the wire; the delay gives the Client that changed the
436
+ // TrustList time to receive it before its own channel may be closed
437
+ // ("This process may not complete before the Method returns").
438
+ serverConfigurationPriv.$pushCertificateManager.on("trustListUpdated", () => {
439
+ setTimeout(() => {
440
+ closeChannelsNoLongerTrusted(server).catch((err) =>
441
+ errorLog("[TrustList] cannot re-evaluate open channels:", (err as Error).message)
442
+ );
443
+ }, REEVALUATION_DELAY_MS);
444
+ });
445
+
446
+ if (leaveWhenConfigured) {
447
+ withdrawProvisionalTrustOnLeavingSetup(server, cm);
448
+ // The TrustList may be written after ApplyChanges: without a
449
+ // transaction CloseAndUpdate applies on its own, so check then too.
450
+ serverConfigurationPriv.$pushCertificateManager.on("trustListUpdated", () => {
451
+ setTimeout(() => {
452
+ leaveApplicationSetupIfConfigured(server, cm).catch((err) =>
453
+ errorLog("[ApplicationSetup] cannot evaluate the setup state:", (err as Error).message)
454
+ );
455
+ }, REEVALUATION_DELAY_MS);
456
+ });
457
+ }
458
+ }
459
+
460
+ // ── Re-evaluating open channels (§7.8.2.5) ─────────────────────
461
+
462
+ const reevaluations = new WeakMap<OPCUAServer, Promise<unknown>>();
463
+
464
+ /** Certificates being re-evaluated: the relaxation hook admits them without trusting them. */
465
+ const reevaluating = new WeakSet<Certificate>();
466
+
467
+ /** How long after a TrustList change open channels are re-evaluated (see the "trustListUpdated" listener). */
468
+ const REEVALUATION_DELAY_MS = 250;
469
+
470
+ /**
471
+ * Close every SecureChannel, and the Sessions on it, whose client
472
+ * certificate the server would no longer admit, by the same decision as at
473
+ * OpenSecureChannel: the certificate store, then the endpoint's
474
+ * `onAdjustCertificateStatus` hook (the NoConfiguration relaxation, plus
475
+ * whatever admission policy the application composed with it). In the setup
476
+ * state an untrusted certificate is therefore still admitted. Returns the
477
+ * number of channels closed.
478
+ *
479
+ * The check never trusts a certificate on its own.
480
+ */
481
+ export function closeChannelsNoLongerTrusted(server: OPCUAServer): Promise<number> {
482
+ const run = (reevaluations.get(server) ?? Promise.resolve()).then(() => _closeChannelsNoLongerTrusted(server));
483
+ reevaluations.set(
484
+ server,
485
+ run.catch(() => undefined)
486
+ );
487
+ return run;
488
+ }
489
+
490
+ async function _closeChannelsNoLongerTrusted(server: OPCUAServer): Promise<number> {
491
+ const cm = server.serverCertificateManager;
492
+ let closed = 0;
493
+ for (const endpoint of server.endpoints as OPCUAServerEndPoint[]) {
494
+ for (const channel of endpoint.getChannels()) {
495
+ const certificate = channel.clientCertificate;
496
+ if (!certificate) {
497
+ continue;
498
+ }
499
+ // The same decision as when the channel was opened: the store,
500
+ // then the endpoint's adjustment hook, which the application may
501
+ // have composed with its own admission policy. Only the
502
+ // NoConfiguration relaxation's side effect (trusting the
503
+ // certificate) is suppressed: a re-evaluation never adds trust.
504
+ let statusCode = await cm.checkCertificate(certificate);
505
+ if (statusCode.isNotGood()) {
506
+ reevaluating.add(certificate);
507
+ try {
508
+ statusCode = await endpoint.adjustCertificateStatus(statusCode, certificate);
509
+ } finally {
510
+ reevaluating.delete(certificate);
511
+ }
512
+ }
513
+ if (statusCode.isGood()) {
514
+ continue;
515
+ }
516
+ warningLog(`[TrustList] closing SecureChannel ${channel.channelId}: client certificate is now ${statusCode.name}`);
517
+ for (const session of server.engine.getSessions()) {
518
+ if (session.channel === channel) {
519
+ server.engine.closeSession(session.authenticationToken, false, "Terminated");
520
+ }
521
+ }
522
+ channel.close();
523
+ closed++;
524
+ }
525
+ }
526
+ return closed;
527
+ }
528
+
529
+ // ── Leaving the application setup state (§G.2) ─────────────────
530
+
531
+ function isSelfSigned(certificate: Certificate): boolean {
532
+ try {
533
+ return verifyCertificateSignature(certificate, certificate);
534
+ } catch {
535
+ return false;
536
+ }
537
+ }
538
+
539
+ /**
540
+ * Move to Running once ApplyChanges has put a CA-issued certificate into
541
+ * service and the TrustList holds a certificate that was not trusted
542
+ * automatically. Leaving earlier would lock every client out, the
543
+ * CertificateManager included.
544
+ */
545
+ async function leaveApplicationSetupIfConfigured(server: OPCUAServer, cm: OPCUACertificateManager): Promise<void> {
546
+ if (server.engine.getServerState() !== ServerState.NoConfiguration) {
547
+ return;
548
+ }
549
+ if (isSelfSigned(server.getCertificate())) {
550
+ doDebug && debugLog("[ApplicationSetup] still self-signed: staying in NoConfiguration");
551
+ return;
552
+ }
553
+ if (!(await hasConfirmedTrust(cm))) {
554
+ warningLog(
555
+ "[ApplicationSetup] a CA-issued certificate is in service but no TrustList was written: staying in NoConfiguration"
556
+ );
557
+ return;
558
+ }
559
+ warningLog("[ApplicationSetup] configured: leaving NoConfiguration");
560
+ server.engine.setServerState(ServerState.Running);
561
+ }
562
+
563
+ /** When the server leaves NoConfiguration, remove the certificates trusted automatically during the setup. */
564
+ function withdrawProvisionalTrustOnLeavingSetup(server: OPCUAServer, cm: OPCUACertificateManager): void {
565
+ const onStateChanged = (oldState: ServerState, newState: ServerState) => {
566
+ if (newState === ServerState.Shutdown) {
567
+ server.engine.removeListener("serverStateChanged", onStateChanged);
568
+ return;
569
+ }
570
+ if (oldState !== ServerState.NoConfiguration || newState === ServerState.NoConfiguration) {
571
+ return;
572
+ }
573
+ withdrawProvisionalTrust(cm)
574
+ .then(async (withdrawn) => {
575
+ if (withdrawn > 0) {
576
+ warningLog(
577
+ `[ApplicationSetup] setup ended: ${withdrawn} certificate(s) trusted automatically are no longer trusted`
578
+ );
579
+ }
580
+ await closeChannelsNoLongerTrusted(server);
581
+ })
582
+ .catch((err) => errorLog("[ApplicationSetup] cannot withdraw provisional trust:", (err as Error).message));
583
+ };
584
+ server.engine.on("serverStateChanged", onStateChanged);
379
585
  }
380
586
 
381
587
  // ── Certificate relaxation for NoConfiguration state ──────────
@@ -403,7 +609,10 @@ export async function installPushCertificateManagementOnServer(
403
609
  * relaxation hook returns the original status code unchanged and
404
610
  * normal strict certificate validation applies. The accepted
405
611
  * certificate is auto-trusted (see `autoTrustCertificateChain`)
406
- * so that subsequent connections succeed under normal validation.
612
+ * so that subsequent connections succeed under normal validation;
613
+ * with `applicationSetup.leaveWhenConfigured` that trust is
614
+ * withdrawn again when the setup ends, unless a TrustList write
615
+ * kept it.
407
616
  *
408
617
  * Errors that indicate an active security violation (revoked,
409
618
  * expired, invalid signature, wrong usage) are **never** relaxed,
@@ -432,7 +641,7 @@ function isRelaxableCertificateError(statusCode: StatusCode): boolean {
432
641
  * connection from unintentionally granting trust to every
433
642
  * certificate signed by the same CA.
434
643
  */
435
- async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certificate): Promise<void> {
644
+ async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certificate, recordProvisional: boolean): Promise<void> {
436
645
  let chain: Certificate[];
437
646
  try {
438
647
  chain = split_der(certificate);
@@ -456,8 +665,12 @@ async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certi
456
665
  }
457
666
 
458
667
  if (i === 0) {
459
- // Leaf certificate → trust explicitly
668
+ // Leaf certificate → trust explicitly, and remember that it was
669
+ // trusted only because the server is in the setup state.
460
670
  try {
671
+ if (recordProvisional && (await cm.isCertificateTrusted(cert)) !== "Good") {
672
+ await recordProvisionalTrust(cm, "trusted", makeSHA1Thumbprint(cert).toString("hex"));
673
+ }
461
674
  await cm.trustCertificate(cert);
462
675
  } catch (err) {
463
676
  // ENOENT can happen if another concurrent call already
@@ -470,6 +683,12 @@ async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certi
470
683
  // Issuer CA certificate → add to issuers/ (chain-building
471
684
  // only, does NOT establish a trust anchor)
472
685
  try {
686
+ if (recordProvisional) {
687
+ const thumbprint = makeSHA1Thumbprint(cert).toString("hex");
688
+ if (!(await leafThumbprintsIn(cm.issuersCertFolder)).includes(thumbprint)) {
689
+ await recordProvisionalTrust(cm, "issuers", thumbprint);
690
+ }
691
+ }
473
692
  await cm.addIssuer(cert);
474
693
  } catch (err) {
475
694
  warningLog("[NoConfiguration] Failed to add issuer certificate:", (err as Error).message);
@@ -490,7 +709,7 @@ async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certi
490
709
  * validation. Issuer CAs are placed in `issuers/` for chain
491
710
  * building but do not become trust anchors.
492
711
  */
493
- function installCertificateRelaxationHook(server: OPCUAServer): void {
712
+ function installCertificateRelaxationHook(server: OPCUAServer, recordProvisional: boolean): void {
494
713
  const adjustCertificateStatus = async (statusCode: StatusCode, certificate: Certificate): Promise<StatusCode> => {
495
714
  // Only relax in NoConfiguration state
496
715
  if (server.engine.getServerState() !== ServerState.NoConfiguration) {
@@ -509,8 +728,11 @@ function installCertificateRelaxationHook(server: OPCUAServer): void {
509
728
  "(server is awaiting GDS provisioning)"
510
729
  );
511
730
 
512
- // Auto-trust the leaf certificate; issuer CAs go to issuers/
513
- await autoTrustCertificateChain(server, certificate);
731
+ // Auto-trust the leaf certificate; issuer CAs go to issuers/ (not
732
+ // when an open channel is being re-evaluated: that adds no trust).
733
+ if (!reevaluating.has(certificate)) {
734
+ await autoTrustCertificateChain(server, certificate, recordProvisional);
735
+ }
514
736
 
515
737
  return StatusCodes.Good;
516
738
  };
@@ -21,7 +21,7 @@ import type {
21
21
  } from "node-opcua-address-space";
22
22
  import { BinaryStream } from "node-opcua-binary-stream";
23
23
  import type { OPCUACertificateManager } from "node-opcua-certificate-manager";
24
- import { split_der, verifyCertificateChain } from "node-opcua-crypto/web";
24
+ import { exploreCertificate, makeSHA1Thumbprint, split_der, verifyCertificateChain } from "node-opcua-crypto/web";
25
25
  import { AccessRestrictionsFlag } from "node-opcua-data-model";
26
26
  import { checkDebugFlag, make_debugLog, make_errorLog, make_warningLog } from "node-opcua-debug";
27
27
  import { type AbstractFs, installFileType, OpenFileMode } from "node-opcua-file-transfer";
@@ -29,6 +29,7 @@ import { VerificationStatus } from "node-opcua-pki";
29
29
  import { type CallbackT, type StatusCode, StatusCodes } from "node-opcua-status-code";
30
30
  import { type CallMethodResultOptions, TrustListDataType } from "node-opcua-types";
31
31
  import { DataType, Variant } from "node-opcua-variant";
32
+ import { confirmTrust, isSharedTrustListStore, leafThumbprintsIn } from "./application_setup.js";
32
33
  import type { PushCertificateManagerServerImpl } from "./push_certificate_manager_server_impl.js";
33
34
  import { rolePermissionAdminOnly } from "./roles_and_permissions.js";
34
35
  import { hasEncryptedChannel, hasExpectedUserAccess } from "./tools.js";
@@ -274,29 +275,6 @@ async function applyTrustListChanges(cm: OPCUACertificateManager, trustListData:
274
275
  trustListData.specifiedLists |= TrustListMasks.IssuerCertificates;
275
276
  }
276
277
 
277
- // Process CRLs
278
- if ((trustListData.specifiedLists & TrustListMasks.IssuerCrls) === TrustListMasks.IssuerCrls) {
279
- doDebug && debugLog("Processing issuer CRLs");
280
- await cm.clearRevocationLists("issuers");
281
- if (trustListData.issuerCrls && trustListData.issuerCrls.length > 0) {
282
- doDebug && debugLog(` Writing ${trustListData.issuerCrls.length} issuer CRLs`);
283
- for (const crl of trustListData.issuerCrls) {
284
- await cm.addRevocationList(crl, "issuers");
285
- }
286
- }
287
- }
288
-
289
- if ((trustListData.specifiedLists & TrustListMasks.TrustedCrls) === TrustListMasks.TrustedCrls) {
290
- doDebug && debugLog("Processing trusted CRLs");
291
- await cm.clearRevocationLists("trusted");
292
- if (trustListData.trustedCrls && trustListData.trustedCrls.length > 0) {
293
- doDebug && debugLog(` Writing ${trustListData.trustedCrls.length} trusted CRLs`);
294
- for (const crl of trustListData.trustedCrls) {
295
- await cm.addRevocationList(crl, "trusted");
296
- }
297
- }
298
- }
299
-
300
278
  // Validate all trusted certificates
301
279
  if (
302
280
  (trustListData.specifiedLists & TrustListMasks.TrustedCertificates) === TrustListMasks.TrustedCertificates &&
@@ -305,6 +283,9 @@ async function applyTrustListChanges(cm: OPCUACertificateManager, trustListData:
305
283
  for (const cert of trustListData.trustedCertificates) {
306
284
  try {
307
285
  const certs = split_der(cert);
286
+ // verifyCertificateChain checks nothing on a one-element chain;
287
+ // parsing is what rejects bytes that are not a certificate.
288
+ exploreCertificate(certs[0]);
308
289
  const validationResult = await verifyCertificateChain([certs[0]]);
309
290
  if (validationResult.status !== "Good") {
310
291
  warningLog("Invalid certificate in trust list:", validationResult.status, validationResult.reason);
@@ -325,6 +306,9 @@ async function applyTrustListChanges(cm: OPCUACertificateManager, trustListData:
325
306
  for (const cert of trustListData.issuerCertificates) {
326
307
  try {
327
308
  const certs = split_der(cert);
309
+ // verifyCertificateChain checks nothing on a one-element chain;
310
+ // parsing is what rejects bytes that are not a certificate.
311
+ exploreCertificate(certs[0]);
328
312
  const validationResult = await verifyCertificateChain([certs[0]]);
329
313
  if (validationResult.status !== "Good") {
330
314
  warningLog("Invalid issuer certificate in trust list:", validationResult.status, validationResult.reason);
@@ -337,22 +321,53 @@ async function applyTrustListChanges(cm: OPCUACertificateManager, trustListData:
337
321
  }
338
322
  }
339
323
 
340
- // Update certificates
341
- if (
342
- (trustListData.specifiedLists & TrustListMasks.TrustedCertificates) === TrustListMasks.TrustedCertificates &&
343
- trustListData.trustedCertificates
344
- ) {
345
- for (const cert of trustListData.trustedCertificates) {
324
+ // Nothing below runs unless every uploaded certificate is valid, so an
325
+ // invalid upload leaves the TrustList (CRLs included) as it was.
326
+ // Process CRLs
327
+ if ((trustListData.specifiedLists & TrustListMasks.IssuerCrls) === TrustListMasks.IssuerCrls) {
328
+ doDebug && debugLog("Processing issuer CRLs");
329
+ await cm.clearRevocationLists("issuers");
330
+ if (trustListData.issuerCrls && trustListData.issuerCrls.length > 0) {
331
+ doDebug && debugLog(` Writing ${trustListData.issuerCrls.length} issuer CRLs`);
332
+ for (const crl of trustListData.issuerCrls) {
333
+ await cm.addRevocationList(crl, "issuers");
334
+ }
335
+ }
336
+ }
337
+
338
+ if ((trustListData.specifiedLists & TrustListMasks.TrustedCrls) === TrustListMasks.TrustedCrls) {
339
+ doDebug && debugLog("Processing trusted CRLs");
340
+ await cm.clearRevocationLists("trusted");
341
+ if (trustListData.trustedCrls && trustListData.trustedCrls.length > 0) {
342
+ doDebug && debugLog(` Writing ${trustListData.trustedCrls.length} trusted CRLs`);
343
+ for (const crl of trustListData.trustedCrls) {
344
+ await cm.addRevocationList(crl, "trusted");
345
+ }
346
+ }
347
+ }
348
+
349
+ // Update certificates. A list whose bit is set is replaced by the one
350
+ // uploaded, not added to (§7.8.2.5: the Server "creates a new TrustList
351
+ // that includes the existing TrustList plus any updates"; §7.8.2.9: a
352
+ // list whose bit is not set is not changed). The uploaded certificates
353
+ // are added before the others are removed: should an add fail, the
354
+ // list is left larger than asked, never emptier, so nobody who was
355
+ // trusted is locked out by a half-applied write.
356
+ if ((trustListData.specifiedLists & TrustListMasks.TrustedCertificates) === TrustListMasks.TrustedCertificates) {
357
+ const uploaded = trustListData.trustedCertificates ?? [];
358
+ for (const cert of uploaded) {
346
359
  await cm.trustCertificate(cert);
347
360
  }
361
+ await confirmTrust(cm, "trusted", uploaded.map(leafThumbprint));
362
+ await removeCertificatesNotIn(cm, cm.trustedFolder, uploaded, (thumbprint) => cm.removeTrustedCertificate(thumbprint));
348
363
  }
349
- if (
350
- (trustListData.specifiedLists & TrustListMasks.IssuerCertificates) === TrustListMasks.IssuerCertificates &&
351
- trustListData.issuerCertificates
352
- ) {
353
- for (const cert of trustListData.issuerCertificates) {
364
+ if ((trustListData.specifiedLists & TrustListMasks.IssuerCertificates) === TrustListMasks.IssuerCertificates) {
365
+ const uploaded = trustListData.issuerCertificates ?? [];
366
+ for (const cert of uploaded) {
354
367
  await cm.addIssuer(cert);
355
368
  }
369
+ await confirmTrust(cm, "issuers", uploaded.map(leafThumbprint));
370
+ await removeCertificatesNotIn(cm, cm.issuersCertFolder, uploaded, (thumbprint) => cm.removeIssuer(thumbprint));
356
371
  }
357
372
 
358
373
  return StatusCodes.Good;
@@ -362,6 +377,38 @@ async function applyTrustListChanges(cm: OPCUACertificateManager, trustListData:
362
377
  }
363
378
  }
364
379
 
380
+ function leafThumbprint(certificateOrChain: Buffer): string {
381
+ return makeSHA1Thumbprint(split_der(certificateOrChain)[0]).toString("hex");
382
+ }
383
+
384
+ /**
385
+ * Remove from `folder` every certificate that is not in `uploaded`.
386
+ *
387
+ * Skipped, with a warning, when the store backs another CertificateGroup as
388
+ * well: the certificates of both groups sit in the same folder, and removing
389
+ * what this group's upload leaves out would remove the other group's too.
390
+ * Such a store keeps the previous behaviour, adding only.
391
+ */
392
+ async function removeCertificatesNotIn(
393
+ cm: OPCUACertificateManager,
394
+ folder: string,
395
+ uploaded: Buffer[],
396
+ remove: (thumbprint: string) => Promise<unknown>
397
+ ): Promise<void> {
398
+ if (isSharedTrustListStore(cm)) {
399
+ warningLog(
400
+ `[TrustList] ${cm.rootDir} backs more than one CertificateGroup: the uploaded list is added to it, not replacing it (OPC 10000-12 §7.8.2.5). Give each group its own CertificateManager.`
401
+ );
402
+ return;
403
+ }
404
+ const kept = new Set(uploaded.map(leafThumbprint));
405
+ for (const thumbprint of await leafThumbprintsIn(folder)) {
406
+ if (!kept.has(thumbprint)) {
407
+ await remove(thumbprint);
408
+ }
409
+ }
410
+ }
411
+
365
412
  async function _closeAndUpdate(
366
413
  this: UAMethod,
367
414
  inputArguments: Variant[],
@@ -515,6 +562,7 @@ async function _addCertificate(
515
562
  return { statusCode: verificationStatusToStatusCode(status) };
516
563
  }
517
564
 
565
+ await confirmTrust(cm, "trusted", [leafThumbprint(certificateBuffer)]);
518
566
  updateLastUpdateTime(trustList);
519
567
  emitTrustListUpdated(trustList);
520
568
 
@@ -562,6 +610,7 @@ async function _removeCertificate(
562
610
  if (!removed) {
563
611
  return { statusCode: StatusCodes.BadInvalidArgument };
564
612
  }
613
+ await confirmTrust(cm, "trusted", [normalizedThumbprint]);
565
614
  } else {
566
615
  // Removing an issuer certificate — first check if it's still
567
616
  // needed by any trusted certificate
@@ -580,6 +629,7 @@ async function _removeCertificate(
580
629
 
581
630
  // Also remove CRLs issued by this CA
582
631
  await cm.removeRevocationListsForIssuer(issuerCert, "issuers");
632
+ await confirmTrust(cm, "issuers", [normalizedThumbprint]);
583
633
  }
584
634
 
585
635
  updateLastUpdateTime(trustList);
@@ -27,6 +27,7 @@ import type { CallMethodResultOptions } from "node-opcua-types";
27
27
  import { DataType, type Variant, VariantArrayType } from "node-opcua-variant";
28
28
 
29
29
  import type { CreateSigningRequestResult, PushCertificateManager } from "../push_certificate_manager.js";
30
+ import { markSharedTrustListStore } from "./application_setup.js";
30
31
  import { installCertificateFileWatcher } from "./install_certificate_file_watcher.js";
31
32
  import { installAccessRestrictionOnTrustList, promoteTrustList } from "./promote_trust_list.js";
32
33
  import {
@@ -320,6 +321,9 @@ function bindCertificateManager(addressSpace: AddressSpace, options: PushCertifi
320
321
  "ServerConfiguration"
321
322
  ) as UAServerConfiguration;
322
323
 
324
+ if (options.applicationGroup && options.userTokenGroup) {
325
+ markSharedTrustListStore(options.applicationGroup, options.userTokenGroup);
326
+ }
323
327
  const defaultApplicationGroup = serverConfiguration.certificateGroups.getComponentByName(
324
328
  "DefaultApplicationGroup"
325
329
  ) as UACertificateGroup | null;