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.
- package/dist/clientTools/push_certificate_management_client.d.ts.map +1 -1
- package/dist/clientTools/push_certificate_management_client.js +4 -1
- package/dist/clientTools/push_certificate_management_client.js.map +1 -1
- package/dist/server/application_setup.d.ts +29 -0
- package/dist/server/application_setup.d.ts.map +1 -0
- package/dist/server/application_setup.js +157 -0
- package/dist/server/application_setup.js.map +1 -0
- package/dist/server/install_push_certificate_management.d.ts +44 -0
- package/dist/server/install_push_certificate_management.d.ts.map +1 -1
- package/dist/server/install_push_certificate_management.js +150 -9
- package/dist/server/install_push_certificate_management.js.map +1 -1
- package/dist/server/promote_trust_list.d.ts.map +1 -1
- package/dist/server/promote_trust_list.js +74 -29
- package/dist/server/promote_trust_list.js.map +1 -1
- package/dist/server/push_certificate_manager_helpers.d.ts.map +1 -1
- package/dist/server/push_certificate_manager_helpers.js +4 -0
- package/dist/server/push_certificate_manager_helpers.js.map +1 -1
- package/package.json +16 -16
- package/source/clientTools/push_certificate_management_client.ts +4 -1
- package/source/server/application_setup.ts +169 -0
- package/source/server/install_push_certificate_management.ts +213 -9
- package/source/server/promote_trust_list.ts +85 -35
- package/source/server/push_certificate_manager_helpers.ts +4 -0
|
@@ -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 {
|
|
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
|
|
327
|
-
// a
|
|
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
|
-
//
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
-
|
|
351
|
-
|
|
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);
|