Skip to content

Commit 813dee6

Browse files
authored
Merge pull request #1415 from node-opcua/feat/binding_opcua_set_security_1401
feat(binding-opcua): Add channel security support #1401
2 parents 3e93960 + 269ea39 commit 813dee6

12 files changed

+1044
-44
lines changed

package-lock.json

Lines changed: 3 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/binding-opcua/.mocharc.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ require:
33
- ts-node/register
44
timeout: 100000
55
enable-source-maps: true
6+
extensions:
7+
- .ts
68
spec: "test/*-test.ts"
79
bail: true
810
parallel: false

packages/binding-opcua/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,15 @@
2121
"@node-wot/core": "0.9.2",
2222
"ajv": "^8.11.0",
2323
"ajv-formats": "^2.1.1",
24+
"env-paths": "2.2.1",
2425
"node-opcua": "2.143.0",
2526
"node-opcua-address-space": "2.143.0",
2627
"node-opcua-basic-types": "2.139.0",
2728
"node-opcua-binary-stream": "2.139.0",
28-
"node-opcua-buffer-utils": "2.139.0",
29+
"node-opcua-certificate-manager": "2.143.0",
2930
"node-opcua-client": "2.143.0",
3031
"node-opcua-constants": "2.139.0",
32+
"node-opcua-crypto": "4.16.0",
3133
"node-opcua-data-model": "2.139.0",
3234
"node-opcua-data-value": "2.142.0",
3335
"node-opcua-date-time": "2.139.0",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/********************************************************************************
2+
* Copyright (c) 2025 Contributors to the Eclipse Foundation
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information regarding copyright ownership.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License v. 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and
10+
* Document License (2015-05-13) which is available at
11+
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document.
12+
*
13+
* SPDX-License-Identifier: EPL-2.0 OR W3C-20150513
14+
********************************************************************************/
15+
import path from "node:path";
16+
import { OPCUACertificateManager } from "node-opcua-certificate-manager";
17+
import envPath from "env-paths";
18+
import { createLoggers } from "@node-wot/core";
19+
20+
const { debug } = createLoggers("binding-opcua", "opcua-protocol-client");
21+
22+
const env = envPath("binding-opcua", { suffix: "node-wot" });
23+
24+
/**
25+
* Certificate Manager Singleton for OPCUA Binding in the WoT context.
26+
*
27+
*/
28+
export class CertificateManagerSingleton {
29+
private static _certificateManager: OPCUACertificateManager | null = null;
30+
31+
public static async getCertificateManager(): Promise<OPCUACertificateManager> {
32+
if (CertificateManagerSingleton._certificateManager) {
33+
return CertificateManagerSingleton._certificateManager;
34+
}
35+
const rootFolder = path.join(env.config, "PKI");
36+
debug("OPCUA PKI folder", rootFolder);
37+
const certificateManager = new OPCUACertificateManager({
38+
rootFolder,
39+
});
40+
await certificateManager.initialize();
41+
certificateManager.referenceCounter++;
42+
CertificateManagerSingleton._certificateManager = certificateManager;
43+
return certificateManager;
44+
}
45+
46+
public static releaseCertificateManager(): void {
47+
if (CertificateManagerSingleton._certificateManager) {
48+
CertificateManagerSingleton._certificateManager.referenceCounter--;
49+
// dispose is degined to free resources if referenceCounter==0;
50+
CertificateManagerSingleton._certificateManager.dispose();
51+
CertificateManagerSingleton._certificateManager = null;
52+
}
53+
}
54+
}

packages/binding-opcua/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,5 @@
1616
export * from "./factory";
1717
export * from "./codec";
1818
export * from "./opcua-protocol-client";
19+
export * from "./security_scheme";
1920
// no protocol_client here => get access from factor

