Skip to content

Commit 2e190cb

Browse files
committed
feat(binding-opcua): improve channel security test
- add ability to call the WhoIAm method to loopback on the actual credentials settings of the connected user, This ensure that the security scheme has worked as expected.
1 parent 53ab333 commit 2e190cb

2 files changed

Lines changed: 203 additions & 16 deletions

File tree

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

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ import {
2828
createLoggers,
2929
OPCUACAuthenticationScheme,
3030
OPCUAChannelSecurityScheme,
31+
AllOfSecurityScheme,
32+
OneOfSecurityScheme,
3133
} from "@node-wot/core";
3234

3335
import {
@@ -603,6 +605,18 @@ export class OPCUAProtocolClient implements ProtocolClient {
603605
case "opcua-authentication":
604606
success = this.setAuthentication(securityScheme as OPCUACAuthenticationScheme);
605607
break;
608+
case "combo": {
609+
const combo = securityScheme as AllOfSecurityScheme | OneOfSecurityScheme;
610+
if (combo.allOf) {
611+
success = this.setSecurity(combo.allOf, credentials);
612+
} else if (combo.oneOf) {
613+
// pick the first one for now
614+
// later we might use credentials to select the most appropriate one
615+
success = this.setSecurity([combo.oneOf[0]], credentials);
616+
} else {
617+
success = false;
618+
}
619+
}
606620
default:
607621
// not for us , ignored
608622
break;

packages/binding-opcua/test/opcua-security-test.ts

Lines changed: 189 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/********************************************************************************
2-
* Copyright (c) 2022 Contributors to the Eclipse Foundation
2+
* Copyright (c) 2025 Contributors to the Eclipse Foundation
33
*
44
* See the NOTICE file(s) distributed with this work for additional
55
* information regarding copyright ownership.
@@ -16,7 +16,7 @@
1616
// node-wot implementation of W3C WoT Servient
1717

1818
import { expect } from "chai";
19-
import path from "path";
19+
import path, { resolve } from "path";
2020
import {
2121
OPCUACUserNameAuthenticationScheme,
2222
OPCUACertificateAuthenticationScheme,
@@ -26,14 +26,27 @@ import {
2626
} from "@node-wot/core";
2727
import { InteractionOptions } from "wot-typescript-definitions";
2828

29-
import { OPCUAClient, OPCUAServer } from "node-opcua";
29+
import {
30+
IBasicSessionCallAsync,
31+
MessageSecurityMode,
32+
ObjectIds,
33+
OPCUAClient,
34+
OPCUAServer,
35+
SecurityPolicy,
36+
} from "node-opcua";
3037
import { coercePrivateKeyPem, readCertificate, readCertificatePEM, readPrivateKey } from "node-opcua-crypto";
3138
import { OPCUAClientFactory, OPCUAProtocolClient } from "../src";
3239
import { startServer } from "./fixture/basic-opcua-server";
3340
const endpoint = "opc.tcp://localhost:7890";
3441

3542
const { debug } = createLoggers("binding-opcua", "full-opcua-thing-test");
3643

44+
interface WhoAmI {
45+
UserName: string | null;
46+
UserIdentityTokenType: string | null;
47+
ChannelSecurityMode: string | null;
48+
ChannelSecurityPolicyUri: string | null;
49+
}
3750
const thingDescription: WoT.ThingDescription = {
3851
"@context": "https://www.w3.org/2019/wot/td/v1",
3952
"@type": ["Thing"],
@@ -48,8 +61,14 @@ const thingDescription: WoT.ThingDescription = {
4861
messageMode: "sign_encrypt",
4962
policy: "Basic256Sha256", // deprecated
5063
},
64+
// Aes128_Sha256_RsaOaep
65+
"c:sign-encrypt_aes128Sha256RsaOaep": <OPCUAChannelSecurityScheme>{
66+
scheme: "opcua-channel-security",
67+
messageMode: "sign_encrypt",
68+
policy: "Aes128_Sha256_RsaOaep",
69+
},
5170

52-
"c:sign": <OPCUAChannelSecurityScheme>{
71+
"c:sign_basic256Sha256": <OPCUAChannelSecurityScheme>{
5372
scheme: "opcua-channel-security",
5473
messageMode: "sign",
5574
policy: "Basic256Sha256",
@@ -89,13 +108,13 @@ const thingDescription: WoT.ThingDescription = {
89108
privateKey: undefined,
90109
},
91110
// compbo
92-
"c:sign-a:username-password": {
111+
"c:sign_basic256Sha256-a:username-password": {
93112
scheme: "combo",
94-
allOf: ["c:sign", "a:username-password"],
113+
allOf: ["c:sign_basic256Sha256", "a:username-password"],
95114
},
96-
"c:sign-a:username-invalid-password": {
115+
"c:sign_basic256Sha256-a:username-invalid-password": {
97116
scheme: "combo",
98-
allOf: ["c:sign", "a:username-invalid-password"],
117+
allOf: ["c:sign_basic256Sha256", "a:username-invalid-password"],
99118
},
100119
"c:sign-encrypt_basic256Sha256-a:username-password": {
101120
scheme: "combo",
@@ -146,10 +165,157 @@ const thingDescription: WoT.ThingDescription = {
146165
],
147166
},
148167
},
168+
actions: {
169+
whoAmI: {
170+
forms: [
171+
{
172+
type: "object",
173+
href: "/",
174+
op: ["invokeaction"],
175+
"opcua:nodeId": { root: "i=84", path: "/Objects/Server" },
176+
"opcua:method": { root: "i=84", path: "/Objects/Server/1:WhoAmI" },
177+
},
178+
],
179+
description: "query information about the log in user and current channel security mode",
180+
// see https://www.w3.org/TR/wot-thing-description11/#action-serialization-sample
181+
input: {
182+
type: "object",
183+
properties: {},
184+
required: [],
185+
},
186+
output: {
187+
type: "object",
188+
properties: {
189+
UserName: {
190+
type: "string",
191+
title: "the current user name",
192+
},
193+
UserIdentityTokenType: {
194+
type: "string",
195+
title: "the current user identity token type",
196+
},
197+
ChannelSecurityMode: {
198+
type: "string",
199+
title: "the current security mode",
200+
},
201+
ChannelSecurityPolicyUri: {
202+
type: "string",
203+
title: "the current security policy",
204+
},
205+
},
206+
required: ["UserName", "UserIdentityTokenType", "ChannelSecurityMode", "ChannelSecurityPolicyUri"],
207+
},
208+
},
209+
},
149210
};
150211

212+
function inferExpectedSecurityMode(security: string): WhoAmI {
213+
const expected: WhoAmI = {
214+
UserName: null,
215+
UserIdentityTokenType: "AnonymousIdentityToken",
216+
ChannelSecurityMode: MessageSecurityMode[MessageSecurityMode.None],
217+
ChannelSecurityPolicyUri: SecurityPolicy.None,
218+
};
219+
220+
if (security.match(/a:username-password/)) {
221+
expected.UserName = "joe";
222+
expected.UserIdentityTokenType = "UserNameIdentityToken";
223+
} else if (security.match(/certificate/)) {
224+
expected.UserName = null;
225+
expected.UserIdentityTokenType = "X509IdentityToken";
226+
} else {
227+
expected.UserName = null;
228+
expected.UserIdentityTokenType = "AnonymousIdentityToken";
229+
}
230+
231+
//
232+
if (security.match(/c:sign-encrypt/)) {
233+
expected.ChannelSecurityMode = "SignAndEncrypt";
234+
} else if (security.match(/c:sign/)) {
235+
expected.ChannelSecurityMode = "Sign";
236+
} else if (security.match(/c:no_security/)) {
237+
expected.ChannelSecurityMode = "None";
238+
expected.ChannelSecurityPolicyUri = "http://opcfoundation.org/UA/SecurityPolicy#None";
239+
}
240+
241+
if (security.match(/basic256Sha256/)) {
242+
expected.ChannelSecurityPolicyUri = "http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256";
243+
} else if (security.match(/aes128Sha256RsaOaep/)) {
244+
expected.ChannelSecurityPolicyUri = "http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep";
245+
}
246+
return expected;
247+
}
248+
249+
describe("Testing OPCUA Expected Value inference", () => {
250+
it("should infer expected values from security string", () => {
251+
let expected = inferExpectedSecurityMode("c:sign-encrypt_basic256Sha256-a:username-password");
252+
expect(expected.UserName).to.eql("joe");
253+
expect(expected.UserIdentityTokenType).to.eql("UserNameIdentityToken");
254+
expect(expected.ChannelSecurityMode).to.eql("SignAndEncrypt");
255+
expect(expected.ChannelSecurityPolicyUri).to.eql("http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256");
256+
257+
expected = inferExpectedSecurityMode("c:sign_basic256Sha256-a:username-password");
258+
expect(expected.UserName).to.eql("joe");
259+
expect(expected.UserIdentityTokenType).to.eql("UserNameIdentityToken");
260+
expect(expected.ChannelSecurityMode).to.eql("Sign");
261+
expect(expected.ChannelSecurityPolicyUri).to.eql("http://opcfoundation.org/UA/SecurityPolicy#Basic256Sha256");
262+
263+
expected = inferExpectedSecurityMode("c:no_security");
264+
expect(expected.UserName).to.eql(null);
265+
expect(expected.UserIdentityTokenType).to.eql("AnonymousIdentityToken");
266+
expect(expected.ChannelSecurityMode).to.eql("None");
267+
expect(expected.ChannelSecurityPolicyUri).to.eql("http://opcfoundation.org/UA/SecurityPolicy#None");
268+
269+
expected = inferExpectedSecurityMode("c:sign-encrypt_aes128Sha256RsaOaep-a:x509-certificate");
270+
expect(expected.UserName).to.eql(null);
271+
expect(expected.UserIdentityTokenType).to.eql("X509IdentityToken");
272+
expect(expected.ChannelSecurityMode).to.eql("SignAndEncrypt");
273+
expect(expected.ChannelSecurityPolicyUri).to.eql(
274+
"http://opcfoundation.org/UA/SecurityPolicy#Aes128_Sha256_RsaOaep"
275+
);
276+
});
277+
});
278+
151279
const possibleSecurityMode = Object.keys(thingDescription.securityDefinitions).filter((s) => !s.match(/invalid/));
152280
const possibleInvalidSecurityMode = Object.keys(thingDescription.securityDefinitions).filter((s) => s.match(/invalid/));
281+
describe("verify test securityDefinitions", () => {
282+
it("should have a coherent security definitions", () => {
283+
expect(thingDescription).to.be.an("object");
284+
const definitions = thingDescription.securityDefinitions;
285+
expect(definitions).to.be.an("object");
286+
expect(Object.keys(definitions).length).to.be.greaterThan(0);
287+
for (const key of Object.keys(definitions)) {
288+
const def = definitions[key];
289+
expect(def).to.have.property("scheme");
290+
if (def.scheme === "nosec") {
291+
continue;
292+
}
293+
if (def.scheme === "combo") {
294+
const comboDef = def as { scheme: string; allOf: string[] };
295+
expect(comboDef.allOf).to.be.an("array");
296+
expect(comboDef.allOf.length).to.be.greaterThan(0);
297+
for (const subKey of comboDef.allOf) {
298+
expect(definitions).to.have.property(subKey);
299+
}
300+
} else if (def.scheme === "opcua-channel-security") {
301+
const channelDef = def as OPCUAChannelSecurityScheme;
302+
expect(channelDef).to.have.property("messageMode");
303+
expect(["none", "sign", "sign_encrypt"]).to.include(channelDef.messageMode);
304+
// policy is optional
305+
} else if (def.scheme === "opcua-authentication") {
306+
const authDef = def as OPCUACertificateAuthenticationScheme | OPCUACUserNameAuthenticationScheme;
307+
expect(authDef).to.have.property("tokenType");
308+
if (authDef.tokenType === "username") {
309+
expect(authDef).to.have.property("userName");
310+
expect(authDef).to.have.property("password");
311+
} else if (authDef.tokenType === "certificate") {
312+
expect(authDef).to.have.property("certificate");
313+
expect(authDef).to.have.property("privateKey");
314+
}
315+
}
316+
}
317+
});
318+
});
153319

154320
describe("Testing OPCUA Security Combination", () => {
155321
let opcuaServer: OPCUAServer;
@@ -238,15 +404,20 @@ describe("Testing OPCUA Security Combination", () => {
238404

239405
async function doTest(
240406
thing: WoT.ConsumedThing,
241-
propertyName: string,
242407
localOptions: InteractionOptions
243-
): Promise<{ value?: number; err?: Error }> {
408+
): Promise<{ value?: number; whoAmI?: WhoAmI; err?: Error }> {
244409
debug("------------------------------------------------------");
245410
try {
246-
const content = await thing.readProperty(propertyName, localOptions);
411+
const propertyName = "temperature";
247412

413+
const content = await thing.readProperty(propertyName, localOptions);
248414
const value = (await content.value()) as number;
249-
return { value };
415+
416+
const result = await thing.invokeAction("whoAmI", {}, localOptions);
417+
const whoAmI = (await result?.value()) as WhoAmI;
418+
419+
debug(`whoAmI = ${JSON.stringify(whoAmI)}`);
420+
return { value, whoAmI };
250421
} catch (e) {
251422
debug(`${e}`);
252423
return { err: e as Error };
@@ -258,10 +429,13 @@ describe("Testing OPCUA Security Combination", () => {
258429
const localOptions = {};
259430
const { thing, servient } = await makeThing(security);
260431
try {
261-
const propertyName = "temperature";
262-
const { value, err } = await doTest(thing, propertyName, localOptions);
432+
const { value, whoAmI, err } = await doTest(thing, localOptions);
263433
expect(err).to.eql(undefined);
264434
expect(value).to.eql(25);
435+
436+
const expected = inferExpectedSecurityMode(security);
437+
expect(whoAmI).to.eql(expected);
438+
// infer expected result from security string
265439
} finally {
266440
await servient.shutdown();
267441
}
@@ -273,8 +447,7 @@ describe("Testing OPCUA Security Combination", () => {
273447
const localOptions = {};
274448
const { thing, servient } = await makeThing(security);
275449
try {
276-
const propertyName = "temperature";
277-
const { err } = await doTest(thing, propertyName, localOptions);
450+
const { err } = await doTest(thing, localOptions);
278451
expect(err).to.not.eql(undefined);
279452
} finally {
280453
await servient.shutdown();

0 commit comments

Comments
 (0)