Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/binding-opcua/.mocharc.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ require:
- ts-node/register
timeout: 100000
enable-source-maps: true
extensions:
- .ts
spec: "test/*-test.ts"
bail: true
parallel: false
4 changes: 3 additions & 1 deletion packages/binding-opcua/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,15 @@
"@node-wot/core": "0.9.2",
"ajv": "^8.11.0",
"ajv-formats": "^2.1.1",
"env-paths": "2.2.1",
"node-opcua": "2.143.0",
"node-opcua-address-space": "2.143.0",
"node-opcua-basic-types": "2.139.0",
"node-opcua-binary-stream": "2.139.0",
"node-opcua-buffer-utils": "2.139.0",
"node-opcua-certificate-manager": "2.143.0",
"node-opcua-client": "2.143.0",
"node-opcua-constants": "2.139.0",
"node-opcua-crypto": "4.16.0",
"node-opcua-data-model": "2.139.0",
"node-opcua-data-value": "2.142.0",
"node-opcua-date-time": "2.139.0",
Expand Down
54 changes: 54 additions & 0 deletions packages/binding-opcua/src/certificate-manager-singleton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/********************************************************************************
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and
* Document License (2015-05-13) which is available at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document.
*
* SPDX-License-Identifier: EPL-2.0 OR W3C-20150513
********************************************************************************/
import path from "node:path";
import { OPCUACertificateManager } from "node-opcua-certificate-manager";
import envPath from "env-paths";
import { createLoggers } from "@node-wot/core";

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

const env = envPath("binding-opcua", { suffix: "node-wot" });

/**
* Certificate Manager Singleton for OPCUA Binding in the WoT context.
*
*/
export class CertificateManagerSingleton {
private static _certificateManager: OPCUACertificateManager | null = null;

public static async getCertificateManager(): Promise<OPCUACertificateManager> {
if (CertificateManagerSingleton._certificateManager) {
return CertificateManagerSingleton._certificateManager;
}
const rootFolder = path.join(env.config, "PKI");
debug("OPCUA PKI folder", rootFolder);
const certificateManager = new OPCUACertificateManager({
rootFolder,
});
await certificateManager.initialize();
certificateManager.referenceCounter++;
CertificateManagerSingleton._certificateManager = certificateManager;
return certificateManager;
}

public static releaseCertificateManager(): void {
if (CertificateManagerSingleton._certificateManager) {
CertificateManagerSingleton._certificateManager.referenceCounter--;
// dispose is degined to free resources if referenceCounter==0;
CertificateManagerSingleton._certificateManager.dispose();
CertificateManagerSingleton._certificateManager = null;
}
}
}
1 change: 1 addition & 0 deletions packages/binding-opcua/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,5 @@
export * from "./factory";
export * from "./codec";
export * from "./opcua-protocol-client";
export * from "./security_scheme";
// no protocol_client here => get access from factor
111 changes: 96 additions & 15 deletions packages/binding-opcua/src/opcua-protocol-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,16 @@ import { Subscription } from "rxjs/Subscription";
import { promisify } from "util";
import { Readable } from "stream";
import { URL } from "url";

import { ProtocolClient, Content, ContentSerdes, Form, SecurityScheme, createLoggers } from "@node-wot/core";
import {
ProtocolClient,
Content,
ContentSerdes,
Form,
SecurityScheme,
createLoggers,
AllOfSecurityScheme,
OneOfSecurityScheme,
} from "@node-wot/core";

