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 /// StorageService.ValidateClientCredential (the op that kept the 2020 token framing). /// 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}).{DescribeError(error)}"); } 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; } /// /// Renders a diagnostic suffix for a rejected credential round: the decoded native error /// (type/code/name) plus a short hex + printable-ASCII preview of the server error buffer. /// Keeps secrets out — error buffers carry server status codes/messages, not credentials. /// private static string DescribeError(byte[] error) { if (error.Length == 0) { return string.Empty; } HistorianNativeError? native = HistorianOpen2Protocol.TryReadNativeError(error); string nativePart = native is null ? string.Empty : $" native(type={native.Type}, code={native.Code}{(native.Name is null ? string.Empty : $", {native.Name}")})"; ReadOnlySpan preview = error.AsSpan(0, Math.Min(error.Length, 64)); string hex = Convert.ToHexString(preview); char[] ascii = new char[preview.Length]; for (int i = 0; i < preview.Length; i++) { ascii[i] = preview[i] is >= 0x20 and < 0x7F ? (char)preview[i] : '.'; } return $"{nativePart} hex={hex} ascii=\"{new string(ascii)}\""; } }