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)}\"";
}
}