packages/binding-opcua/src/opcua-protocol-client.ts

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,16 @@ import { Subscription } from "rxjs/Subscription";
1717
import { promisify } from "util";
1818
import { Readable } from "stream";
1919
import { URL } from "url";
20-
21-
import { ProtocolClient, Content, ContentSerdes, Form, SecurityScheme, createLoggers } from "@node-wot/core";
20+
import {
21+
ProtocolClient,
22+
Content,
23+
ContentSerdes,
24+
Form,
25+
SecurityScheme,
26+
createLoggers,
27+
AllOfSecurityScheme,
28+
OneOfSecurityScheme,
29+
} from "@node-wot/core";
2230

2331
import {
2432
ClientSession,
@@ -36,19 +44,29 @@ import {
3644
VariantArrayType,
3745
Variant,
3846
VariantOptions,
47+
SecurityPolicy,
3948
} from "node-opcua-client";
40-
import { ArgumentDefinition, getBuiltInDataType, readNamespaceArray } from "node-opcua-pseudo-session";
41-
49+
import {
50+
AnonymousIdentity,
51+
ArgumentDefinition,
52+
getBuiltInDataType,
53+
readNamespaceArray,
54+
UserIdentityInfo,
55+
} from "node-opcua-pseudo-session";
4256
import { makeNodeId, NodeId, NodeIdLike, NodeIdType, resolveNodeId } from "node-opcua-nodeid";
4357
import { AttributeIds, BrowseDirection, makeResultMask } from "node-opcua-data-model";
4458
import { makeBrowsePath } from "node-opcua-service-translate-browse-path";
4559
import { StatusCodes } from "node-opcua-status-code";
46-
47-
import { schemaDataValue } from "./codec";
60+
import { coercePrivateKeyPem, readPrivateKey } from "node-opcua-crypto";
4861
import { opcuaJsonEncodeVariant } from "node-opcua-json";
49-
import { Argument, BrowseDescription, BrowseResult } from "node-opcua-types";
62+
import { Argument, BrowseDescription, BrowseResult, MessageSecurityMode, UserTokenType } from "node-opcua-types";
5063
import { isGoodish2, ReferenceTypeIds } from "node-opcua";
5164

65+
import { schemaDataValue } from "./codec";
66+
import { OPCUACAuthenticationScheme, OPCUAChannelSecurityScheme } from "./security_scheme";
67+
import { CertificateManagerSingleton } from "./certificate-manager-singleton";
68+
import { resolveChannelSecurity, resolvedUserIdentity } from "./opcua-security-resolver";
69+
5270
const { debug } = createLoggers("binding-opcua", "opcua-protocol-client");
5371

5472
export type Command = "Read" | "Write" | "Subscribe";
@@ -141,6 +159,10 @@ function _variantToJSON(variant: Variant, contentType: string) {
141159
export class OPCUAProtocolClient implements ProtocolClient {
142160
private _connections: Map<string, OPCUAConnectionEx> = new Map<string, OPCUAConnectionEx>();
143161

162+
private _securityMode: MessageSecurityMode = MessageSecurityMode.None;
163+
private _securityPolicy: SecurityPolicy = SecurityPolicy.None;
164+
private _userIdentity: UserIdentityInfo = <AnonymousIdentity>{ type: UserTokenType.Anonymous };
165+
144166
private async _withConnection<T>(form: OPCUAForm, next: (connection: OPCUAConnection) => Promise<T>): Promise<T> {
145167
const endpoint = form.href;
146168
const matchesScheme: boolean = endpoint?.match(/^opc.tcp:\/\//) != null;
@@ -150,11 +172,15 @@ export class OPCUAProtocolClient implements ProtocolClient {
150172
}
151173
let c: OPCUAConnectionEx | undefined = this._connections.get(endpoint);
152174
if (!c) {
175+
const clientCertificateManager = await CertificateManagerSingleton.getCertificateManager();
153176
const client = OPCUAClient.create({
154177
endpointMustExist: false,
155178
connectionStrategy: {
156179
maxRetry: 1,
157180
},
181+
securityMode: this._securityMode,
182+
securityPolicy: this._securityPolicy,
183+
clientCertificateManager,
158184
});
159185
client.on("backoff", () => {
160186
debug(`connection:backoff: cannot connection to ${endpoint}`);
@@ -168,7 +194,19 @@ export class OPCUAProtocolClient implements ProtocolClient {
168194
this._connections.set(endpoint, c);
169195
try {
170196
await client.connect(endpoint);
171-
const session = await client.createSession();
197+
} catch (err) {
198+
const errMessage = "Cannot connected to endpoint " + endpoint + "\nmsg = " + (<Error>err).message;
199+
debug(errMessage);
200+
throw new Error(errMessage);
201+
}
202+
try {
203+
// adjust with private key
204+
if (this._userIdentity.type === UserTokenType.Certificate && !this._userIdentity.privateKey) {
205+
const internalKey = readPrivateKey(client.clientCertificateManager.privateKey);
206+
const privateKeyPem = coercePrivateKeyPem(internalKey);
207+
this._userIdentity.privateKey = privateKeyPem;
208+
}
209+
const session = await client.createSession(this._userIdentity);
172210
c.session = session;
173211

174212
const subscription = await session.createSubscription2({
@@ -187,7 +225,10 @@ export class OPCUAProtocolClient implements ProtocolClient {
187225

188226
this._connections.set(endpoint, c);
189227
} catch (err) {
190-
throw new Error("Cannot connected to endpoint " + endpoint + "\nmsg = " + (<Error>err).message);
228+
await client.disconnect();
229+
const errMessage = "Cannot handle session on " + endpoint + "\nmsg = " + (<Error>err).message;
230+
debug(errMessage);
231+
throw new Error(errMessage);
191232
}
192233
}
193234
if (c.pending) {
@@ -464,16 +505,56 @@ export class OPCUAProtocolClient implements ProtocolClient {
464505

465506
async stop(): Promise<void> {
466507
debug("stop");
467-
for (const c of this._connections.values()) {
468-
await c.subscription.terminate();
469-
await c.session.close();
470-
await c.client.disconnect();
508+
for (const connection of this._connections.values()) {
509+
await connection.subscription.terminate();
510+
await connection.session.close();
511+
await connection.client.disconnect();
471512
}
513+
CertificateManagerSingleton.releaseCertificateManager();
514+
}
515+
516+
#setChannelSecurity(security: OPCUAChannelSecurityScheme): boolean {
517+
const { messageSecurityMode, securityPolicy } = resolveChannelSecurity(security);
518+
this._securityMode = messageSecurityMode;
519+
this._securityPolicy = securityPolicy;
520+
return true;
472521
}
473522

474-
setSecurity(metadata: SecurityScheme[], credentials?: unknown): boolean {
523+
#setAuthentication(security: OPCUACAuthenticationScheme): boolean {
524+
this._userIdentity = resolvedUserIdentity(security);
525+
return true;
526+
}
527+
528+
setSecurity(securitySchemes: SecurityScheme[], credentials?: unknown): boolean {
529+
for (const securityScheme of securitySchemes) {
530+
let success = true;
531+
switch (securityScheme.scheme) {
532+
case "uav:channel-security":
533+
success = this.#setChannelSecurity(securityScheme as OPCUAChannelSecurityScheme);
534+
break;
535+
case "uav:authentication":
536+
success = this.#setAuthentication(securityScheme as OPCUACAuthenticationScheme);
537+
break;
538+
case "combo": {
539+
const combo = securityScheme as AllOfSecurityScheme | OneOfSecurityScheme;
540+
if (combo.allOf !== undefined) {
541+
success = this.setSecurity(combo.allOf, credentials);
542+
} else if (combo.oneOf !== undefined) {
543+
// pick the first one for now
544+
// later we might use credentials to select the most appropriate one
545+
success = this.setSecurity([combo.oneOf[0]], credentials);
546+
} else {
547+
success = false;
548+
}
549+
break;
550+
}
551+
default:
552+
// not for us , ignored
553+
break;
554+
}
555+
if (!success) return false;
556+
}
475557
return true;
476-
// throw new Error("Method not implemented.");
477558
}
478559

479560
private _monitoredItems: Map<
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/********************************************************************************
2+
* Copyright (c) 2025 Contributors to the Eclipse Foundation
3+
*
4+
* See the NOTICE file(s) distributed with this work for additional
5+
* information regarding copyright ownership.
6+
*
7+
* This program and the accompanying materials are made available under the
8+
* terms of the Eclipse Public License v. 2.0 which is available at
9+
* http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and
10+
* Document License (2015-05-13) which is available at
11+
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document.
12+
*
13+
* SPDX-License-Identifier: EPL-2.0 OR W3C-20150513
14+
********************************************************************************/
15+
import {
16+
MessageSecurityMode,
17+
SecurityPolicy,
18+
UserIdentityInfo,
19+
UserIdentityInfoUserName,
20+
UserIdentityInfoX509,
21+
UserTokenType,
22+
} from "node-opcua-client";
23+
import { convertPEMtoDER } from "node-opcua-crypto";
24+
import { OPCUACAuthenticationScheme, OPCUAChannelSecurityScheme } from "./security_scheme";
25+
26+
export interface OPCUAChannelSecuritySettings {
27+
securityPolicy: SecurityPolicy;
28+
messageSecurityMode: MessageSecurityMode;
29+
}
30+
31+
/**
32+
* Resolves the channel security settings from the given security scheme.
33+
* Will throw an error if the policy or message mode is invalid.
34+
* @param security The OPC UA channel security scheme.
35+
* @returns The resolved channel security settings.
36+
*/
37+
export function resolveChannelSecurity(security: OPCUAChannelSecurityScheme): OPCUAChannelSecuritySettings {
38+
if (security.scheme === "uav:channel-security" && security.messageMode !== "none") {
39+
const securityPolicy: SecurityPolicy = SecurityPolicy[security.policy as keyof typeof SecurityPolicy];
40+
41+
if (securityPolicy === undefined) {
42+
throw new Error(`Invalid security policy '${security.policy}'`);
43+
}
44+
45+
let messageSecurityMode: MessageSecurityMode = MessageSecurityMode.Invalid;
46+
switch (security.messageMode) {
47+
case "sign":
48+
messageSecurityMode = MessageSecurityMode.Sign;
49+
break;
50+
case "sign_encrypt":
51+
messageSecurityMode = MessageSecurityMode.SignAndEncrypt;
52+
break;
53+
default:
54+
messageSecurityMode = MessageSecurityMode.None;
55+
break;
56+
}
57+
58+
return {
59+
securityPolicy,
60+
messageSecurityMode,
61+
};
62+
} else {
63+
return {
64+
securityPolicy: SecurityPolicy.None,
65+
messageSecurityMode: MessageSecurityMode.None,
66+
};
67+
}
68+
}
69+
70+
/**
71+
* Resolves the user identity information from the given authentication scheme.
72+
* Will throw an error if the token type is invalid.
73+
* @param security The OPC UA authentication scheme.
74+
* @returns The resolved user identity information.
75+
*/
76+
export function resolvedUserIdentity(security: OPCUACAuthenticationScheme) {
77+
let userIdentity: UserIdentityInfo;
78+
switch (security.tokenType) {
79+
case "username":
80+
userIdentity = <UserIdentityInfoUserName>{
81+
type: UserTokenType.UserName,
82+
password: security.password,
83+
userName: security.userName,
84+
};
85+
break;
86+
case "certificate":
87+
userIdentity = <UserIdentityInfoX509>{
88+
type: UserTokenType.Certificate,
89+
certificateData: convertPEMtoDER(security.certificate),
90+
privateKey: security.privateKey,
91+
};
92+
break;
93+
case "anonymous":
94+
default:
95+
// it is OK to use anonymous as default,
96+
// as it provides the lowest privileges
97+
userIdentity = <UserIdentityInfo>{
98+
type: UserTokenType.Anonymous,
99+
};
100+
break;
101+
}
102+
103+
return userIdentity;
104+
}

0 commit comments

Comments
 (0)