Skip to content

Commit 7acf786

Browse files
committed
feat(binding-opcua): Add channel security support #1401
Implements OPC UA channel security by allowing the configuration of security mode and policy and authentication This change introduces a certificate manager to handle client-side certificates. Key changes: - Added new SecurityScheme OPCUASecureSecuritySchemeBase (abstract) -> OPCUAChannelSecurityScheme -> OPCUAUnsecureChannelScheme -> OPCUASecureSecurityScheme -> OPCUASecureSecurityScheme -> OPCUACertificateAuthenticationScheme -> OPCUAUserNameAuthenticationScheme - Implemented a shared `OPCUACertificateManager` for PKI. PKI folder for OPCUAPrococolClient is set to env-path("binding-opcua-wot").PKI which resolve to on window: C:\Users\<User>\AppData\Roaming\binding-opcua-node-wot\Config\PKI on linux: ~/.config/binding-opcua-node-wot/PKI on linux' on macOs: ~/Library/Application Support/binding-opcua-node-wot/PKI - Added tests for secure communication.
1 parent ebabb88 commit 7acf786

7 files changed

Lines changed: 561 additions & 20 deletions

File tree

package-lock.json

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

packages/binding-opcua/package.json

Lines changed: 4 additions & 2 deletions
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",
27-
"node-opcua-binary-stream": "2.139.0",
28-
"node-opcua-buffer-utils": "2.139.0",
28+
"node-opcua-binary-stream": "2.143.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",

packages/binding-opcua/src/factory.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import { ProtocolClientFactory, ProtocolClient, ContentSerdes, createLoggers } from "@node-wot/core";
1717
import { OpcuaJSONCodec, OpcuaBinaryCodec } from "./codec";
1818
import { OPCUAProtocolClient } from "./opcua-protocol-client";
19+
import { OPCUACertificateManager } from "node-opcua-certificate-manager";
1920

2021
const { debug } = createLoggers("binding-opcua", "factory");
2122

@@ -55,6 +56,12 @@ export class OPCUAClientFactory implements ProtocolClientFactory {
5556
await client.stop();
5657
}
5758
})();
59+
60+
OPCUAProtocolClient.releaseCertificateManager();
5861
return true;
5962
}
63+
64+
async getCertificateManager(): Promise<OPCUACertificateManager> {
65+
return await OPCUAProtocolClient.getCertificateManager();
66+
}
6067
}

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

Lines changed: 150 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,18 @@ 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 path from "path";
21+
import envPath from "env-paths";
22+
import {
23+
ProtocolClient,
24+
Content,
25+
ContentSerdes,
26+
Form,
27+
SecurityScheme,
28+
createLoggers,
29+
OPCUACAuthenticationScheme,
30+
OPCUAChannelSecurityScheme,
31+
} from "@node-wot/core";
2232

