/** * @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 { 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; public createSession(userIdentityInfo: UserIdentityInfo, callback: Callback): void; public createSession(callback: Callback): 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; public createSession2(userIdentityInfo: UserIdentityInfo, callback: Callback): void; public createSession2(callback: Callback): 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; 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; public changeSessionIdentity(session: ClientSession, userIdentityInfo: UserIdentityInfo, callback: CallbackT): 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; public closeSession(session: ClientSession, deleteSubscriptions: boolean, callback: (err?: Error) => void): void; public closeSession( session: ClientSession, deleteSubscriptions: boolean, callback?: (err?: Error) => void ): Promise | void { if (this._retryCreateSessionTimer) { clearTimeout(this._retryCreateSessionTimer); this._retryCreateSessionTimer = undefined; } super.closeSession( session as unknown as ClientSessionImpl, deleteSubscriptions, callback as (err?: Error) => void ); } public toJSON(): Record { return { endpointUrl: this.endpointUrl, serverUri: this.serverUri, requestedSessionTimeout: this.requestedSessionTimeout, status: this._internalState }; } 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; } public [Symbol.for("nodejs.util.inspect.custom")](): string { return this.toString(); } /** * * @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 { 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; 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(connectionPoint: string | EndpointWithUserIdentity, func: WithSessionFuncP): Promise { 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( connectionPoint: string | EndpointWithUserIdentity, parameters: ClientSubscriptionOptions, func: WithSubscriptionFuncP ): Promise { 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 ", (err).message); throw err; } finally { await subscription.terminate(); } }); } /** * transfer session to this client * @param session * @param callback * @return {*} */ public async reactivateSession(session: ClientSession): Promise; 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 AnonymousIdentity; } userIdentityInfo = coerceUserIdentityInfo(userIdentityInfo); assert(typeof callback === "function"); if (null === userIdentityInfo) { return callback(null, { userIdentityToken: null, userTokenSignature: {} }); } let userIdentityToken: UserIdentityToken; let userTokenSignature: SignatureDataOptions = { algorithm: undefined, signature: undefined }; try { switch (userIdentityInfo.type) { case UserTokenType.Anonymous: userIdentityToken = createAnonymousIdentityToken(context); break; case UserTokenType.UserName: { const userName = userIdentityInfo.userName || ""; const password = userIdentityInfo.password || ""; userIdentityToken = createUserNameIdentityToken(context, userName, password); break; } case UserTokenType.Certificate: { const certificate = userIdentityInfo.certificateData; const privateKey = makePrivateKeyFromPem(userIdentityInfo.privateKey); ({ userIdentityToken, userTokenSignature } = createX509IdentityToken(context, certificate, privateKey)); break; } default: debugLog(" userIdentityInfo = ", userIdentityInfo); return callback(new Error("CLIENT: Invalid userIdentityInfo")); } } catch (err) { if (typeof err === "string") { return callback(new Error(`Create identity token failed ${userIdentityInfo.type} ${err}`)); } return callback(err as Error); } return callback(null, { userIdentityToken, userTokenSignature }); } } // tslint:disable:no-var-requires // tslint:disable:max-line-length import { withCallback } from "thenify-ex"; /** * * @example * // create a anonymous session * const session = await client.createSession(); * * @example * // create a session with a userName and password * const userIdentityInfo = { * type: UserTokenType.UserName, * userName: "JoeDoe", * password:"secret" * }; * const session = client.createSession(userIdentityInfo); * */ OPCUAClientImpl.prototype.createSession = withCallback(OPCUAClientImpl.prototype.createSession); OPCUAClientImpl.prototype.createSession2 = withCallback(OPCUAClientImpl.prototype.createSession2); /** */ OPCUAClientImpl.prototype.changeSessionIdentity = withCallback(OPCUAClientImpl.prototype.changeSessionIdentity); /** * @example * const session = await client.createSession(); * await client.closeSession(session); */ OPCUAClientImpl.prototype.closeSession = withCallback(OPCUAClientImpl.prototype.closeSession); OPCUAClientImpl.prototype.reactivateSession = withCallback(OPCUAClientImpl.prototype.reactivateSession);