import {
ClientSession,
Expand All @@ -36,19 +44,29 @@ import {
VariantArrayType,
Variant,
VariantOptions,
SecurityPolicy,
} from "node-opcua-client";
import { ArgumentDefinition, getBuiltInDataType, readNamespaceArray } from "node-opcua-pseudo-session";

import {
AnonymousIdentity,
ArgumentDefinition,
getBuiltInDataType,
readNamespaceArray,
UserIdentityInfo,
} from "node-opcua-pseudo-session";
import { makeNodeId, NodeId, NodeIdLike, NodeIdType, resolveNodeId } from "node-opcua-nodeid";
import { AttributeIds, BrowseDirection, makeResultMask } from "node-opcua-data-model";
import { makeBrowsePath } from "node-opcua-service-translate-browse-path";
import { StatusCodes } from "node-opcua-status-code";

import { schemaDataValue } from "./codec";
import { coercePrivateKeyPem, readPrivateKey } from "node-opcua-crypto";
import { opcuaJsonEncodeVariant } from "node-opcua-json";
import { Argument, BrowseDescription, BrowseResult } from "node-opcua-types";
import { Argument, BrowseDescription, BrowseResult, MessageSecurityMode, UserTokenType } from "node-opcua-types";
import { isGoodish2, ReferenceTypeIds } from "node-opcua";

import { schemaDataValue } from "./codec";
import { OPCUACAuthenticationScheme, OPCUAChannelSecurityScheme } from "./security_scheme";
import { CertificateManagerSingleton } from "./certificate-manager-singleton";
import { resolveChannelSecurity, resolvedUserIdentity } from "./opcua-security-resolver";

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

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

private _securityMode: MessageSecurityMode = MessageSecurityMode.None;
private _securityPolicy: SecurityPolicy = SecurityPolicy.None;
private _userIdentity: UserIdentityInfo = <AnonymousIdentity>{ type: UserTokenType.Anonymous };

private async _withConnection<T>(form: OPCUAForm, next: (connection: OPCUAConnection) => Promise<T>): Promise<T> {
const endpoint = form.href;
const matchesScheme: boolean = endpoint?.match(/^opc.tcp:\/\//) != null;
Expand All @@ -150,11 +172,15 @@ export class OPCUAProtocolClient implements ProtocolClient {
}
let c: OPCUAConnectionEx | undefined = this._connections.get(endpoint);
if (!c) {
const clientCertificateManager = await CertificateManagerSingleton.getCertificateManager();
const client = OPCUAClient.create({
endpointMustExist: false,
connectionStrategy: {
maxRetry: 1,
},
securityMode: this._securityMode,
securityPolicy: this._securityPolicy,
clientCertificateManager,
});
client.on("backoff", () => {
debug(`connection:backoff: cannot connection to ${endpoint}`);
Expand All @@ -168,7 +194,19 @@ export class OPCUAProtocolClient implements ProtocolClient {
this._connections.set(endpoint, c);
try {
await client.connect(endpoint);
const session = await client.createSession();
} catch (err) {
const errMessage = "Cannot connected to endpoint " + endpoint + "\nmsg = " + (<Error>err).message;
debug(errMessage);
throw new Error(errMessage);
}
try {
// adjust with private key
if (this._userIdentity.type === UserTokenType.Certificate && !this._userIdentity.privateKey) {
const internalKey = readPrivateKey(client.clientCertificateManager.privateKey);
const privateKeyPem = coercePrivateKeyPem(internalKey);
this._userIdentity.privateKey = privateKeyPem;
}
const session = await client.createSession(this._userIdentity);
c.session = session;

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

this._connections.set(endpoint, c);
} catch (err) {
throw new Error("Cannot connected to endpoint " + endpoint + "\nmsg = " + (<Error>err).message);
await client.disconnect();
const errMessage = "Cannot handle session on " + endpoint + "\nmsg = " + (<Error>err).message;
debug(errMessage);
throw new Error(errMessage);
}
}
if (c.pending) {
Expand Down Expand Up @@ -464,16 +505,56 @@ export class OPCUAProtocolClient implements ProtocolClient {

async stop(): Promise<void> {
debug("stop");
for (const c of this._connections.values()) {
await c.subscription.terminate();
await c.session.close();
await c.client.disconnect();
for (const connection of this._connections.values()) {
await connection.subscription.terminate();
await connection.session.close();
await connection.client.disconnect();
}
CertificateManagerSingleton.releaseCertificateManager();
}

#setChannelSecurity(security: OPCUAChannelSecurityScheme): boolean {
const { messageSecurityMode, securityPolicy } = resolveChannelSecurity(security);
this._securityMode = messageSecurityMode;
this._securityPolicy = securityPolicy;
return true;
}

setSecurity(metadata: SecurityScheme[], credentials?: unknown): boolean {
#setAuthentication(security: OPCUACAuthenticationScheme): boolean {
this._userIdentity = resolvedUserIdentity(security);
return true;
}

setSecurity(securitySchemes: SecurityScheme[], credentials?: unknown): boolean {
for (const securityScheme of securitySchemes) {
let success = true;
switch (securityScheme.scheme) {
case "uav:channel-security":
success = this.#setChannelSecurity(securityScheme as OPCUAChannelSecurityScheme);
break;
case "uav:authentication":
success = this.#setAuthentication(securityScheme as OPCUACAuthenticationScheme);
break;
case "combo": {
const combo = securityScheme as AllOfSecurityScheme | OneOfSecurityScheme;
if (combo.allOf !== undefined) {
success = this.setSecurity(combo.allOf, credentials);
} else if (combo.oneOf !== undefined) {
// pick the first one for now
// later we might use credentials to select the most appropriate one
success = this.setSecurity([combo.oneOf[0]], credentials);
} else {
success = false;
}
break;
}
default:
// not for us , ignored
break;
}
if (!success) return false;
}
return true;
// throw new Error("Method not implemented.");
}

private _monitoredItems: Map<
Expand Down
104 changes: 104 additions & 0 deletions packages/binding-opcua/src/opcua-security-resolver.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/********************************************************************************
* Copyright (c) 2025 Contributors to the Eclipse Foundation
*
* See the NOTICE file(s) distributed with this work for additional
* information regarding copyright ownership.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License v. 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0, or the W3C Software Notice and
* Document License (2015-05-13) which is available at
* https://www.w3.org/Consortium/Legal/2015/copyright-software-and-document.
*
* SPDX-License-Identifier: EPL-2.0 OR W3C-20150513
********************************************************************************/
import {
MessageSecurityMode,
SecurityPolicy,
UserIdentityInfo,
UserIdentityInfoUserName,
UserIdentityInfoX509,
UserTokenType,
} from "node-opcua-client";
import { convertPEMtoDER } from "node-opcua-crypto";
import { OPCUACAuthenticationScheme, OPCUAChannelSecurityScheme } from "./security_scheme";

export interface OPCUAChannelSecuritySettings {
securityPolicy: SecurityPolicy;
messageSecurityMode: MessageSecurityMode;
}

/**
* Resolves the channel security settings from the given security scheme.
* Will throw an error if the policy or message mode is invalid.
* @param security The OPC UA channel security scheme.
* @returns The resolved channel security settings.
*/
export function resolveChannelSecurity(security: OPCUAChannelSecurityScheme): OPCUAChannelSecuritySettings {
if (security.scheme === "uav:channel-security" && security.messageMode !== "none") {
const securityPolicy: SecurityPolicy = SecurityPolicy[security.policy as keyof typeof SecurityPolicy];

if (securityPolicy === undefined) {
throw new Error(`Invalid security policy '${security.policy}'`);
}

let messageSecurityMode: MessageSecurityMode = MessageSecurityMode.Invalid;
switch (security.messageMode) {
case "sign":
messageSecurityMode = MessageSecurityMode.Sign;
break;
case "sign_encrypt":
messageSecurityMode = MessageSecurityMode.SignAndEncrypt;
break;
default:
messageSecurityMode = MessageSecurityMode.None;
break;
}

return {
securityPolicy,
messageSecurityMode,
};
} else {
return {
securityPolicy: SecurityPolicy.None,
messageSecurityMode: MessageSecurityMode.None,
};
}
}

/**
* Resolves the user identity information from the given authentication scheme.
* Will throw an error if the token type is invalid.
* @param security The OPC UA authentication scheme.
* @returns The resolved user identity information.
*/
export function resolvedUserIdentity(security: OPCUACAuthenticationScheme) {
let userIdentity: UserIdentityInfo;
switch (security.tokenType) {
case "username":
userIdentity = <UserIdentityInfoUserName>{
type: UserTokenType.UserName,
password: security.password,
userName: security.userName,
};
break;
case "certificate":
userIdentity = <UserIdentityInfoX509>{
type: UserTokenType.Certificate,
certificateData: convertPEMtoDER(security.certificate),
privateKey: security.privateKey,
};
break;
case "anonymous":
default:
// it is OK to use anonymous as default,
// as it provides the lowest privileges
userIdentity = <UserIdentityInfo>{
type: UserTokenType.Anonymous,
};
break;
}

return userIdentity;
}
Loading
Loading