node-opcua-client
Version:
pure nodejs OPCUA SDK - module client
1,191 lines (1,066 loc) • 53.1 kB
text/typescript
/**
* @module node-opcua-client-private
*/
import { createPublicKey, randomBytes } from "node:crypto";
import { callbackify } from "node:util";
import chalk from "chalk";
import { assert } from "node-opcua-assert";
import { createFastUninitializedBuffer } from "node-opcua-buffer-utils";
import { DataTypeExtractStrategy } from "node-opcua-client-dynamic-extension-object";
import {
type Certificate,
exploreCertificate,
extractPublicKeyFromCertificateSync,
makePrivateKeyFromPem,
type Nonce,
type PrivateKey,
toPem
} from "node-opcua-crypto/web";
import { LocalizedText } from "node-opcua-data-model";
import { checkDebugFlag, make_debugLog, make_errorLog, make_warningLog } from "node-opcua-debug";
import { extractFullyQualifiedDomainName } from "node-opcua-hostname";
import { readNamespaceArray } from "node-opcua-pseudo-session";
import {
type ClientSecureChannelLayer,
computeSignature,
fromURI,
getCryptoFactory,
SecurityPolicy
} from "node-opcua-secure-channel";
import {
type ApplicationDescriptionOptions,
ApplicationType,
EndpointDescription,
UserTokenType
} from "node-opcua-service-endpoints";
import { MessageSecurityMode, type UserTokenPolicy } from "node-opcua-service-secure-channel";
import {
ActivateSessionRequest,
ActivateSessionResponse,
AnonymousIdentityToken,
CreateSessionRequest,
CreateSessionResponse,
UserNameIdentityToken,
X509IdentityToken
} from "node-opcua-service-session";
import { type Callback, type CallbackT, type StatusCode, StatusCodes } from "node-opcua-status-code";
import type { SignatureDataOptions, UserIdentityToken } from "node-opcua-types";
import { isNullOrUndefined, matchUri } from "node-opcua-utils";
import type { NodeId } from "node-opcua-nodeid";
import type { OPCUAClientBaseEvents } from "../client_base";
import type { ClientSession } from "../client_session";
import type { ClientSubscriptionOptions } from "../client_subscription";
import type { Response } from "../common";
import {
type EndpointWithUserIdentity,
OPCUAClient,
type OPCUAClientOptions,
type WithSessionFuncP,
type WithSubscriptionFuncP
} from "../opcua_client";
import type { AnonymousIdentity, UserIdentityInfo, UserIdentityInfoUserName, UserIdentityInfoX509 } from "../user_identity_info";
import { ClientBaseImpl } from "./client_base_impl";
import { ClientSessionImpl } from "./client_session_impl";
import type { IClientBase } from "./i_private_client";
import { repair_client_sessions } from "./reconnection/reconnection";
interface TokenAndSignature {
userIdentityToken: UserIdentityToken | null;
userTokenSignature: SignatureDataOptions;
}
const doDebug = checkDebugFlag(__filename);
const debugLog = make_debugLog(__filename);
const errorLog = make_errorLog(__filename);
const warningLog = make_warningLog(__filename);
function validateServerNonce(serverNonce: Nonce | null): boolean {
return !(serverNonce && serverNonce.length < 32) || (serverNonce && serverNonce.length === 0);
}
function verifyEndpointDescriptionMatches(_client: OPCUAClientImpl, _responseServerEndpoints: EndpointDescription[]): boolean {
// The Server returns its EndpointDescriptions in the response. Clients use this information to
// determine whether the list of EndpointDescriptions returned from the Discovery Endpoint matches
// the Endpoints that the Server has. If there is a difference then the Client shall close the
// Session and report an error.
// The Server returns all EndpointDescriptions for the serverUri
// specified by the Client in the request. The Client only verifies EndpointDescriptions with a
// transportProfileUri that matches the profileUri specified in the original GetEndpoints request.
// A Client may skip this check if the EndpointDescriptions were provided by a trusted source
// such as the Administrator.
// serverEndpoints:
// The Client shall verify this list with the list from a Discovery Endpoint if it used a Discovery Endpoint
// fetch to the EndpointDescriptions.
// ToDo
return true;
}
const hasDeprecatedSecurityPolicy = (userIdentity: UserTokenPolicy) => {
return (
userIdentity.securityPolicyUri === SecurityPolicy.Basic128Rsa15 ||
userIdentity.securityPolicyUri === SecurityPolicy.Basic128
);
};
const ordered: string[] = [
// obsolete
SecurityPolicy.Basic128Rsa15,
SecurityPolicy.Basic192Rsa15,
SecurityPolicy.Basic256,
SecurityPolicy.None,
SecurityPolicy.Basic128,
SecurityPolicy.Basic192,
SecurityPolicy.Basic256Rsa15,
SecurityPolicy.Basic256Sha256,
SecurityPolicy.Aes128_Sha256_RsaOaep,
SecurityPolicy.Aes256_Sha256_RsaPss
];
const _compareSecurityPolicy = (a: string | null, b: string | null) => {
if (a === b) {
return 0;
}
if (!a && b) return 1;
if (a && !b) return -1;
const rankA = ordered.indexOf(a as string);
const rankB = ordered.indexOf(b as string);
return rankB - rankA;
};
const compareSecurityPolicy = (a: UserTokenPolicy, b: UserTokenPolicy) => {
return _compareSecurityPolicy(a.securityPolicyUri, b.securityPolicyUri);
};
function findUserTokenPolicy(endpointDescription: EndpointDescription, userTokenType: UserTokenType): UserTokenPolicy | null {
endpointDescription.userIdentityTokens = endpointDescription.userIdentityTokens || [];
let r = endpointDescription.userIdentityTokens.filter(
(userIdentity: UserTokenPolicy) => userIdentity.tokenType === userTokenType
);
if (r.length === 0) {
return null;
}
if (r.length > 1) {
// avoid Basic128Rsa15 & Basic128 encryption algorithm
// note: some servers (S7) sometime provides multiple policyId with various encryption algorithm
// when the connection is Encrypted.
// even though there is no need to further encrypt a password.
// Further more, Basic128Rsa15 & Basic128 encryption algorithm are flawed and not working any more
// with nodejs 21.11.1 onwards
r = r.filter((userIdentity: UserTokenPolicy) => !hasDeprecatedSecurityPolicy(userIdentity));
}
if (r.length > 1) {
if (endpointDescription.securityMode === MessageSecurityMode.SignAndEncrypt) {
// no encryption will do if available
const unencrypted = r.find(
(userIdentity: UserTokenPolicy) =>
userIdentity.securityPolicyUri === SecurityPolicy.None || !userIdentity.securityPolicyUri
);
if (unencrypted) return unencrypted;
}
// if not then use the strongest encryption,
r = r.sort(compareSecurityPolicy);
}
return r.length === 0 ? null : r[0];
}
interface IdentityTokenContext {
endpoint: EndpointDescription;
securityPolicy: SecurityPolicy;
serverCertificate: Certificate;
serverNonce: Buffer;
}
function createAnonymousIdentityToken(context: IdentityTokenContext): AnonymousIdentityToken {
const endpoint = context.endpoint;
const userTokenPolicy = findUserTokenPolicy(endpoint, UserTokenType.Anonymous);
if (!userTokenPolicy) {
throw new Error("Cannot find ANONYMOUS user token policy in end point description");
}
return new AnonymousIdentityToken({ policyId: userTokenPolicy.policyId });
}
interface X509TokenAndSignature {
userIdentityToken: X509IdentityToken;
userTokenSignature: SignatureDataOptions;
}
/**
*
* @param context
* @param certificate - the user certificate
* @param privateKey - the private key associated with the user certificate
*/
function createX509IdentityToken(
context: IdentityTokenContext,
certificate: Certificate,
privateKey: PrivateKey
): X509TokenAndSignature {
const endpoint = context.endpoint;
assert(endpoint instanceof EndpointDescription);
const userTokenPolicy = findUserTokenPolicy(endpoint, UserTokenType.Certificate);
// c8 ignore next
if (!userTokenPolicy) {
throw new Error("Cannot find Certificate (X509) user token policy in end point description");
}
let securityPolicy = fromURI(userTokenPolicy.securityPolicyUri);
// if the security policy is not specified we use the session security policy
if (securityPolicy === SecurityPolicy.Invalid) {
securityPolicy = context.securityPolicy;
}
const userIdentityToken = new X509IdentityToken({
certificateData: certificate,
policyId: userTokenPolicy.policyId
});
const serverCertificate: Certificate = context.serverCertificate;
assert(serverCertificate instanceof Buffer);
const serverNonce: Nonce = context.serverNonce || Buffer.alloc(0);
assert(serverNonce instanceof Buffer);
// see Release 1.02 155 OPC Unified Architecture, Part 4
const cryptoFactory = getCryptoFactory(securityPolicy);
// c8 ignore next
if (!cryptoFactory) {
throw new Error(" Unsupported security Policy");
}
/**
* OPCUA Spec 1.04 - part 4
* page 28:
* 5.6.3.1
* ...
* If the token is an X509IdentityToken then the proof is a signature generated with private key
* associated with the Certificate. The data to sign is created by appending the last serverNonce to
* the **serverCertificate** specified in the CreateSession response. If a token includes a secret then it
* should be encrypted using the public key from the serverCertificate.
*
* page 155:
* Token Encryption and Proof of Possession
* 7.36.2.1 Overview
* The Client shall always prove possession of a UserIdentityToken when it passes it to the Server.
* Some tokens include a secret such as a password which the Server will accept as proof. In order
* to protect these secrets the Token may be encrypted before it is passed to the Server. Other types
* of tokens allow the Client to create a signature with the secret associated with the Token. In these
* cases, the Client proves possession of a UserIdentityToken by creating a signature with the secret
* and passing it to the Server
*
* page 159:
* 7.36.5 X509IdentityTokens
* The X509IdentityToken is used to pass an X.509 v3 Certificate which is issued by the user.
* This token shall always be accompanied by a Signature in the userTokenSignature parameter of
* ActivateSession if required by the SecurityPolicy. The Server should specify a SecurityPolicy for
* the UserTokenPolicy if the SecureChannel has a SecurityPolicy of None.
*/
// now create the proof of possession, by creating a signature
// The data to sign is created by appending the last serverNonce to the serverCertificate
// The signature generated with private key associated with the User Certificate
const userTokenSignature = computeSignature(serverCertificate, serverNonce, privateKey, securityPolicy) as SignatureDataOptions;
return { userIdentityToken, userTokenSignature };
}
function createUserNameIdentityToken(
session: IdentityTokenContext,
userName: string | null,
password: string | null
): UserNameIdentityToken {
// assert(endpoint instanceof EndpointDescription);
assert(userName === null || typeof userName === "string");
assert(password === null || typeof password === "string");
const endpoint = session.endpoint;
assert(endpoint instanceof EndpointDescription);
/**
* OPC Unified Architecture 1.0.4: Part 4 155
* Each UserIdentityToken allowed by an Endpoint shall have a UserTokenPolicy specified in the
* EndpointDescription. The UserTokenPolicy specifies what SecurityPolicy to use when encrypting
* or signing. If this SecurityPolicy is omitted then the Client uses the SecurityPolicy in the
* EndpointDescription. If the matching SecurityPolicy is set to None then no encryption or signature
* is required.
*
*/
const userTokenPolicy = findUserTokenPolicy(endpoint, UserTokenType.UserName);
// c8 ignore next
if (!userTokenPolicy) {
throw new Error("Cannot find USERNAME user token policy in end point description");
}
let securityPolicy = fromURI(userTokenPolicy.securityPolicyUri);
// if the security policy is not specified we use the session security policy
if (securityPolicy === SecurityPolicy.Invalid) {
securityPolicy = session.securityPolicy;
}
let identityToken: UserNameIdentityToken;
let serverCertificate: Buffer | string | null = session.serverCertificate;
// if server does not provide certificate use unencrypted password
if (!serverCertificate || serverCertificate.length === 0) {
identityToken = new UserNameIdentityToken({
encryptionAlgorithm: null,
password: Buffer.from(password as string, "utf-8"),
policyId: userTokenPolicy.policyId,
userName
});
return identityToken;
}
assert(serverCertificate instanceof Buffer);
serverCertificate = toPem(serverCertificate, "CERTIFICATE");
const publicKey = createPublicKey(extractPublicKeyFromCertificateSync(serverCertificate));
const serverNonce: Nonce = session.serverNonce || Buffer.alloc(0);
assert(serverNonce instanceof Buffer);
// If None is specified for the UserTokenPolicy and SecurityPolicy is None
// then the password only contains the UTF-8 encoded password.
// note: this means that password is sent in clear text to the server
// note: OPCUA specification discourages use of unencrypted password
// but some old OPCUA server may only provide this policy and we
// still have to support in the client?
if (securityPolicy === SecurityPolicy.None) {
identityToken = new UserNameIdentityToken({
encryptionAlgorithm: null,
password: Buffer.from(password as string, "utf-8"),
policyId: userTokenPolicy.policyId,
userName
});
return identityToken;
}
// see Release 1.02 155 OPC Unified Architecture, Part 4
const cryptoFactory = getCryptoFactory(securityPolicy);
// c8 ignore next
if (!cryptoFactory) {
throw new Error(` Unsupported security Policy ${securityPolicy.toString()}`);
}
identityToken = new UserNameIdentityToken({
encryptionAlgorithm: cryptoFactory.asymmetricEncryptionAlgorithm,
password: Buffer.from(password as string, "utf-8"),
policyId: userTokenPolicy.policyId,
userName
});
// now encrypt password as requested
const lenBuf = createFastUninitializedBuffer(4);
lenBuf.writeUInt32LE(identityToken.password.length + serverNonce.length, 0);
const block = Buffer.concat([lenBuf, identityToken.password, serverNonce]);
identityToken.password = cryptoFactory.asymmetricEncrypt(block, publicKey);
return identityToken;
}
function _adjustRevisedSessionTimeout(revisedSessionTimeout: number, requestedTimeout: number): number {
// Some old OPCUA Servers are known to report an invalid revisedSessionTimeout
// such as Siemens SimoCode Pro V.
// we need to adjust the value here, by guessing a sensible sessionTimeout value to use instead.
if (revisedSessionTimeout < 1e-10) {
warningLog(
`the revisedSessionTimeout ${revisedSessionTimeout} reported by the server is inconsistent and has been adjusted back to requestedTimeout ${requestedTimeout}`
);
return requestedTimeout;
}
if (revisedSessionTimeout < OPCUAClientImpl.minimumRevisedSessionTimeout) {
warningLog(
`the revisedSessionTimeout ${revisedSessionTimeout} is smaller than the minimum timeout (OPCUAClientImpl.minimumRevisedSessionTimeout = ${OPCUAClientImpl.minimumRevisedSessionTimeout}) and has been clamped to this value`
);
return OPCUAClientImpl.minimumRevisedSessionTimeout;
}
return revisedSessionTimeout;
}
export class OPCUAClientImpl extends ClientBaseImpl<OPCUAClientBaseEvents> {
public static minimumRevisedSessionTimeout = 100.0;
private _retryCreateSessionTimer?: NodeJS.Timeout;
public static create(options: OPCUAClientOptions): OPCUAClient {
return new OPCUAClientImpl(options);
}
public endpoint?: EndpointDescription;
private endpointMustExist: boolean;
private requestedSessionTimeout: number;
private ___sessionName_counter: number;
private serverUri?: string;
private clientNonce?: Nonce;
public dataTypeExtractStrategy: DataTypeExtractStrategy;
constructor(options?: OPCUAClientOptions) {
options = options || {};
super(options);
this.dataTypeExtractStrategy = options.dataTypeExtractStrategy || DataTypeExtractStrategy.Auto;
// @property endpointMustExist {Boolean}
// if set to true , create Session will only accept connection from server which endpoint_url has been reported
// by GetEndpointsRequest.
// By default, the client is strict.
if (Object.hasOwn(options, "endpoint_must_exist")) {
if (Object.hasOwn(options, "endpointMustExist")) {
throw new Error(
"endpoint_must_exist is deprecated! you must now use endpointMustExist instead of endpoint_must_exist "
);
}
warningLog("Warning: endpoint_must_exist is now deprecated, use endpointMustExist instead");
options.endpointMustExist = options.endpoint_must_exist;
}
this.endpointMustExist = isNullOrUndefined(options.endpointMustExist) ? true : !!options.endpointMustExist;
this.requestedSessionTimeout = options.requestedSessionTimeout || 60000; // 1 minute
this.___sessionName_counter = 0;
this.endpoint = undefined;
}
/**
* create and activate a new session
*
*
* @example
* // create a anonymous session
* const session = await client.createSession();
*
* @example
* // create a session with a userName and password
* const session = await client.createSession({
* type: UserTokenType.UserName,
* userName: "JoeDoe",
* password:"secret"
* });
*
*/
public async createSession(userIdentityInfo?: UserIdentityInfo): Promise<ClientSession>;
public createSession(userIdentityInfo: UserIdentityInfo, callback: Callback<ClientSession>): void;
public createSession(callback: Callback<ClientSession>): void;
/**
* @internal
* @param args
*
*/
// biome-ignore lint/suspicious/noExplicitAny: overload implementation
public createSession(...args: any[]): any {
if (args.length === 1) {
return this.createSession({ type: UserTokenType.Anonymous }, args[0]);
}
const userIdentityInfo = args[0] || { type: UserTokenType.Anonymous };
const callback = args[1];
assert(typeof callback === "function");
this._createSession((err: Error | null, session?: ClientSession) => {
if (err) {
callback(err);
} else {
/* c8 ignore next */
if (!session) {
return callback(new Error("Internal Error"));
}
this._addSession(session as ClientSessionImpl);
this._activateSession(
session as ClientSessionImpl,
userIdentityInfo,
(err1: Error | null, session2?: ClientSessionImpl) => {
if (err1) {
session
.close(true)
.then(() => {
callback(err1, null);
})
.catch((err2) => {
err2;
callback(err1, null);
});
} else {
callback(null, session2);
}
}
);
}
});
}
/**
* createSession2 create a session with persistance
*
* - if the server returns BadTooManySession, the method will make an other attempt
* until create session succeed or connection is closed.
*
* @experimental
* @param userIdentityInfo
*/
public async createSession2(userIdentityInfo?: UserIdentityInfo): Promise<ClientSession>;
public createSession2(userIdentityInfo: UserIdentityInfo, callback: Callback<ClientSession>): void;
public createSession2(callback: Callback<ClientSession>): void;
// biome-ignore lint/suspicious/noExplicitAny: overload implementation
public createSession2(...args: any[]): any {
if (args.length === 1) {
return this.createSession2({ type: UserTokenType.Anonymous }, args[0]);
}
const userIdentityInfo = args[0] as UserIdentityInfo;
const callback = args[1] as Callback<ClientSession>;
if (!this._secureChannel) {
// we do not have a connection anymore
return callback(new Error("Connection is closed"));
}
if (this._internalState === "disconnected" || this._internalState === "disconnecting") {
return callback(new Error(`disconnecting`));
}
return this.createSession(args[0], (err: Error | null, session?: ClientSession) => {
if (err?.message.match(/BadTooManySessions/)) {
const delayToRetry = 5; // seconds
errorLog(`TooManySession .... we need to retry later ... in ${delayToRetry} secondes ${this._internalState}`);
this._retryCreateSessionTimer = setTimeout(() => {
errorLog(`TooManySession .... now retrying (${this._internalState})`);
this.createSession2(userIdentityInfo, callback);
}, delayToRetry * 1000);
return;
}
callback(err, session);
});
}
/**
* @deprecated use session.changeUser instead
*/
public async changeSessionIdentity(session: ClientSession, userIdentityInfo: UserIdentityInfo): Promise<StatusCode>;
public changeSessionIdentity(session: ClientSession, userIdentityInfo: UserIdentityInfo, callback: CallbackT<StatusCode>): void;
// biome-ignore lint/suspicious/noExplicitAny: overload implementation
public changeSessionIdentity(...args: any[]): any {
warningLog(
"[NODE-OPCUA-W34] OPCUAClient.changeSessionIdentity(session,userIdentity) is deprecated use ClientSession.changeUser(userIdentity) instead"
);
const session = args[0] as ClientSessionImpl;
const userIdentityInfo = args[1] as UserIdentityInfo;
const callback = args[2];
assert(typeof callback === "function");
session.changeUser(userIdentityInfo, callback);
}
/**
* close a session, internal
*/
public closeSession(session: ClientSession, deleteSubscriptions: boolean): Promise<void>;
public closeSession(session: ClientSession, deleteSubscriptions: boolean, callback: (err?: Error) => void): void;
public closeSession(
session: ClientSession,
deleteSubscriptions: boolean,
callback?: (err?: Error) => void
): Promise<void> | void {
if (this._retryCreateSessionTimer) {
clearTimeout(this._retryCreateSessionTimer);
this._retryCreateSessionTimer = undefined;
}
super.closeSession(
session as unknown as ClientSessionImpl,
deleteSubscriptions,
callback as (err?: Error) => void
);
}
public toString(): string {
let str = ClientBaseImpl.prototype.toString.call(this);
str += ` requestedSessionTimeout....... ${this.requestedSessionTimeout}\n`;
str += ` endpointUrl................... ${this.endpointUrl}\n`;
str += ` serverUri..................... ${this.serverUri}\n`;
return str;
}
/**
*
* @example
*
* ```javascript
*
* const session = await OPCUAClient.createSession(endpointUrl);
* const dataValue = await session.read({ nodeId, attributeId: AttributeIds.Value });
* await session.close();
*
* ```
* @stability experimental
*
* @param endpointUrl
* @param userIdentity
* @returns session
*
*
* const create
*/
// biome-ignore lint/suspicious/useAdjacentOverloadSignatures: static vs instance method
public static async createSession(
endpointUrl: string,
userIdentity?: UserIdentityInfo,
clientOptions?: OPCUAClientOptions
): Promise<ClientSession> {
const client = OPCUAClient.create(clientOptions || {});
await client.connect(endpointUrl);
const session = await client.createSession2(userIdentity);
// biome-ignore lint/suspicious/noExplicitAny: monkey patch close
const oldClose = session.close as any;
// biome-ignore lint/suspicious/noExplicitAny: monkey patch close
(session as any).close = withCallback((...args: any[]): any => {
if (args.length === 1) {
return session.close(true, args[0]);
}
const deleteSubscriptions = args[0] as boolean;
const callback = args[1] as Callback<void>;
session.close = oldClose;
oldClose.call(session, deleteSubscriptions, (_err?: Error) => {
client.disconnect((err?: Error | null) => {
callback(err as Error);
});
});
});
return session;
}
/**
*
* @param connectionPoint
* @param func
* @returns
*/
public async withSessionAsync<T>(connectionPoint: string | EndpointWithUserIdentity, func: WithSessionFuncP<T>): Promise<T> {
assert(typeof func === "function");
assert(func.length === 1, "expecting a single argument in func");
const endpointUrl: string = typeof connectionPoint === "string" ? connectionPoint : connectionPoint.endpointUrl;
const userIdentity: UserIdentityInfo =
typeof connectionPoint === "string" ? { type: UserTokenType.Anonymous } : connectionPoint.userIdentity;
this.on("backoff", (count, delay) => {
warningLog("cannot connect to ", endpointUrl, `attempt #${count}`, " retrying in ", delay);
});
await this.connect(endpointUrl);
try {
const session = await this.createSession2(userIdentity);
let result: T;
// always need this
await readNamespaceArray(session);
try {
result = await func(session);
return result;
} catch (err) {
errorLog(err);
throw err;
} finally {
await session.close();
}
} catch (err) {
errorLog((err as Error).message);
throw err;
} finally {
await this.disconnect();
}
}
public async withSubscriptionAsync<T>(
connectionPoint: string | EndpointWithUserIdentity,
parameters: ClientSubscriptionOptions,
func: WithSubscriptionFuncP<T>
): Promise<T> {
return await this.withSessionAsync(connectionPoint, async (session: ClientSession) => {
assert(session, " session must exist");
const client1 = this as IClientBase;
if (client1.beforeSubscriptionRecreate) {
await client1.beforeSubscriptionRecreate(session);
}
const subscription = await session.createSubscription2(parameters);
try {
const result = await func(session, subscription);
return result;
} catch (err) {
errorLog("withSubscriptionAsync inner function failed ", (<Error>err).message);
throw err;
} finally {
await subscription.terminate();
}
});
}
/**
* transfer session to this client
* @param session
* @param callback
* @return {*}
*/
public async reactivateSession(session: ClientSession): Promise<void>;
public reactivateSession(session: ClientSession, callback: (err?: Error) => void): void;
// biome-ignore lint/suspicious/noExplicitAny: overload implementation
public reactivateSession(session: ClientSession, callback?: (err?: Error) => void): any {
const internalSession = session as ClientSessionImpl;
assert(typeof callback === "function");
if (!this._secureChannel) {
return callback?.(new Error(" client must be connected first"));
}
// c8 ignore next
if (!this.__resolveEndPoint() || !this.endpoint) {
return callback?.(
new Error(
" End point must exist " +
this._secureChannel?.endpointUrl +
" securityMode = " +
MessageSecurityMode[this.securityMode] +
" securityPolicy = " +
this.securityPolicy
)
);
}
assert(
!internalSession._client || matchUri(internalSession._client.endpointUrl, this.endpointUrl),
"cannot reactivateSession on a different endpoint"
);
const old_client = internalSession._client;
debugLog("OPCUAClientImpl#reactivateSession");
this._activateSession(
internalSession,
internalSession.userIdentityInfo as UserIdentityInfo,
(err: Error | null /*, newSession?: ClientSessionImpl*/) => {
if (!err) {
if (old_client !== this) {
// remove session from old client:
if (old_client) {
old_client._removeSession(internalSession);
assert(old_client._sessions.indexOf(internalSession) === -1);
}
this._addSession(internalSession);
assert(internalSession._client === this);
assert(!internalSession._closed, "session should not vbe closed");
assert(this._sessions.indexOf(internalSession) !== -1);
}
callback?.();
} else {
// c8 ignore next
if (doDebug) {
debugLog(chalk.red.bgWhite("reactivateSession has failed !"), err.message);
}
callback?.(err);
}
}
);
}
/**
* @internal
* @private
*/
public _on_connection_reestablished(callback: (err?: Error) => void): void {
super._on_connection_reestablished((/*err?: Error*/) => {
repair_client_sessions(this, callback);
});
}
/**
*
* @internal
* @private
*/
#createSession_step3(session: ClientSessionImpl, callback: (err: Error | null, session?: ClientSessionImpl) => void): void {
assert(typeof callback === "function");
assert(this.serverUri !== undefined, ` must have a valid server URI ${this.serverUri}`);
assert(this.endpointUrl !== undefined, " must have a valid server endpointUrl");
assert(this.endpoint);
// c8 ignore next
if (!this._secureChannel) {
callback(new Error("Invalid channel"));
return;
}
const applicationUri = this._getApplicationUri();
const applicationDescription: ApplicationDescriptionOptions = {
applicationName: new LocalizedText({ text: this.applicationName, locale: null }),
applicationType: ApplicationType.Client,
applicationUri,
discoveryProfileUri: undefined,
discoveryUrls: [],
gatewayServerUri: undefined,
productUri: "NodeOPCUA-Client"
};
// note : do not confuse CreateSessionRequest.clientNonce with OpenSecureChannelRequest.clientNonce
// which are two different nonce, with different size (although they share the same name )
this.clientNonce = randomBytes(32);
// recycle session name if already exists
const sessionName = session.name;
const request = new CreateSessionRequest({
clientCertificate: this.getCertificate(),
clientDescription: applicationDescription,
clientNonce: this.clientNonce,
endpointUrl: this.endpointUrl,
maxResponseMessageSize: 800000,
requestedSessionTimeout: this.requestedSessionTimeout,
serverUri: this.serverUri,
sessionName
});
// a client Nonce must be provided if security mode is set
assert(this._secureChannel.securityMode === MessageSecurityMode.None || request.clientNonce !== null);
this.performMessageTransaction(request, (err: Error | null, response?: Response) => {
/* c8 ignore next */
if (err) {
debugLog("__createSession_step3 has failed", err.message);
return callback(err);
// // we could have an invalid state here or a connection error
// errorLog("error: ", err.message, " retrying in ... 5 secondes");
// setTimeout(() => {
// errorLog(" .... now retrying");
// this.__createSession_step3(session, callback);
// }, 5 * 1000);
// return;
}
/* c8 ignore next */
if (!response || !(response instanceof CreateSessionResponse)) {
return callback(new Error("internal error"));
}
if (response.responseHeader.serviceResult === StatusCodes.BadTooManySessions) {
return callback(new Error(response.responseHeader.serviceResult.toString()));
}
if (response.responseHeader.serviceResult !== StatusCodes.Good) {
err = new Error(
`Error ${response.responseHeader.serviceResult.name} ${response.responseHeader.serviceResult.description}`
);
return callback(err);
}
// c8 ignore next
if (!validateServerNonce(response.serverNonce)) {
return callback(new Error("Invalid server Nonce"));
}
// todo: verify SignedSoftwareCertificates and response.serverSignature
session.name = request.sessionName || "";
session.sessionId = response.sessionId;
session.authenticationToken = response.authenticationToken;
session.timeout = _adjustRevisedSessionTimeout(response.revisedSessionTimeout, this.requestedSessionTimeout);
session.serverNonce = response.serverNonce;
session.serverCertificate = response.serverCertificate;
session.serverSignature = response.serverSignature;
debugLog("revised session timeout = ", session.timeout, response.revisedSessionTimeout);
response.serverEndpoints = response.serverEndpoints || [];
if (!verifyEndpointDescriptionMatches(this, response.serverEndpoints)) {
errorLog("Endpoint description previously retrieved with GetEndpointsDescription");
errorLog("CreateSessionResponse.serverEndpoints= ");
errorLog(response.serverEndpoints);
return callback(new Error("Invalid endpoint descriptions Found"));
}
// this._serverEndpoints = response.serverEndpoints;
session.serverEndpoints = response.serverEndpoints;
callback(null, session);
});
}
/**
*
* @internal
* @private
*/
public __createSession_step2(
session: ClientSessionImpl,
callback: (err: Error | null, session?: ClientSessionImpl) => void
): void {
callbackify(extractFullyQualifiedDomainName)(() => {
this.#createSession_step3(session, callback);
});
}
/**
* @internal
* @private
*/
public _activateSession(
session: ClientSessionImpl,
userIdentityInfo: UserIdentityInfo,
callback: (err: Error | null, session?: ClientSessionImpl) => void
): void {
// see OPCUA Part 4 - $7.35
assert(typeof callback === "function");
// c8 ignore next
if (!this._secureChannel) {
callback(new Error(" No secure channel"));
return;
}
const serverCertificate = session.serverCertificate;
// If the securityPolicyUri is None and none of the UserTokenPolicies requires encryption,
// the Client shall ignore the ApplicationInstanceCertificate (serverCertificate)
assert(serverCertificate === null || serverCertificate instanceof Buffer);
const serverNonce = session.serverNonce;
assert(!serverNonce || serverNonce instanceof Buffer);
// make sure session is attached to this client
const _old_client = session._client;
session._client = this;
const context: IdentityTokenContext = {
endpoint: this.endpoint as EndpointDescription,
securityPolicy: this._secureChannel.securityPolicy,
serverCertificate,
serverNonce: serverNonce as Buffer // please check this !
};
this.createUserIdentityToken(context, userIdentityInfo, (err: Error | null, data?: TokenAndSignature | null) => {
if (err) {
session._client = _old_client;
return callback(err);
}
data = data as TokenAndSignature;
const userIdentityToken: UserIdentityToken = data.userIdentityToken as UserIdentityToken;
const userTokenSignature: SignatureDataOptions = data.userTokenSignature as SignatureDataOptions;
// TODO. fill the ActivateSessionRequest
// see 5.6.3.2 Parameters OPC Unified Architecture, Part 4 30 Release 1.02
const request = new ActivateSessionRequest({
// This is a signature generated with the private key associated with the
// clientCertificate. The SignatureAlgorithm shall be the AsymmetricSignatureAlgorithm
// specified in the SecurityPolicy for the Endpoint. The SignatureData type is defined in 7.30.
clientSignature: this.computeClientSignature(this._secureChannel as ClientSecureChannelLayer, serverCertificate, serverNonce) || undefined,
// These are the SoftwareCertificates which have been issued to the Client application.
// The productUri contained in the SoftwareCertificates shall match the productUri in the
// ApplicationDescription passed by the Client in the CreateSession requests. Certificates without
// matching productUri should be ignored. Servers may reject connections from Clients if they are
// not satisfied with the SoftwareCertificates provided by the Client.
// This parameter only needs to be specified in the first ActivateSession request
// after CreateSession.
// It shall always be omitted if the maxRequestMessageSize returned from the Server in the
// CreateSession response is less than one megabyte.
// The SignedSoftwareCertificate type is defined in 7.31.
clientSoftwareCertificates: [],
// List of locale ids in priority order for localized strings. The first LocaleId in the list
// has the highest priority. If the Server returns a localized string to the Client, the Server
// shall return the translation with the highest priority that it can. If it does not have a
// translation for any of the locales identified in this list, then it shall return the string
// value that it has and include the locale id with the string.
// See Part 3 for more detail on locale ids. If the Client fails to specify at least one locale id,
// the Server shall use any that it has.
// This parameter only needs to be specified during the first call to ActivateSession during
// a single application Session. If it is not specified the Server shall keep using the current
// localeIds for the Session.
localeIds: [],
// The credentials of the user associated with the Client application. The Server uses these
// credentials to determine whether the Client should be allowed to activate a Session and what
// resources the Client has access to during this Session. The UserIdentityToken is an extensible
// parameter type defined in 7.35.
// The EndpointDescription specifies what UserIdentityTokens the Server shall accept.
userIdentityToken,
// If the Client specified a user identity token that supports digital signatures,
// then it shall create a signature and pass it as this parameter. Otherwise the parameter
// is omitted.
// The SignatureAlgorithm depends on the identity token type.
userTokenSignature
});
request.requestHeader.authenticationToken = session.authenticationToken as NodeId;
session.lastRequestSentTime = new Date();
this.performMessageTransaction(request, (err1: Error | null, response?: Response) => {
if (!err1 && response && response.responseHeader.serviceResult === StatusCodes.Good) {
/* c8 ignore next */
if (!(response instanceof ActivateSessionResponse)) {
return callback(new Error("Internal Error"));
}
if (!validateServerNonce(response.serverNonce)) {
return callback(new Error("Invalid server Nonce"));
}
session._client = this;
session.serverNonce = response.serverNonce;
session.lastResponseReceivedTime = new Date();
if (this.keepSessionAlive) {
session.startKeepAliveManager(this.keepAliveInterval);
}
session.userIdentityInfo = userIdentityInfo;
return callback(null, session);
} else {
// restore client
session._client = _old_client;
/* c8 ignore next */
if (!err1 && response) {
err1 = new Error(response.responseHeader.serviceResult.toString());
}
session._client = _old_client;
return callback(err1);
}
});
});
}
/**
*
* @private
*/
private _nextSessionName() {
if (!this.___sessionName_counter) {
this.___sessionName_counter = 0;
}
this.___sessionName_counter += 1;
return this.clientName + this.___sessionName_counter;
}
/**
*
* @private
*/
private _getApplicationUri() {
const certificate = this.getCertificate();
let applicationUri: string;
if (certificate) {
const e = exploreCertificate(certificate);
if (e.tbsCertificate.extensions?.subjectAltName?.uniformResourceIdentifier) {
applicationUri = e.tbsCertificate.extensions.subjectAltName.uniformResourceIdentifier[0];
} else {
errorLog("Certificate has no extensions.subjectAltName.uniformResourceIdentifier, ");
errorLog(toPem(certificate, "CERTIFICATE"));
applicationUri = this._getBuiltApplicationUri();
}
} else {
applicationUri = this._getBuiltApplicationUri();
}
return applicationUri;
}
/**
*
* @private
*/
private __resolveEndPoint() {
this.securityPolicy = this.securityPolicy || SecurityPolicy.None;
let endpoint = this.findEndpoint(this._secureChannel?.endpointUrl || "", this.securityMode, this.securityPolicy);
this.endpoint = endpoint;
// this is explained here : see OPCUA Part 4 Version 1.02 $5.4.1 page 12:
// A Client shall verify the HostName specified in the Server Certificate is the same as the HostName
// contained in the endpointUrl provided in the EndpointDescription. If there is a difference then the
// Client shall report the difference and may close the SecureChannel.
if (!this.endpoint) {
if (this.endpointMustExist) {
warningLog(
"OPCUAClientImpl#endpointMustExist = true and endpoint with url ",
this._secureChannel?.endpointUrl,
" cannot be found"
);
const infos = this._serverEndpoints.map(
(endpoint: EndpointDescription) =>
`${endpoint.endpointUrl} ${MessageSecurityMode[endpoint.securityMode]}, ${endpoint.securityPolicyUri} `
);
warningLog("Valid endpoints are ");
warningLog(` ${infos.join("\n ")}`);
return false;
} else {
// fallback :
// our strategy is to take the first server_end_point that match the security settings
// ( is this really OK ?)
// this will permit us to access a OPCUA Server using it's IP address instead of its hostname
endpoint = this.findEndpointForSecurity(this.securityMode, this.securityPolicy);
if (!endpoint) {
return false;
}
this.endpoint = endpoint;
}
}
return true;
}
/**
*
* @private
*/
private _createSession(callback: (err: Error | null, session?: ClientSession) => void) {
assert(typeof callback === "function");
assert(this._secureChannel);
if (!this.__resolveEndPoint() || !this.endpoint) {
/* c8 ignore next */
if (this._serverEndpoints) {
warningLog(
"server endpoints =",
this._serverEndpoints
.map(
(endpoint) =>
endpoint.endpointUrl +
" " +
MessageSecurityMode[endpoint.securityMode] +
" " +
endpoint.securityPolicyUri +
" " +
endpoint.userIdentityTokens?.map((u) => UserTokenType[u.tokenType]).join(",")
)
.join("\n")
);
}
return callback(
new Error(
" End point must exist " +
this._secureChannel?.endpointUrl +
" securityMode = " +
MessageSecurityMode[this.securityMode] +
" securityPolicy = " +
this.securityPolicy
)
);
}
this.serverUri = this.endpoint.server.applicationUri || "invalid application uri";
this.endpointUrl = this._secureChannel?.endpointUrl || "";
const session = new ClientSessionImpl(this);
session.name = this._nextSessionName();
this.__createSession_step2(session, callback);
}
/**
*
* @private
*/
private computeClientSignature(channel: ClientSecureChannelLayer, serverCertificate: Buffer, serverNonce: Nonce | undefined) {
return computeSignature(serverCertificate, serverNonce || Buffer.alloc(0), this.getPrivateKey(), channel.securityPolicy);
}
/**
*
* @private
*/
private createUserIdentityToken(
context: IdentityTokenContext,
userIdentityInfo: UserIdentityInfo,
callback: (err: Error | null, data?: TokenAndSignature) => void
) {
// biome-ignore lint/suspicious/noExplicitAny: user provided object that needs soft type coercion
function coerceUserIdentityInfo(identityInfo: any): UserIdentityInfo {
if (!identityInfo) {
return { type: UserTokenType.Anonymous };
}
if (Object.hasOwn(identityInfo, "type")) {
return identityInfo as UserIdentityInfo;
}
if (Object.hasOwn(identityInfo, "userName")) {
identityInfo.type = UserTokenType.UserName;
return identityInfo as UserIdentityInfoUserName;
}
if (Object.hasOwn(identityInfo, "certificateData")) {
identityInfo.type = UserTokenType.Certificate;
return identityInfo as UserIdentityInfoX509;
}
identityInfo.type = UserTokenType.Anonymous;
return identityInfo as Anony