Skip to content

Commit 36be8d1

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 8cd451b commit 36be8d1

5 files changed

Lines changed: 490 additions & 15 deletions

File tree

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: 139 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,35 @@ 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+
public static releaseCertificateManager(): void {
186+
if (OPCUAProtocolClient._certificateManager) {
187+
OPCUAProtocolClient._certificateManager.referenceCounter--;
188+
// dispose is degined to free resources if referenceCounter==0;
189+
OPCUAProtocolClient._certificateManager.dispose();
190+
OPCUAProtocolClient._certificateManager = null;
191+
}
192+
}
193+
144194
private async _withConnection<T>(form: OPCUAForm, next: (connection: OPCUAConnection) => Promise<T>): Promise<T> {
145195
const endpoint = form.href;
146196
const matchesScheme: boolean = endpoint?.match(/^opc.tcp:\/\//) != null;
@@ -150,11 +200,15 @@ export class OPCUAProtocolClient implements ProtocolClient {
150200
}
151201
let c: OPCUAConnectionEx | undefined = this._connections.get(endpoint);
152202
if (!c) {
203+
const clientCertificateManager = await OPCUAProtocolClient.getCertificateManager();
153204
const client = OPCUAClient.create({
154205
endpointMustExist: false,
155206
connectionStrategy: {
156207
maxRetry: 1,
157208
},
209+
securityMode: this._securityMode,
210+
securityPolicy: this._securityPolicy,
211+
clientCertificateManager,
158212
});
159213
client.on("backoff", () => {
160214
debug(`connection:backoff: cannot connection to ${endpoint}`);
@@ -168,7 +222,19 @@ export class OPCUAProtocolClient implements ProtocolClient {
168222
this._connections.set(endpoint, c);
169223
try {
170224
await client.connect(endpoint);
171-
const session = await client.createSession();
225+
} catch (err) {
226+
const errMessage = "Cannot connected to endpoint " + endpoint + "\nmsg = " + (<Error>err).message;
227+
debug(errMessage);
228+
throw new Error(errMessage);
229+
}
230+
try {
231+
// adjust with private key
232+
if (this._userIdentity.type === UserTokenType.Certificate && !this._userIdentity.privateKey) {
233+
const internalKey = readPrivateKey(client.clientCertificateManager.privateKey);
234+
const privateKeyPem = coercePrivateKeyPem(internalKey);
235+
this._userIdentity.privateKey = privateKeyPem;
236+
}
237+
const session = await client.createSession(this._userIdentity);
172238
c.session = session;
173239

174240
const subscription = await session.createSubscription2({
@@ -187,7 +253,10 @@ export class OPCUAProtocolClient implements ProtocolClient {
187253

188254
this._connections.set(endpoint, c);
189255
} catch (err) {
190-
throw new Error("Cannot connected to endpoint " + endpoint + "\nmsg = " + (<Error>err).message);
256+
await client.disconnect();
257+
const errMessage = "Cannot handle session on " + endpoint + "\nmsg = " + (<Error>err).message;
258+
debug(errMessage);
259+
throw new Error(errMessage);
191260
}
192261
}
193262
if (c.pending) {
@@ -464,16 +533,72 @@ export class OPCUAProtocolClient implements ProtocolClient {
464533

465534
async stop(): Promise<void> {
466535
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();
536+
for (const connection of this._connections.values()) {
537+
await connection.subscription.terminate();
538+
await connection.session.close();
539+
await connection.client.disconnect();
471540
}
541+
await OPCUAProtocolClient._certificateManager?.dispose();
472542
}
473543

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

479604
private _monitoredItems: Map<

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,18 @@ export async function startServer(): Promise<OPCUAServer> {
5252
const server = new OPCUAServer({
5353
port: 7890,
5454
nodeset_filename: [nodesets.standard, nodesets.di],
55+
56+
userManager: {
57+
isValidUser(userName: string, password: string): boolean {
58+
if (userName === "joe" && password === "password_for_joe") {
59+
return true;
60+
}
61+
if (userName === "admin" && password === "password_for_admin") {
62+
return true;
63+
}
64+
return false;
65+
},
66+
},
5567
});
5668

5769
await server.initialize();

0 commit comments

Comments
 (0)