using System.Buffers.Binary; using System.Diagnostics; namespace AVEVA.Historian.Client.Wcf; /// /// Transport-agnostic pieces of the native Historian connect handshake: building the /// OpenConnection3 v6 request buffer, running the SSPI/NTLM token-exchange rounds, and /// decoding the OpenConnection response. Shared by the WCF/MDAS path /// () and the 2023 R2 gRPC path /// (Grpc.HistorianGrpcReadOrchestrator). The byte payloads are identical across /// transports — only the envelope (WCF operation vs gRPC method) differs. /// internal static class HistorianNativeHandshake { private const int CredentialBlockSizeBytes = 1026; private const int OpenConnectionMinResponseLength = 5; private const int MaxTokenRounds = 8; private const string ClientNodeNameFallback = "AVEVA.Historian.Client"; private const string ClientDataSourceId = "2020.406.2652.2"; private const string ClientDllVersionString = "2020.406.2652.2"; private const byte NativeClientType = 4; private const byte NativeClientCommonInfoFormatVersion = 4; private const ushort NativeHcalVersion = 17; private const uint NativeClientVersionInt = 999_999; private const ushort NativeOpen2ClientVersion = 9; /// Result of one transport-level credential-token exchange. internal readonly record struct TokenExchangeResult(bool Success, byte[] ServerOutput, byte[] Error); /// /// Performs a single credential-token round on the wire. is the /// upper-case context-key GUID, is the AVEVA-wrapped SSPI /// token (round byte + length + token). The WCF path maps this to /// Hist.ValidateClientCredential; the gRPC path maps it to /// HistoryService.ExchangeKey (the renamed handshake op). /// internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round); /// /// Drives the SSPI/NTLM negotiate loop against the supplied /// delegate until the server signals terminal success. Mirrors the native two-round /// (69→239, 93→1) sequence. /// public static void RunTokenRounds( TokenExchange exchange, Guid contextKey, HistorianClientOptions options, CancellationToken cancellationToken) { using HistorianSspiClient sspi = options.IntegratedSecurity ? new HistorianSspiClient(options.TargetSpn) : new HistorianSspiClient(options.TargetSpn, ParseDomain(options.UserName), ParseUserName(options.UserName), options.Password); string handle = contextKey.ToString("D").ToUpperInvariant(); byte[] incoming = []; for (int round = 0; round < MaxTokenRounds; round++) { cancellationToken.ThrowIfCancellationRequested(); HistorianSspiStepResult step = sspi.Next(incoming); byte[] outgoing = step.Token; HistorianWcfAuthenticationProtocol.TryApplyNativeNtlmNegotiateVersionFlag(outgoing); byte[] wrapped = HistorianWcfAuthenticationProtocol.WrapValidateClientCredentialToken(round == 0, outgoing); TokenExchangeResult result = exchange(handle, wrapped, round); byte[] serverOutput = result.ServerOutput ?? []; byte[] error = result.Error ?? []; if (!result.Success) { throw new InvalidOperationException($"Credential token round {round} rejected (errorLen={error.Length})."); } ValidateClientCredentialResponse? response = HistorianWcfAuthenticationProtocol.TryReadValidateClientCredentialResponse(serverOutput); if (response is null || !response.Continue) { return; } incoming = response.Token; if (step.IsCompleted && incoming.Length == 0) { return; } } throw new InvalidOperationException($"Credential token exchange exceeded {MaxTokenRounds} rounds without terminal success."); } /// /// Builds the native OpenConnection3 (Open2) version-6 request buffer. Identical bytes are /// sent over WCF (Hist.OpenConnection2) and gRPC /// (HistoryService.OpenConnection.btConnectionRequest). /// public static byte[] BuildOpenConnection3Request(string host, Guid contextKey, uint connectionMode) { Process current = Process.GetCurrentProcess(); string machineName = Environment.MachineName; string processName = string.IsNullOrEmpty(current.ProcessName) ? ClientNodeNameFallback : current.ProcessName; _ = host; // host reserved for remote-orchestrator extension HistorianOpen2Request open2 = new( HostName: machineName, ProcessName: string.Empty, ProcessId: checked((uint)current.Id), UserName: string.Empty, Password: [], ClientType: NativeClientType, ClientVersion: NativeOpen2ClientVersion, ConnectionMode: connectionMode, MetadataNamespace: HistorianMetadataNamespace.Empty); HistorianClientCommonInfo commonInfo = new( FormatVersion: NativeClientCommonInfoFormatVersion, ServerNodeName: machineName, ClientNodeName: processName, ProcessId: checked((uint)current.Id), HcalVersion: NativeHcalVersion, ProcessName: string.Empty, Proxy: string.Empty, DataSourceId: ClientDataSourceId, ShardId: Guid.Empty, ClientVersion: NativeClientVersionInt, ClientTimestamp: (ulong)DateTime.UtcNow.ToFileTimeUtc(), ClientDllVersion: ClientDllVersionString); return HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6( open2, commonInfo, contextKey, credentialBlock: new byte[CredentialBlockSizeBytes]); } /// /// Decodes the OpenConnection response blob: byte 0 = protocol version, bytes 1..4 = /// transient /Retr client handle (UInt32 LE), bytes 5..20 = storage session GUID. /// public static (uint ClientHandle, Guid StorageSessionId) ParseOpenConnectionResponse(ReadOnlySpan response) { if (response.Length < OpenConnectionMinResponseLength) { throw new InvalidOperationException($"OpenConnection response too short (ResponseLen={response.Length})."); } uint clientHandle = BinaryPrimitives.ReadUInt32LittleEndian(response.Slice(1, 4)); Guid storageSessionId = response.Length >= 21 ? new Guid(response.Slice(5, 16)) : Guid.Empty; return (clientHandle, storageSessionId); } private static string ParseDomain(string userName) { if (string.IsNullOrEmpty(userName)) return string.Empty; int slash = userName.IndexOf('\\'); return slash > 0 ? userName[..slash] : string.Empty; } private static string ParseUserName(string userName) { if (string.IsNullOrEmpty(userName)) return string.Empty; int slash = userName.IndexOf('\\'); return slash > 0 ? userName[(slash + 1)..] : userName; } }