node-opcua-server-configuration 2.186.3 → 2.186.5

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.
@@ -0,0 +1,169 @@
1
+ /**
2
+ * @module node-opcua-server-configuration
3
+ *
4
+ * Clients trusted automatically in the application setup state.
5
+ *
6
+ * While a server reports `ServerState.NoConfiguration` (the application setup
7
+ * state of OPC 10000-12 §G.2), push certificate management admits any client
8
+ * and trusts its certificate, so that a CertificateManager can reach the
9
+ * server for the first time. Anyone else who connects in that window is
10
+ * trusted the same way. This module remembers which certificates were
11
+ * trusted that way (leaves in the trusted list, and the issuers that came
12
+ * with them), so that they can be withdrawn when the server leaves the setup
13
+ * state, unless a TrustList written since then kept them.
14
+ *
15
+ * The record is kept next to the PKI store, so a restart in the middle of the
16
+ * setup does not turn provisional trust into permanent trust.
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import type { CertificateManager } from "node-opcua-certificate-manager";
21
+ import { makeSHA1Thumbprint, readCertificateChainAsync } from "node-opcua-crypto";
22
+ import { make_errorLog, make_warningLog } from "node-opcua-debug";
23
+
24
+ const warningLog = make_warningLog("ServerConfiguration");
25
+ const errorLog = make_errorLog("ServerConfiguration");
26
+
27
+ const RECORD_FILE = "provisional_trust.json";
28
+
29
+ /** Which list of the TrustList a provisionally trusted certificate sits in. */
30
+ export type ProvisionalList = "trusted" | "issuers";
31
+
32
+ const records = new WeakMap<CertificateManager, Set<string>>();
33
+
34
+ const key = (list: ProvisionalList, thumbprint: string) => `${list}:${thumbprint}`;
35
+
36
+ function recordFile(cm: CertificateManager): string {
37
+ return path.join(cm.rootDir, RECORD_FILE);
38
+ }
39
+
40
+ function load(cm: CertificateManager): Set<string> {
41
+ let record = records.get(cm);
42
+ if (record) return record;
43
+ record = new Set<string>();
44
+ const file = recordFile(cm);
45
+ try {
46
+ if (fs.existsSync(file)) {
47
+ for (const entry of JSON.parse(fs.readFileSync(file, "utf8")) as string[]) {
48
+ record.add(entry);
49
+ }
50
+ }
51
+ } catch (err) {
52
+ // The record is written atomically (see save), so this is not a torn
53
+ // write: someone edited or damaged it. Nothing here can tell which
54
+ // trusted certificates were provisional any more; say so loudly.
55
+ errorLog(
56
+ `[ApplicationSetup] cannot read ${file}: certificates trusted automatically during the setup will not be withdrawn.`,
57
+ (err as Error).message
58
+ );
59
+ }
60
+ records.set(cm, record);
61
+ return record;
62
+ }
63
+
64
+ async function save(cm: CertificateManager, record: Set<string>): Promise<void> {
65
+ const file = recordFile(cm);
66
+ if (record.size === 0) {
67
+ await fs.promises.rm(file, { force: true });
68
+ return;
69
+ }
70
+ // Write-then-rename: a crash mid-write must not leave a record that
71
+ // cannot be read, which would make provisional trust permanent.
72
+ const tmp = `${file}.tmp`;
73
+ await fs.promises.writeFile(tmp, JSON.stringify([...record], null, 1), "utf8");
74
+ await fs.promises.rename(tmp, file);
75
+ }
76
+
77
+ /** The certificate identified by `thumbprint` was put in `list` only because the server is in the setup state. */
78
+ export async function recordProvisionalTrust(cm: CertificateManager, list: ProvisionalList, thumbprint: string): Promise<void> {
79
+ const record = load(cm);
80
+ const entry = key(list, thumbprint);
81
+ if (record.has(entry)) return;
82
+ record.add(entry);
83
+ await save(cm, record);
84
+ }
85
+
86
+ /** A TrustList write put these certificates in `list`: they are no longer provisional. */
87
+ export async function confirmTrust(cm: CertificateManager, list: ProvisionalList, thumbprints: string[]): Promise<void> {
88
+ const record = load(cm);
89
+ let changed = false;
90
+ for (const thumbprint of thumbprints) {
91
+ changed = record.delete(key(list, thumbprint)) || changed;
92
+ }
93
+ if (changed) {
94
+ await save(cm, record);
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Thumbprints (lowercase hex SHA-1, as the PKI store keys them) of the leaf
100
+ * certificate of each certificate file in a PKI folder. A file that cannot be
101
+ * parsed is skipped with a warning rather than failing the caller.
102
+ */
103
+ export async function leafThumbprintsIn(folder: string): Promise<string[]> {
104
+ if (!fs.existsSync(folder)) return [];
105
+ const thumbprints: string[] = [];
106
+ for (const file of await fs.promises.readdir(folder)) {
107
+ const ext = path.extname(file);
108
+ if (ext !== ".der" && ext !== ".pem") continue;
109
+ try {
110
+ const chain = await readCertificateChainAsync(path.join(folder, file));
111
+ if (chain.length > 0) thumbprints.push(makeSHA1Thumbprint(chain[0]).toString("hex"));
112
+ } catch (err) {
113
+ warningLog(`[TrustList] skipping ${path.join(folder, file)}: not a certificate`, (err as Error).message);
114
+ }
115
+ }
116
+ return thumbprints;
117
+ }
118
+
119
+ /** Whether the trusted list holds at least one certificate that was not trusted provisionally. */
120
+ export async function hasConfirmedTrust(cm: CertificateManager): Promise<boolean> {
121
+ const record = load(cm);
122
+ return (await leafThumbprintsIn(cm.trustedFolder)).some((thumbprint) => !record.has(key("trusted", thumbprint)));
123
+ }
124
+
125
+ /**
126
+ * Remove every provisionally trusted certificate from the TrustList. An
127
+ * issuer that a remaining trusted certificate still needs is kept. Returns
128
+ * the number of certificates removed.
129
+ */
130
+ export async function withdrawProvisionalTrust(cm: CertificateManager): Promise<number> {
131
+ const record = load(cm);
132
+ let withdrawn = 0;
133
+ const entries = [...record];
134
+ for (const entry of entries.filter((e) => e.startsWith("trusted:"))) {
135
+ if (await cm.removeTrustedCertificate(entry.slice("trusted:".length))) withdrawn++;
136
+ }
137
+ for (const entry of entries.filter((e) => e.startsWith("issuers:"))) {
138
+ const issuer = await cm.removeIssuer(entry.slice("issuers:".length));
139
+ if (!issuer) continue;
140
+ if (await cm.isIssuerInUseByTrustedCertificate(issuer)) {
141
+ await cm.addIssuer(issuer);
142
+ } else {
143
+ withdrawn++;
144
+ }
145
+ }
146
+ record.clear();
147
+ await save(cm, record);
148
+ return withdrawn;
149
+ }
150
+
151
+ // ── TrustList stores shared between CertificateGroups ─────────────
152
+
153
+ const sharedStores = new WeakSet<CertificateManager>();
154
+
155
+ /**
156
+ * `a` and `b` back two CertificateGroups with one PKI store. A TrustList
157
+ * write to either group then cannot replace its list without also removing
158
+ * the other group's certificates (see applyTrustListChanges).
159
+ */
160
+ export function markSharedTrustListStore(a: CertificateManager, b: CertificateManager): void {
161
+ if (a === b || path.resolve(a.rootDir) === path.resolve(b.rootDir)) {
162
+ sharedStores.add(a);
163
+ sharedStores.add(b);
164
+ }
165
+ }
166
+
167
+ export function isSharedTrustListStore(cm: CertificateManager): boolean {
168
+ return sharedStores.has(cm);
169
+ }
@@ -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,149 @@ 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
+ /** How long after a TrustList change open channels are re-evaluated (see the "trustListUpdated" listener). */
465
+ const REEVALUATION_DELAY_MS = 250;
466
+
467
+ /**
468
+ * Close every SecureChannel, and the Sessions on it, whose client
469
+ * certificate the server no longer accepts. In the setup state an untrusted
470
+ * certificate is still admitted, so only a revoked or otherwise invalid one
471
+ * closes the channel then. Returns the number of channels closed.
472
+ *
473
+ * The check never trusts a certificate on its own: the relaxation hook,
474
+ * which does, is not called.
475
+ */
476
+ export function closeChannelsNoLongerTrusted(server: OPCUAServer): Promise<number> {
477
+ const run = (reevaluations.get(server) ?? Promise.resolve()).then(() => _closeChannelsNoLongerTrusted(server));
478
+ reevaluations.set(
479
+ server,
480
+ run.catch(() => undefined)
481
+ );
482
+ return run;
483
+ }
484
+
485
+ async function _closeChannelsNoLongerTrusted(server: OPCUAServer): Promise<number> {
486
+ const cm = server.serverCertificateManager;
487
+ let closed = 0;
488
+ for (const endpoint of server.endpoints as OPCUAServerEndPoint[]) {
489
+ for (const channel of endpoint.getChannels()) {
490
+ const certificate = channel.clientCertificate;
491
+ if (!certificate) {
492
+ continue;
493
+ }
494
+ const statusCode = await cm.checkCertificate(certificate);
495
+ if (statusCode.isGood()) {
496
+ continue;
497
+ }
498
+ if (server.engine.getServerState() === ServerState.NoConfiguration && isRelaxableCertificateError(statusCode)) {
499
+ continue;
500
+ }
501
+ warningLog(`[TrustList] closing SecureChannel ${channel.channelId}: client certificate is now ${statusCode.name}`);
502
+ for (const session of server.engine.getSessions()) {
503
+ if (session.channel === channel) {
504
+ server.engine.closeSession(session.authenticationToken, false, "Terminated");
505
+ }
506
+ }
507
+ channel.close();
508
+ closed++;
509
+ }
510
+ }
511
+ return closed;
512
+ }
513
+
514
+ // ── Leaving the application setup state (§G.2) ─────────────────
515
+
516
+ function isSelfSigned(certificate: Certificate): boolean {
517
+ try {
518
+ return verifyCertificateSignature(certificate, certificate);
519
+ } catch {
520
+ return false;
521
+ }
522
+ }
523
+
524
+ /**
525
+ * Move to Running once ApplyChanges has put a CA-issued certificate into
526
+ * service and the TrustList holds a certificate that was not trusted
527
+ * automatically. Leaving earlier would lock every client out, the
528
+ * CertificateManager included.
529
+ */
530
+ async function leaveApplicationSetupIfConfigured(server: OPCUAServer, cm: OPCUACertificateManager): Promise<void> {
531
+ if (server.engine.getServerState() !== ServerState.NoConfiguration) {
532
+ return;
533
+ }
534
+ if (isSelfSigned(server.getCertificate())) {
535
+ doDebug && debugLog("[ApplicationSetup] still self-signed: staying in NoConfiguration");
536
+ return;
537
+ }
538
+ if (!(await hasConfirmedTrust(cm))) {
539
+ warningLog(
540
+ "[ApplicationSetup] a CA-issued certificate is in service but no TrustList was written: staying in NoConfiguration"
541
+ );
542
+ return;
543
+ }
544
+ warningLog("[ApplicationSetup] configured: leaving NoConfiguration");
545
+ server.engine.setServerState(ServerState.Running);
546
+ }
547
+
548
+ /** When the server leaves NoConfiguration, remove the certificates trusted automatically during the setup. */
549
+ function withdrawProvisionalTrustOnLeavingSetup(server: OPCUAServer, cm: OPCUACertificateManager): void {
550
+ const onStateChanged = (oldState: ServerState, newState: ServerState) => {
551
+ if (newState === ServerState.Shutdown) {
552
+ server.engine.removeListener("serverStateChanged", onStateChanged);
553
+ return;
554
+ }
555
+ if (oldState !== ServerState.NoConfiguration || newState === ServerState.NoConfiguration) {
556
+ return;
557
+ }
558
+ withdrawProvisionalTrust(cm)
559
+ .then(async (withdrawn) => {
560
+ if (withdrawn > 0) {
561
+ warningLog(
562
+ `[ApplicationSetup] setup ended: ${withdrawn} certificate(s) trusted automatically are no longer trusted`
563
+ );
564
+ }
565
+ await closeChannelsNoLongerTrusted(server);
566
+ })
567
+ .catch((err) => errorLog("[ApplicationSetup] cannot withdraw provisional trust:", (err as Error).message));
568
+ };
569
+ server.engine.on("serverStateChanged", onStateChanged);
379
570
  }
380
571
 
381
572
  // ── Certificate relaxation for NoConfiguration state ──────────
@@ -403,7 +594,10 @@ export async function installPushCertificateManagementOnServer(
403
594
  * relaxation hook returns the original status code unchanged and
404
595
  * normal strict certificate validation applies. The accepted
405
596
  * certificate is auto-trusted (see `autoTrustCertificateChain`)
406
- * so that subsequent connections succeed under normal validation.
597
+ * so that subsequent connections succeed under normal validation;
598
+ * with `applicationSetup.leaveWhenConfigured` that trust is
599
+ * withdrawn again when the setup ends, unless a TrustList write
600
+ * kept it.
407
601
  *
408
602
  * Errors that indicate an active security violation (revoked,
409
603
  * expired, invalid signature, wrong usage) are **never** relaxed,
@@ -432,7 +626,7 @@ function isRelaxableCertificateError(statusCode: StatusCode): boolean {
432
626
  * connection from unintentionally granting trust to every
433
627
  * certificate signed by the same CA.
434
628
  */
435
- async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certificate): Promise<void> {
629
+ async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certificate, recordProvisional: boolean): Promise<void> {
436
630
  let chain: Certificate[];
437
631
  try {
438
632
  chain = split_der(certificate);
@@ -456,8 +650,12 @@ async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certi
456
650
  }
457
651
 
458
652
  if (i === 0) {
459
- // Leaf certificate → trust explicitly
653
+ // Leaf certificate → trust explicitly, and remember that it was
654
+ // trusted only because the server is in the setup state.
460
655
  try {
656
+ if (recordProvisional && (await cm.isCertificateTrusted(cert)) !== "Good") {
657
+ await recordProvisionalTrust(cm, "trusted", makeSHA1Thumbprint(cert).toString("hex"));
658
+ }
461
659
  await cm.trustCertificate(cert);
462
660
  } catch (err) {
463
661
  // ENOENT can happen if another concurrent call already
@@ -470,6 +668,12 @@ async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certi
470
668
  // Issuer CA certificate → add to issuers/ (chain-building
471
669
  // only, does NOT establish a trust anchor)
472
670
  try {
671
+ if (recordProvisional) {
672
+ const thumbprint = makeSHA1Thumbprint(cert).toString("hex");
673
+ if (!(await leafThumbprintsIn(cm.issuersCertFolder)).includes(thumbprint)) {
674
+ await recordProvisionalTrust(cm, "issuers", thumbprint);
675
+ }
676
+ }
473
677
  await cm.addIssuer(cert);
474
678
  } catch (err) {
475
679
  warningLog("[NoConfiguration] Failed to add issuer certificate:", (err as Error).message);
@@ -490,7 +694,7 @@ async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certi
490
694
  * validation. Issuer CAs are placed in `issuers/` for chain
491
695
  * building but do not become trust anchors.
492
696
  */
493
- function installCertificateRelaxationHook(server: OPCUAServer): void {
697
+ function installCertificateRelaxationHook(server: OPCUAServer, recordProvisional: boolean): void {
494
698
  const adjustCertificateStatus = async (statusCode: StatusCode, certificate: Certificate): Promise<StatusCode> => {
495
699
  // Only relax in NoConfiguration state
496
700
  if (server.engine.getServerState() !== ServerState.NoConfiguration) {
@@ -510,7 +714,7 @@ function installCertificateRelaxationHook(server: OPCUAServer): void {
510
714
  );
511
715
 
512
716
  // Auto-trust the leaf certificate; issuer CAs go to issuers/
513
- await autoTrustCertificateChain(server, certificate);
717
+ await autoTrustCertificateChain(server, certificate, recordProvisional);
514
718
 
515
719
  return StatusCodes.Good;
516
720
  };
@@ -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);