2333
import {
2434
ClientSession,
@@ -36,21 +46,32 @@ import {
3646
VariantArrayType,
3747
Variant,
3848
VariantOptions,
49+
SecurityPolicy,
3950
} from "node-opcua-client";
40-
import { ArgumentDefinition, getBuiltInDataType, readNamespaceArray } from "node-opcua-pseudo-session";
41-
51+
import {
52+
AnonymousIdentity,
53+
ArgumentDefinition,
54+
getBuiltInDataType,
55+
readNamespaceArray,
56+
UserIdentityInfo,
57+
UserIdentityInfoUserName,
58+
UserIdentityInfoX509,
59+
} from "node-opcua-pseudo-session";
4260
import { makeNodeId, NodeId, NodeIdLike, NodeIdType, resolveNodeId } from "node-opcua-nodeid";
4361
import { AttributeIds, BrowseDirection, makeResultMask } from "node-opcua-data-model";
4462
import { makeBrowsePath } from "node-opcua-service-translate-browse-path";
4563
import { StatusCodes } from "node-opcua-status-code";
64+
import { coercePrivateKeyPem, convertPEMtoDER, readPrivateKey } from "node-opcua-crypto";
4665

4766
import { schemaDataValue } from "./codec";
4867
import { opcuaJsonEncodeVariant } from "node-opcua-json";
49-
import { Argument, BrowseDescription, BrowseResult } from "node-opcua-types";
50-
import { isGoodish2, ReferenceTypeIds } from "node-opcua";
68+
import { Argument, BrowseDescription, BrowseResult, MessageSecurityMode, UserTokenType } from "node-opcua-types";
69+
import { isGoodish2, OPCUACertificateManager, ReferenceTypeIds } from "node-opcua";
5170

5271
const { debug } = createLoggers("binding-opcua", "opcua-protocol-client");
5372

73+
const env = envPath("binding-opcua", { suffix: "node-wot" });
74+
5475
export type Command = "Read" | "Write" | "Subscribe";
5576

5677
export interface NodeByBrowsePath {
@@ -141,6 +162,36 @@ function _variantToJSON(variant: Variant, contentType: string) {
141162
export class OPCUAProtocolClient implements ProtocolClient {
142163
private _connections: Map<string, OPCUAConnectionEx> = new Map<string, OPCUAConnectionEx>();
143164

165+
private _securityMode: MessageSecurityMode = MessageSecurityMode.None;
166+
private _securityPolicy: SecurityPolicy = SecurityPolicy.None;
167+
private _userIdentity: UserIdentityInfo = <AnonymousIdentity>{ type: UserTokenType.Anonymous };
168+
169+
private static _certificateManager: OPCUACertificateManager | null = null;
170+
171+
public static async getCertificateManager(): Promise<OPCUACertificateManager> {
172+
if (OPCUAProtocolClient._certificateManager) {
173+
return OPCUAProtocolClient._certificateManager;
174+
}
175+
const rootFolder = path.join(env.config, "PKI");
176+
debug("OPCUA PKI folder", rootFolder);
177+
const certificateManager = new OPCUACertificateManager({
178+
rootFolder,
179+
});
180+
await certificateManager.initialize();
181+
certificateManager.referenceCounter++;
182+
OPCUAProtocolClient._certificateManager = certificateManager;
183+
return certificateManager;
184+
}
185+
186+
public static releaseCertificateManager(): void {
187+
if (OPCUAProtocolClient._certificateManager) {
188+
OPCUAProtocolClient._certificateManager.referenceCounter--;
189+
// dispose is degined to free resources if referenceCounter==0;
190+
OPCUAProtocolClient._certificateManager.dispose();
191+
OPCUAProtocolClient._certificateManager = null;
192+
}
193+
}
194+
144195
private async _withConnection<T>(form: OPCUAForm, next: (connection: OPCUAConnection) => Promise<T>): Promise<T> {
145196
const endpoint = form.href;
146197
const matchesScheme: boolean = endpoint?.match(/^opc.tcp:\/\//) != null;
@@ -150,11 +201,15 @@ export class OPCUAProtocolClient implements ProtocolClient {
150201
}
151202
let c: OPCUAConnectionEx | undefined = this._connections.get(endpoint);
152203
if (!c) {
204+
const clientCertificateManager = await OPCUAProtocolClient.getCertificateManager();
153205
const client = OPCUAClient.create({
154206
endpointMustExist: false,
155207
connectionStrategy: {
156208
maxRetry: 1,
157209
},
210+
securityMode: this._securityMode,
211+
securityPolicy: this._securityPolicy,
212+
clientCertificateManager,
158213
});
159214
client.on("backoff", () => {
160215
debug(`connection:backoff: cannot connection to ${endpoint}`);
@@ -168,7 +223,19 @@ export class OPCUAProtocolClient implements ProtocolClient {
168223
this._connections.set(endpoint, c);
169224
try {
170225
await client.connect(endpoint);
171-
const session = await client.createSession();
226+
} catch (err) {
227+
const errMessage = "Cannot connected to endpoint " + endpoint + "\nmsg = " + (<Error>err).message;
228+
debug(errMessage);
229+
throw new Error(errMessage);
230+
}
231+
try {
232+
// adjust with private key
233+
if (this._userIdentity.type === UserTokenType.Certificate && !this._userIdentity.privateKey) {
234+
const internalKey = readPrivateKey(client.clientCertificateManager.privateKey);
235+
const privateKeyPem = coercePrivateKeyPem(internalKey);
236+
this._userIdentity.privateKey = privateKeyPem;
237+
}
238+
const session = await client.createSession(this._userIdentity);
172239
c.session = session;
173240

174241
const subscription = await session.createSubscription2({
@@ -187,7 +254,10 @@ export class OPCUAProtocolClient implements ProtocolClient {
187254

188255
this._connections.set(endpoint, c);
189256
} catch (err) {
190-
throw new Error("Cannot connected to endpoint " + endpoint + "\nmsg = " + (<Error>err).message);
257+
await client.disconnect();
258+
const errMessage = "Cannot handle session on " + endpoint + "\nmsg = " + (<Error>err).message;
259+
debug(errMessage);
260+
throw new Error(errMessage);
191261
}
192262
}
193263
if (c.pending) {
@@ -464,16 +534,82 @@ export class OPCUAProtocolClient implements ProtocolClient {
464534

465535
async stop(): Promise<void> {
466536
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();
537+
for (const connection of this._connections.values()) {
538+
await connection.subscription.terminate();
539+
await connection.session.close();
540+
await connection.client.disconnect();
471541
}
542+
await OPCUAProtocolClient._certificateManager?.dispose();
472543
}
473544

474-
setSecurity(metadata: SecurityScheme[], credentials?: unknown): boolean {
545+
private setChannelSecurity(security: OPCUAChannelSecurityScheme): boolean {
546+
const foundSecurity = SecurityPolicy[security.policy as keyof typeof SecurityPolicy];
547+
548+
if (foundSecurity === undefined) {
549+
return false;
550+
}
551+
552+
this._securityPolicy = foundSecurity;
553+
554+
switch (security.messageMode) {
555+
case "sign":
556+
this._securityMode = MessageSecurityMode.Sign;
557+
break;
558+
case "sign_encrypt":
559+
this._securityMode = MessageSecurityMode.SignAndEncrypt;
560+
break;
561+
default:
562+
this._securityMode = MessageSecurityMode.None;
563+
break;
564+
}
565+
566+
return true;
567+
}
568+
569+
private setAuthentication(security: OPCUACAuthenticationScheme): boolean {
570+
switch (security.tokenType) {
571+
case "username":
572+
this._userIdentity = <UserIdentityInfoUserName>{
573+
type: UserTokenType.UserName,
574+
password: security.password,
575+
userName: security.userName,
576+
};
577+
break;
578+
case "certificate":
579+
this._userIdentity = <UserIdentityInfoX509>{
580+
type: UserTokenType.Certificate,
581+
certificateData: convertPEMtoDER(security.certificate),
582+
privateKey: security.privateKey,
583+
};
584+
break;
585+
case "anonymous":
586+
this._userIdentity = <UserIdentityInfo>{
587+
type: UserTokenType.Anonymous,
588+
};
589+
break;
590+
default:
591+
return false;
592+
}
593+
return true;
594+
}
595+
596+
setSecurity(securitySchemes: SecurityScheme[], credentials?: unknown): boolean {
597+
for (const securityScheme of securitySchemes) {
598+
let success = true;
599+
switch (securityScheme.scheme) {
600+
case "opcua-channel-security":
601+
success = this.setChannelSecurity(securityScheme as OPCUAChannelSecurityScheme);
602+
break;
603+
case "opcua-authentication":
604+
success = this.setAuthentication(securityScheme as OPCUACAuthenticationScheme);
605+
break;
606+
default:
607+
// not for us , ignored
608+
break;
609+
}
610+
if (!success) return false;
611+
}
475612
return true;
476-
// throw new Error("Method not implemented.");
477613
}
478614

479615
private _monitoredItems: Map<

packages/binding-opcua/test/fixture/basic-opcua-server.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ import {
3333
VariantArrayType,
3434
CallbackT,
3535
CallMethodResultOptions,
36+
WellKnownRoles,
3637
} from "node-opcua";
37-
import { KeyValuePair } from "node-opcua-types";
38+
import { KeyValuePair, PermissionType } from "node-opcua-types";
3839
import { createLoggers } from "@node-wot/core";
3940

4041
const { info } = createLoggers("binding-opcua", "basic-opcua-server");
@@ -52,6 +53,18 @@ export async function startServer(): Promise<OPCUAServer> {
5253
const server = new OPCUAServer({
5354
port: 7890,
5455
nodeset_filename: [nodesets.standard, nodesets.di],
56+
57+
userManager: {
58+
isValidUser(userName: string, password: string): boolean {
59+
if (userName === "joe" && password === "password_for_joe") {
60+
return true;
61+
}
62+
if (userName === "admin" && password === "password_for_admin") {
63+
return true;
64+
}
65+
return false;
66+
},
67+
},
5568
});
5669

5770
await server.initialize();
@@ -208,6 +221,25 @@ export async function startServer(): Promise<OPCUAServer> {
208221
}
209222
);
210223

224+
const onlyForAuthenticated = namespace.addVariable({
225+
browseName: "OnlyForAuthenticated",
226+
nodeId: "s=OnlyForAuthenticated",
227+
description: "returns the secret value only if user is authenticated (not anonymous)",
228+
componentOf: addressSpace.rootFolder.objects.server,
229+
dataType: "String",
230+
rolePermissions: [
231+
{
232+
roleId: WellKnownRoles.AuthenticatedUser,
233+
permissions: PermissionType.Read | PermissionType.Browse,
234+
},
235+
{
236+
roleId: WellKnownRoles.Anonymous,
237+
permissions: PermissionType.Read | PermissionType.Browse,
238+
},
239+
],
240+
});
241+
onlyForAuthenticated.setValueFromSource({ dataType: "String", value: "Secret" });
242+
211243
await server.start();
212244
info(`Server started: ${server.getEndpointUrl()}`);
213245
return server;

0 commit comments

Comments
 (0)