node-opcua-server-configuration 2.177.0 → 2.179.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/clientTools/push_certificate_management_client.js +1 -1
- package/dist/clientTools/push_certificate_management_client.js.map +1 -1
- package/dist/server/file_transaction_manager.js +26 -13
- package/dist/server/file_transaction_manager.js.map +1 -1
- package/dist/server/install_certificate_file_watcher.js +4 -2
- package/dist/server/install_certificate_file_watcher.js.map +1 -1
- package/dist/server/install_push_certificate_management.d.ts +35 -3
- package/dist/server/install_push_certificate_management.js +119 -27
- package/dist/server/install_push_certificate_management.js.map +1 -1
- package/dist/server/promote_trust_list.js +6 -6
- package/dist/server/promote_trust_list.js.map +1 -1
- package/dist/server/push_certificate_manager/create_signing_request.js.map +1 -1
- package/dist/server/push_certificate_manager/update_certificate.js +34 -4
- package/dist/server/push_certificate_manager/update_certificate.js.map +1 -1
- package/dist/server/push_certificate_manager_helpers.js +8 -4
- package/dist/server/push_certificate_manager_helpers.js.map +1 -1
- package/dist/server/push_certificate_manager_server_impl.d.ts +4 -2
- package/dist/server/push_certificate_manager_server_impl.js +0 -3
- package/dist/server/push_certificate_manager_server_impl.js.map +1 -1
- package/dist/server/tools.js +1 -1
- package/dist/server/tools.js.map +1 -1
- package/package.json +30 -30
- package/source/clientTools/push_certificate_management_client.ts +1 -1
- package/source/server/file_transaction_manager.ts +26 -13
- package/source/server/install_certificate_file_watcher.ts +5 -4
- package/source/server/install_push_certificate_management.ts +175 -58
- package/source/server/promote_trust_list.ts +16 -15
- package/source/server/push_certificate_manager/create_signing_request.ts +4 -2
- package/source/server/push_certificate_manager/update_certificate.ts +36 -7
- package/source/server/push_certificate_manager_helpers.ts +9 -5
- package/source/server/push_certificate_manager_server_impl.ts +6 -9
- package/source/server/tools.ts +1 -1
|
@@ -5,10 +5,11 @@
|
|
|
5
5
|
import fs from "node:fs";
|
|
6
6
|
import os from "node:os";
|
|
7
7
|
import path from "node:path";
|
|
8
|
-
import { make_debugLog, make_errorLog, make_warningLog } from "node-opcua-debug";
|
|
8
|
+
import { checkDebugFlag, make_debugLog, make_errorLog, make_warningLog } from "node-opcua-debug";
|
|
9
9
|
import { randomBytes } from "node-opcua-utils";
|
|
10
10
|
|
|
11
11
|
const debugLog = make_debugLog("ServerConfiguration");
|
|
12
|
+
const doDebug = checkDebugFlag("ServerConfiguration");
|
|
12
13
|
const errorLog = make_errorLog("ServerConfiguration");
|
|
13
14
|
const warningLog = make_warningLog("ServerConfiguration");
|
|
14
15
|
|
|
@@ -16,7 +17,8 @@ type Functor = () => Promise<void>;
|
|
|
16
17
|
|
|
17
18
|
async function _copyFile(source: string, dest: string): Promise<void> {
|
|
18
19
|
try {
|
|
19
|
-
|
|
20
|
+
// c8 ignore next
|
|
21
|
+
doDebug && debugLog("copying file \n source ", source, "\n =>\n dest ", dest);
|
|
20
22
|
const sourceExist = fs.existsSync(source);
|
|
21
23
|
if (sourceExist) {
|
|
22
24
|
await fs.promises.copyFile(source, dest);
|
|
@@ -30,7 +32,8 @@ async function _deleteFile(file: string): Promise<void> {
|
|
|
30
32
|
try {
|
|
31
33
|
const exists = fs.existsSync(file);
|
|
32
34
|
if (exists) {
|
|
33
|
-
|
|
35
|
+
// c8 ignore next
|
|
36
|
+
doDebug && debugLog("deleting file ", file);
|
|
34
37
|
await fs.promises.unlink(file);
|
|
35
38
|
}
|
|
36
39
|
} catch (err) {
|
|
@@ -39,7 +42,8 @@ async function _deleteFile(file: string): Promise<void> {
|
|
|
39
42
|
}
|
|
40
43
|
|
|
41
44
|
async function _moveFile(source: string, dest: string): Promise<void> {
|
|
42
|
-
|
|
45
|
+
// c8 ignore next
|
|
46
|
+
doDebug && debugLog("moving file file \n source ", source, "\n =>\n dest ", dest);
|
|
43
47
|
try {
|
|
44
48
|
await _copyFile(source, dest);
|
|
45
49
|
await _deleteFile(source);
|
|
@@ -50,7 +54,8 @@ async function _moveFile(source: string, dest: string): Promise<void> {
|
|
|
50
54
|
|
|
51
55
|
async function _moveFileWithBackup(source: string, dest: string, backupPath: string): Promise<void> {
|
|
52
56
|
// let make a copy of the destination file
|
|
53
|
-
|
|
57
|
+
// c8 ignore next
|
|
58
|
+
doDebug && debugLog("moveFileWithBackup file \n source ", source, "\n =>\n dest ", dest);
|
|
54
59
|
await _copyFile(dest, backupPath);
|
|
55
60
|
await _moveFile(source, dest);
|
|
56
61
|
}
|
|
@@ -161,7 +166,8 @@ export class FileTransactionManager {
|
|
|
161
166
|
* Commit the transaction by executing all pending file operations.
|
|
162
167
|
*/
|
|
163
168
|
public async applyFileOps(): Promise<void> {
|
|
164
|
-
|
|
169
|
+
// c8 ignore next
|
|
170
|
+
doDebug && debugLog("start applyFileOps");
|
|
165
171
|
const fileOperation = this.#pendingFileOps.splice(0);
|
|
166
172
|
|
|
167
173
|
try {
|
|
@@ -169,7 +175,8 @@ export class FileTransactionManager {
|
|
|
169
175
|
const op = fileOperation.shift();
|
|
170
176
|
await op?.();
|
|
171
177
|
}
|
|
172
|
-
|
|
178
|
+
// c8 ignore next
|
|
179
|
+
doDebug && debugLog("end applyFileOps");
|
|
173
180
|
|
|
174
181
|
// Transaction successful - clean up backup files
|
|
175
182
|
await this.#cleanupBackupFiles();
|
|
@@ -182,7 +189,8 @@ export class FileTransactionManager {
|
|
|
182
189
|
// Rollback: restore all backup files to their original locations
|
|
183
190
|
try {
|
|
184
191
|
await this.#rollbackTransaction();
|
|
185
|
-
|
|
192
|
+
// c8 ignore next
|
|
193
|
+
doDebug && debugLog("Transaction rollback successful");
|
|
186
194
|
} catch (rollbackErr) {
|
|
187
195
|
errorLog("Critical: Rollback failed:", (rollbackErr as Error).message);
|
|
188
196
|
errorLog("Certificate state may be inconsistent - manual intervention required");
|
|
@@ -202,7 +210,8 @@ export class FileTransactionManager {
|
|
|
202
210
|
* This restores files from their *_old backups to recover the previous state.
|
|
203
211
|
*/
|
|
204
212
|
async #rollbackTransaction(): Promise<void> {
|
|
205
|
-
|
|
213
|
+
// c8 ignore next
|
|
214
|
+
doDebug && debugLog("Rolling back transaction, restoring", this.#backupFiles.size, "backup files");
|
|
206
215
|
|
|
207
216
|
const rollbackPromises: Promise<void>[] = [];
|
|
208
217
|
|
|
@@ -212,7 +221,8 @@ export class FileTransactionManager {
|
|
|
212
221
|
try {
|
|
213
222
|
// Check if backup exists before trying to restore
|
|
214
223
|
if (fs.existsSync(backupPath)) {
|
|
215
|
-
|
|
224
|
+
// c8 ignore next
|
|
225
|
+
doDebug && debugLog("Restoring backup:", backupPath, "to", dest);
|
|
216
226
|
await _copyFile(backupPath, dest);
|
|
217
227
|
// Delete backup immediately after restoration
|
|
218
228
|
await _deleteFile(backupPath);
|
|
@@ -225,7 +235,8 @@ export class FileTransactionManager {
|
|
|
225
235
|
}
|
|
226
236
|
|
|
227
237
|
await Promise.all(rollbackPromises);
|
|
228
|
-
|
|
238
|
+
// c8 ignore next
|
|
239
|
+
doDebug && debugLog("Transaction rollback completed");
|
|
229
240
|
}
|
|
230
241
|
|
|
231
242
|
/**
|
|
@@ -233,7 +244,8 @@ export class FileTransactionManager {
|
|
|
233
244
|
* Removes all *_old backup files that were created during the transaction.
|
|
234
245
|
*/
|
|
235
246
|
async #cleanupBackupFiles(): Promise<void> {
|
|
236
|
-
|
|
247
|
+
// c8 ignore next
|
|
248
|
+
doDebug && debugLog("Cleaning up", this.#backupFiles.size, "backup files");
|
|
237
249
|
|
|
238
250
|
const cleanupPromises: Promise<void>[] = [];
|
|
239
251
|
|
|
@@ -265,7 +277,8 @@ export class FileTransactionManager {
|
|
|
265
277
|
}
|
|
266
278
|
|
|
267
279
|
async #executeCleanupTasks(): Promise<void> {
|
|
268
|
-
|
|
280
|
+
// c8 ignore next
|
|
281
|
+
doDebug && debugLog("Executing cleanup tasks");
|
|
269
282
|
const tasks = this.#cleanupTasks.splice(0);
|
|
270
283
|
for (const task of tasks) {
|
|
271
284
|
try {
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import type { ITypedEventEmitter, UAObject, UAObjectEvents } from "node-opcua-address-space-base";
|
|
4
|
-
import { make_debugLog } from "node-opcua-debug";
|
|
4
|
+
import { checkDebugFlag, make_debugLog } from "node-opcua-debug";
|
|
5
5
|
|
|
6
6
|
const debugLog = make_debugLog("ServerConfiguration");
|
|
7
|
+
const doDebug = checkDebugFlag("ServerConfiguration");
|
|
7
8
|
|
|
8
|
-
|
|
9
|
-
export interface CertificateChangeEvents extends UAObjectEvents {
|
|
9
|
+
export interface CertificateChangeEvents extends UAObjectEvents {
|
|
10
10
|
certificateChange: () => void;
|
|
11
11
|
}
|
|
12
12
|
export function installCertificateFileWatcher(
|
|
@@ -20,7 +20,8 @@ export function installCertificateFileWatcher(
|
|
|
20
20
|
(_eventType: "rename" | "change", filename) => {
|
|
21
21
|
/** */
|
|
22
22
|
if (filename === fileToWatch) {
|
|
23
|
-
|
|
23
|
+
// c8 ignore next
|
|
24
|
+
doDebug && debugLog("filename changed = ", filename, fileToWatch);
|
|
24
25
|
node.emit("certificateChange");
|
|
25
26
|
}
|
|
26
27
|
}
|
|
@@ -8,10 +8,17 @@ import chalk from "chalk";
|
|
|
8
8
|
import type { AddressSpace, UAServerConfiguration } from "node-opcua-address-space";
|
|
9
9
|
import { assert } from "node-opcua-assert";
|
|
10
10
|
import { OPCUACertificateManager } from "node-opcua-certificate-manager";
|
|
11
|
-
import {
|
|
12
|
-
|
|
11
|
+
import {
|
|
12
|
+
DiskCertificateKeyPairProvider,
|
|
13
|
+
type ICertificateChainProvider,
|
|
14
|
+
type ICertificateKeyPairProvider,
|
|
15
|
+
ResolvedCertificateKeyPairProvider,
|
|
16
|
+
resolvePrivateKeyProviderIfNeeded
|
|
17
|
+
} from "node-opcua-common";
|
|
18
|
+
import { readPrivateKey } from "node-opcua-crypto";
|
|
19
|
+
import { type Certificate, exploreCertificateInfo, split_der } from "node-opcua-crypto/web";
|
|
13
20
|
import { checkDebugFlag, make_debugLog, make_errorLog, make_warningLog } from "node-opcua-debug";
|
|
14
|
-
import {
|
|
21
|
+
import type { OPCUAServer, OPCUAServerEndPoint } from "node-opcua-server";
|
|
15
22
|
import { type StatusCode, StatusCodes } from "node-opcua-status-code";
|
|
16
23
|
import { type ApplicationDescriptionOptions, ServerState } from "node-opcua-types";
|
|
17
24
|
|
|
@@ -35,6 +42,7 @@ export interface OPCUAServerPartial extends ICertificateKeyPairProvider {
|
|
|
35
42
|
createDefaultCertificate(): Promise<void>;
|
|
36
43
|
setProvider(provider: ICertificateKeyPairProvider): void;
|
|
37
44
|
invalidateCachedCertificates(): void;
|
|
45
|
+
getCertificateChainProvider(): ICertificateChainProvider;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
async function onCertificateAboutToChange(server: OPCUAServer) {
|
|
@@ -56,25 +64,106 @@ async function onCertificateAboutToChange(server: OPCUAServer) {
|
|
|
56
64
|
*/
|
|
57
65
|
async function onCertificateChange(server: OPCUAServer) {
|
|
58
66
|
doDebug && debugLog("on CertificateChanged");
|
|
59
|
-
|
|
67
|
+
await reresolveServerCertificateProvider(server);
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* the
|
|
71
|
+
* Resolve the private key immediately after a rotation, bypassing
|
|
72
|
+
* `OPCUACertificateManager.getPrivateKey()`'s cache.
|
|
73
|
+
*
|
|
74
|
+
* `getPrivateKey()` decrypts the on-disk key *once* and caches it for the
|
|
75
|
+
* manager instance's whole lifetime (pki's documented behavior — see
|
|
76
|
+
* `CertificateManager.getPrivateKey`). That's exactly right for a normal
|
|
77
|
+
* startup resolve (`resolvePrivateKeyProviderIfNeeded`, used by
|
|
78
|
+
* `initializeCM()`), but wrong here: `UpdateCertificate`/`ApplyChanges` just
|
|
79
|
+
* replaced the on-disk key file underneath this *same, long-lived*
|
|
80
|
+
* `certificateManager` instance, so a cached call would silently keep
|
|
81
|
+
* returning the pre-rotation key — certificate and key would then belong to
|
|
82
|
+
* different key pairs, and every subsequent handshake fails signature
|
|
83
|
+
* verification.
|
|
84
|
+
*
|
|
85
|
+
* `privateKeyProvider` mode has no such cache — pki consults the injected
|
|
86
|
+
* provider on every call — so this only special-cases `privateKeyPassphrase`
|
|
87
|
+
* (reading the file directly, with the manager's own configured passphrase,
|
|
88
|
+
* instead of going through the memoized `getPrivateKey()`).
|
|
89
|
+
*/
|
|
90
|
+
async function resolvePrivateKeyAfterRotation(certificateManager: OPCUACertificateManager, privateKeyFile: string) {
|
|
91
|
+
const passphrase = await certificateManager.getPrivateKeyPassphrase();
|
|
92
|
+
if (passphrase !== undefined) {
|
|
93
|
+
return readPrivateKey(privateKeyFile, passphrase);
|
|
94
|
+
}
|
|
95
|
+
return certificateManager.getPrivateKey();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Refresh the server's certificate/private-key provider after a rotation,
|
|
100
|
+
* and push the result to every endpoint.
|
|
101
|
+
*
|
|
102
|
+
* When the certificate manager is passphrase-/provider-managed, a
|
|
103
|
+
* synchronous "invalidate, let it lazily re-read the file" (the old
|
|
104
|
+
* behavior) cannot decrypt the key — so the key is instead resolved here,
|
|
105
|
+
* eagerly and asynchronously (see `resolvePrivateKeyAfterRotation`), and the
|
|
106
|
+
* resulting provider handed out explicitly to the server and every
|
|
107
|
+
* endpoint.
|
|
108
|
+
*
|
|
109
|
+
* For a plain (unmanaged) certificate manager, this falls back to exactly
|
|
110
|
+
* the previous behavior: invalidate the server's and every endpoint's
|
|
111
|
+
* cached certificate/key so the next access re-reads the (plaintext) files
|
|
112
|
+
* from disk.
|
|
113
|
+
*/
|
|
114
|
+
async function reresolveServerCertificateProvider(server: OPCUAServer): Promise<void> {
|
|
115
|
+
const certificateManager = server.serverCertificateManager;
|
|
116
|
+
const isManaged = certificateManager instanceof OPCUACertificateManager && certificateManager.isPrivateKeyManaged();
|
|
117
|
+
if (isManaged) {
|
|
118
|
+
const privateKey = await resolvePrivateKeyAfterRotation(certificateManager, server.privateKeyFile);
|
|
119
|
+
server.setProvider(new ResolvedCertificateKeyPairProvider(server.certificateFile, server.privateKeyFile, privateKey));
|
|
120
|
+
const provider = server.getCertificateChainProvider();
|
|
121
|
+
for (const endpoint of server.endpoints) {
|
|
122
|
+
endpoint.setCertificateProvider(provider);
|
|
123
|
+
}
|
|
124
|
+
} else {
|
|
125
|
+
server.invalidateCachedCertificates();
|
|
126
|
+
for (const endpoint of server.endpoints) {
|
|
127
|
+
endpoint.invalidateCertificates();
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Deferred step, called after the ApplyChanges method response has been
|
|
134
|
+
* sent (so the admin client gets its answer before its own channel is
|
|
135
|
+
* affected).
|
|
136
|
+
*
|
|
137
|
+
* With `closeChannelsOnApplyChanges` (the default) every existing secure
|
|
138
|
+
* channel is shut down immediately, forcing all connected clients to
|
|
139
|
+
* reconnect with the new certificate right away. Without it, existing
|
|
140
|
+
* channels are left alone and continue — including OpenSecureChannel
|
|
141
|
+
* *Renews* — under the certificate/key pair each channel is bound to,
|
|
142
|
+
* while new connections use the new pair. See
|
|
143
|
+
* {@link InstallPushCertificateManagementOnServerOptions.closeChannelsOnApplyChanges}
|
|
144
|
+
* for when each is appropriate.
|
|
145
|
+
*
|
|
146
|
+
* Either way the endpoints, suspended in `onCertificateAboutToChange`,
|
|
147
|
+
* are resumed here.
|
|
67
148
|
*/
|
|
68
|
-
async function onApplyChangesCompleted(server: OPCUAServer) {
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
149
|
+
async function onApplyChangesCompleted(server: OPCUAServer, closeChannels: boolean) {
|
|
150
|
+
if (closeChannels) {
|
|
151
|
+
doDebug && debugLog(chalk.yellow(" onApplyChangesCompleted => shutting down channels"));
|
|
152
|
+
await server.shutdownChannels();
|
|
153
|
+
doDebug && debugLog(chalk.yellow(" onApplyChangesCompleted => channels shut down"));
|
|
154
|
+
} else {
|
|
155
|
+
doDebug &&
|
|
156
|
+
debugLog(chalk.yellow(" onApplyChangesCompleted => leaving existing channels open (bound to their original pair)"));
|
|
157
|
+
}
|
|
72
158
|
|
|
73
159
|
doDebug && debugLog(chalk.yellow(" onApplyChangesCompleted => resuming end points"));
|
|
74
160
|
await server.resumeEndPoints();
|
|
75
161
|
doDebug && debugLog(chalk.yellow(" onApplyChangesCompleted => end points resumed"));
|
|
76
162
|
|
|
77
|
-
|
|
163
|
+
if (closeChannels) {
|
|
164
|
+
// c8 ignore next
|
|
165
|
+
doDebug && debugLog(chalk.yellow("channels have been closed -> client should reconnect "));
|
|
166
|
+
}
|
|
78
167
|
}
|
|
79
168
|
|
|
80
169
|
/**
|
|
@@ -88,9 +177,11 @@ async function install(this: OPCUAServerPartial): Promise<void> {
|
|
|
88
177
|
const certFile = path.join(this.serverCertificateManager.rootDir, CERT_PEM_RELATIVE_PATH);
|
|
89
178
|
const keyFile = this.serverCertificateManager.privateKey;
|
|
90
179
|
|
|
91
|
-
// Inject a new disk provider pointing at the cert manager's
|
|
92
|
-
//
|
|
93
|
-
//
|
|
180
|
+
// Inject a new disk provider pointing at the cert manager's paths. The
|
|
181
|
+
// server's certificateFile/privateKeyFile getters now automatically
|
|
182
|
+
// return the new paths. If the key turns out to be passphrase-encrypted,
|
|
183
|
+
// resolvePrivateKeyProviderIfNeeded() below immediately replaces this
|
|
184
|
+
// with a resolved, in-memory provider.
|
|
94
185
|
this.setProvider(new DiskCertificateKeyPairProvider(certFile, keyFile));
|
|
95
186
|
|
|
96
187
|
// Delegate to the base server's createDefaultCertificate() which
|
|
@@ -98,20 +189,59 @@ async function install(this: OPCUAServerPartial): Promise<void> {
|
|
|
98
189
|
// proper subject via makeSubject(), mutex locking, and file checks.
|
|
99
190
|
await this.createDefaultCertificate();
|
|
100
191
|
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
192
|
+
// Resolve the (possibly passphrase-encrypted) private key once,
|
|
193
|
+
// asynchronously, and install the resolved provider — a synchronous
|
|
194
|
+
// disk re-read (the old invalidateCachedCertificates() behavior) cannot
|
|
195
|
+
// decrypt an encrypted key. For a plaintext key this just replaces the
|
|
196
|
+
// disk provider above with an equivalent resolved one.
|
|
197
|
+
await resolvePrivateKeyProviderIfNeeded(this, this.serverCertificateManager, false);
|
|
104
198
|
}
|
|
105
199
|
|
|
106
200
|
interface UAServerConfigurationEx extends UAServerConfiguration {
|
|
107
201
|
$pushCertificateManager: PushCertificateManagerServerImpl;
|
|
108
202
|
}
|
|
109
203
|
|
|
110
|
-
export
|
|
111
|
-
|
|
204
|
+
export interface InstallPushCertificateManagementOnServerOptions {
|
|
205
|
+
/**
|
|
206
|
+
* What happens to secure channels that are already open when
|
|
207
|
+
* `ApplyChanges` installs a new server certificate (and possibly a new
|
|
208
|
+
* private key).
|
|
209
|
+
*
|
|
210
|
+
* - `true` (default): every existing channel is shut down as soon as
|
|
211
|
+
* the ApplyChanges response has been sent. All connected clients
|
|
212
|
+
* reconnect immediately with the new certificate. This is the safe
|
|
213
|
+
* choice when the rotation is a response to a **compromised or
|
|
214
|
+
* revoked** key: nothing keeps running under the old one.
|
|
215
|
+
*
|
|
216
|
+
* - `false`: existing channels are left open and the rotation is
|
|
217
|
+
* **seamless** for them. Each server channel is bound to the
|
|
218
|
+
* certificate/key pair it was created with (OPC UA Part 6 §6.7.2
|
|
219
|
+
* defines the thumbprint check against "the Certificate it is using
|
|
220
|
+
* for the SecureChannel", and §6.7.4 requires the client to close the
|
|
221
|
+
* channel if a response is signed with a different certificate than
|
|
222
|
+
* the request was encrypted to — so the old pair is the only one a
|
|
223
|
+
* Renew on an old channel can conformantly use). Connected clients
|
|
224
|
+
* keep renewing their security token under the old pair, unmodified
|
|
225
|
+
* third-party clients included; new connections use the new pair.
|
|
226
|
+
* Choose this for a **planned** rotation. The old pair remains in use
|
|
227
|
+
* by those channels until they close naturally — which is exactly why
|
|
228
|
+
* `true` stays the default: when the rotation answers a **compromised
|
|
229
|
+
* or revoked** key, nothing must keep running under it.
|
|
230
|
+
*
|
|
231
|
+
* @defaultValue true
|
|
232
|
+
*/
|
|
233
|
+
closeChannelsOnApplyChanges?: boolean;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export async function installPushCertificateManagementOnServer(
|
|
237
|
+
server: OPCUAServer,
|
|
238
|
+
options?: InstallPushCertificateManagementOnServerOptions
|
|
239
|
+
): Promise<void> {
|
|
240
|
+
const closeChannelsOnApplyChanges = options?.closeChannelsOnApplyChanges ?? true;
|
|
241
|
+
if (!server.engine?.addressSpace) {
|
|
112
242
|
throw new Error(
|
|
113
243
|
"Server must have a valid address space. " +
|
|
114
|
-
|
|
244
|
+
"You need to call installPushCertificateManagementOnServer after server has been initialized"
|
|
115
245
|
);
|
|
116
246
|
}
|
|
117
247
|
await install.call(server as unknown as OPCUAServerPartial);
|
|
@@ -124,15 +254,19 @@ export async function installPushCertificateManagementOnServer(server: OPCUAServ
|
|
|
124
254
|
if (!(server.serverCertificateManager instanceof OPCUACertificateManager)) {
|
|
125
255
|
throw new Error(
|
|
126
256
|
"installPushCertificateManagementOnServer requires a" +
|
|
127
|
-
|
|
128
|
-
|
|
257
|
+
" disk-based OPCUACertificateManager as" +
|
|
258
|
+
" serverCertificateManager"
|
|
129
259
|
);
|
|
130
260
|
}
|
|
131
261
|
const cm = server.serverCertificateManager;
|
|
132
|
-
|
|
133
|
-
|
|
262
|
+
// Reuse the server's own provider (installed by install() above, and
|
|
263
|
+
// already resolved if the key is passphrase-encrypted) rather than
|
|
264
|
+
// constructing a fresh DiskCertificateKeyPairProvider from the file
|
|
265
|
+
// paths — a plain disk provider would re-read the file and fail
|
|
266
|
+
// synchronously on an encrypted key.
|
|
267
|
+
const provider = server.getCertificateChainProvider();
|
|
134
268
|
for (const endpoint of server.endpoints) {
|
|
135
|
-
endpoint.setCertificateProvider(
|
|
269
|
+
endpoint.setCertificateProvider(provider);
|
|
136
270
|
}
|
|
137
271
|
|
|
138
272
|
await installPushCertificateManagement(server.engine.addressSpace, {
|
|
@@ -162,11 +296,11 @@ export async function installPushCertificateManagementOnServer(server: OPCUAServ
|
|
|
162
296
|
});
|
|
163
297
|
|
|
164
298
|
serverConfigurationPriv.$pushCertificateManager.on("applyChangesCompleted", () => {
|
|
165
|
-
// Fire-and-forget: schedule channel teardown + endpoint
|
|
299
|
+
// Fire-and-forget: schedule (optional) channel teardown + endpoint
|
|
166
300
|
// resumption AFTER the method response is sent.
|
|
167
301
|
setImmediate(async () => {
|
|
168
302
|
try {
|
|
169
|
-
await onApplyChangesCompleted(server);
|
|
303
|
+
await onApplyChangesCompleted(server, closeChannelsOnApplyChanges);
|
|
170
304
|
} catch (err) {
|
|
171
305
|
errorLog("onApplyChangesCompleted error:", (err as Error).message);
|
|
172
306
|
}
|
|
@@ -238,18 +372,12 @@ function isRelaxableCertificateError(statusCode: StatusCode): boolean {
|
|
|
238
372
|
* connection from unintentionally granting trust to every
|
|
239
373
|
* certificate signed by the same CA.
|
|
240
374
|
*/
|
|
241
|
-
async function autoTrustCertificateChain(
|
|
242
|
-
server: OPCUAServer,
|
|
243
|
-
certificate: Certificate
|
|
244
|
-
): Promise<void> {
|
|
375
|
+
async function autoTrustCertificateChain(server: OPCUAServer, certificate: Certificate): Promise<void> {
|
|
245
376
|
let chain: Certificate[];
|
|
246
377
|
try {
|
|
247
378
|
chain = split_der(certificate);
|
|
248
379
|
} catch (err) {
|
|
249
|
-
warningLog(
|
|
250
|
-
"[NoConfiguration] Cannot parse certificate chain for auto-trust:",
|
|
251
|
-
(err as Error).message
|
|
252
|
-
);
|
|
380
|
+
warningLog("[NoConfiguration] Cannot parse certificate chain for auto-trust:", (err as Error).message);
|
|
253
381
|
return;
|
|
254
382
|
}
|
|
255
383
|
|
|
@@ -263,10 +391,7 @@ async function autoTrustCertificateChain(
|
|
|
263
391
|
try {
|
|
264
392
|
exploreCertificateInfo(cert);
|
|
265
393
|
} catch (err) {
|
|
266
|
-
warningLog(
|
|
267
|
-
"[NoConfiguration] Skipping invalid certificate in chain for auto-trust:",
|
|
268
|
-
(err as Error).message
|
|
269
|
-
);
|
|
394
|
+
warningLog("[NoConfiguration] Skipping invalid certificate in chain for auto-trust:", (err as Error).message);
|
|
270
395
|
continue;
|
|
271
396
|
}
|
|
272
397
|
|
|
@@ -278,10 +403,7 @@ async function autoTrustCertificateChain(
|
|
|
278
403
|
// ENOENT can happen if another concurrent call already
|
|
279
404
|
// moved the cert from rejected to trusted.
|
|
280
405
|
if ((err as Error & { code?: string }).code !== "ENOENT") {
|
|
281
|
-
warningLog(
|
|
282
|
-
"[NoConfiguration] Failed to auto-trust leaf certificate:",
|
|
283
|
-
(err as Error).message
|
|
284
|
-
);
|
|
406
|
+
warningLog("[NoConfiguration] Failed to auto-trust leaf certificate:", (err as Error).message);
|
|
285
407
|
}
|
|
286
408
|
}
|
|
287
409
|
} else {
|
|
@@ -290,10 +412,7 @@ async function autoTrustCertificateChain(
|
|
|
290
412
|
try {
|
|
291
413
|
await cm.addIssuer(cert);
|
|
292
414
|
} catch (err) {
|
|
293
|
-
warningLog(
|
|
294
|
-
"[NoConfiguration] Failed to add issuer certificate:",
|
|
295
|
-
(err as Error).message
|
|
296
|
-
);
|
|
415
|
+
warningLog("[NoConfiguration] Failed to add issuer certificate:", (err as Error).message);
|
|
297
416
|
}
|
|
298
417
|
}
|
|
299
418
|
}
|
|
@@ -312,10 +431,7 @@ async function autoTrustCertificateChain(
|
|
|
312
431
|
* building but do not become trust anchors.
|
|
313
432
|
*/
|
|
314
433
|
function installCertificateRelaxationHook(server: OPCUAServer): void {
|
|
315
|
-
const adjustCertificateStatus = async (
|
|
316
|
-
statusCode: StatusCode,
|
|
317
|
-
certificate: Certificate
|
|
318
|
-
): Promise<StatusCode> => {
|
|
434
|
+
const adjustCertificateStatus = async (statusCode: StatusCode, certificate: Certificate): Promise<StatusCode> => {
|
|
319
435
|
// Only relax in NoConfiguration state
|
|
320
436
|
if (server.engine.getServerState() !== ServerState.NoConfiguration) {
|
|
321
437
|
return statusCode;
|
|
@@ -326,11 +442,12 @@ function installCertificateRelaxationHook(server: OPCUAServer): void {
|
|
|
326
442
|
return statusCode;
|
|
327
443
|
}
|
|
328
444
|
|
|
329
|
-
doDebug &&
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
445
|
+
doDebug &&
|
|
446
|
+
warningLog(
|
|
447
|
+
`[NoConfiguration] Relaxing certificate check:`,
|
|
448
|
+
`${statusCode.toString()} → Good`,
|
|
449
|
+
"(server is awaiting GDS provisioning)"
|
|
450
|
+
);
|
|
334
451
|
|
|
335
452
|
// Auto-trust the leaf certificate; issuer CAs go to issuers/
|
|
336
453
|
await autoTrustCertificateChain(server, certificate);
|
|
@@ -73,7 +73,7 @@ function trustListIsAlreadyOpened(trustList: UATrustList): boolean {
|
|
|
73
73
|
return false;
|
|
74
74
|
}
|
|
75
75
|
const dataValue = openCountNode.readValue();
|
|
76
|
-
if (!dataValue
|
|
76
|
+
if (!dataValue?.value) {
|
|
77
77
|
return false;
|
|
78
78
|
}
|
|
79
79
|
const openCount = dataValue.value.value as number;
|
|
@@ -109,13 +109,10 @@ function updateLastUpdateTime(trustList: UATrustList): void {
|
|
|
109
109
|
* Uses async fs.promises to avoid blocking the event loop on startup
|
|
110
110
|
* when PKI directories are large or on slow filesystems.
|
|
111
111
|
*/
|
|
112
|
-
async function getNewestMtimeFromPkiStore(
|
|
113
|
-
cm: OPCUACertificateManager,
|
|
114
|
-
isAborted?: () => boolean
|
|
115
|
-
): Promise<Date | null> {
|
|
112
|
+
async function getNewestMtimeFromPkiStore(cm: OPCUACertificateManager, isAborted?: () => boolean): Promise<Date | null> {
|
|
116
113
|
const dirs = [cm.trustedFolder, cm.crlFolder, cm.issuersCertFolder, cm.issuersCrlFolder];
|
|
117
114
|
let newest: Date | null = null;
|
|
118
|
-
|
|
115
|
+
|
|
119
116
|
for (const dir of dirs) {
|
|
120
117
|
if (isAborted?.()) break;
|
|
121
118
|
|
|
@@ -130,7 +127,7 @@ async function getNewestMtimeFromPkiStore(
|
|
|
130
127
|
} catch {
|
|
131
128
|
continue;
|
|
132
129
|
}
|
|
133
|
-
|
|
130
|
+
|
|
134
131
|
// Process stats sequentially to avoid threadpool exhaustion
|
|
135
132
|
// and event-loop lag when directories have thousands of files.
|
|
136
133
|
for (const entry of entries) {
|
|
@@ -174,10 +171,10 @@ async function _initializeLastUpdateTimeFromFilesystem(trustList: UATrustListEx)
|
|
|
174
171
|
|
|
175
172
|
trustList.$$initaliseMTimePromise = (async () => {
|
|
176
173
|
const startTime = Date.now();
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
// Note: Removed abortHandler from registerShutdownTask because AddressSpace
|
|
180
|
-
// does not have an unregister mechanism, which causes `_shutdownTasks` to leak
|
|
174
|
+
const isAborted = false;
|
|
175
|
+
|
|
176
|
+
// Note: Removed abortHandler from registerShutdownTask because AddressSpace
|
|
177
|
+
// does not have an unregister mechanism, which causes `_shutdownTasks` to leak
|
|
181
178
|
// continuously if promoteTrustList is called multiple times.
|
|
182
179
|
// Sequential scanning is fast enough that it won't block shutdown significantly.
|
|
183
180
|
|
|
@@ -193,7 +190,9 @@ async function _initializeLastUpdateTimeFromFilesystem(trustList: UATrustListEx)
|
|
|
193
190
|
if (!currentValue || currentValue.getTime() <= 0) {
|
|
194
191
|
const newest = await getNewestMtimeFromPkiStore(cm, () => isAborted);
|
|
195
192
|
if (isAborted) {
|
|
196
|
-
console.log(
|
|
193
|
+
console.log(
|
|
194
|
+
`[node-opcua] _initializeLastUpdateTimeFromFilesystem aborted for ${trustList.browseName.toString()}`
|
|
195
|
+
);
|
|
197
196
|
return;
|
|
198
197
|
}
|
|
199
198
|
if (newest) {
|
|
@@ -238,7 +237,9 @@ async function _initializeLastUpdateTimeFromFilesystem(trustList: UATrustListEx)
|
|
|
238
237
|
});
|
|
239
238
|
}
|
|
240
239
|
|
|
241
|
-
console.log(
|
|
240
|
+
console.log(
|
|
241
|
+
`[node-opcua] _initializeLastUpdateTimeFromFilesystem took ${Date.now() - startTime}ms for ${trustList.browseName.toString()}`
|
|
242
|
+
);
|
|
242
243
|
} finally {
|
|
243
244
|
trustList.$$initaliseMTimePromise = undefined;
|
|
244
245
|
}
|
|
@@ -464,7 +465,6 @@ function verificationStatusToStatusCode(status: VerificationStatus): StatusCode
|
|
|
464
465
|
return StatusCodes.BadCertificateUntrusted;
|
|
465
466
|
case VerificationStatus.BadSecurityChecksFailed:
|
|
466
467
|
return StatusCodes.BadSecurityChecksFailed;
|
|
467
|
-
case VerificationStatus.BadCertificateInvalid:
|
|
468
468
|
default:
|
|
469
469
|
return StatusCodes.BadCertificateInvalid;
|
|
470
470
|
}
|
|
@@ -662,7 +662,8 @@ export async function promoteTrustList(trustList: UATrustList) {
|
|
|
662
662
|
MemFs.unlinkSync(filename);
|
|
663
663
|
}
|
|
664
664
|
} catch (err) {
|
|
665
|
-
|
|
665
|
+
// c8 ignore next
|
|
666
|
+
doDebug && debugLog("could not release trust list backing file", filename, (err as Error).message);
|
|
666
667
|
}
|
|
667
668
|
});
|
|
668
669
|
|
|
@@ -139,8 +139,10 @@ export async function executeCreateSigningRequest(
|
|
|
139
139
|
errorLog(
|
|
140
140
|
"CreateSigningRequest failed during CSR generation:",
|
|
141
141
|
(err as Error).message,
|
|
142
|
-
"subject=",
|
|
143
|
-
|
|
142
|
+
"subject=",
|
|
143
|
+
subjectName,
|
|
144
|
+
"applicationUri=",
|
|
145
|
+
serverImpl.applicationUri
|
|
144
146
|
);
|
|
145
147
|
return { statusCode: StatusCodes.BadInternalError };
|
|
146
148
|
}
|