c4b8d0dde4
Roadmap docs/plans/hcal-roadmap.md, milestone M0 (gRPC parity for the DONE surface). Now unblocked for live verification by a reachable 2023 R2 server. - R0.4 Probe over gRPC: new HistorianGrpcProbe calls History/Retrieval/Status GetInterfaceVersion (unauthenticated). ProbeAsync routes over gRPC when Transport==RemoteGrpc. LIVE-VERIFIED against a real 2023 R2 server — needs no credentials (runs before the auth loop), so it works despite the auth blocker. - R0.3 System parameter over gRPC: new HistorianGrpcStatusClient calls StatusService.GetSystemParameter over the authenticated session; routed in the dialect. Built + unit-tested (request/response field mapping pinned). Live-verification pending an auth fix (see below). - Extracted the proven auth handshake from HistorianGrpcReadOrchestrator into shared Grpc/HistorianGrpcHandshake (reused by read + status + future browse/metadata). Repointed the IL structural guardrail test to it. - Diagnostics: round-failure now decodes the native server error + hex/ASCII preview (HistorianNativeHandshake.DescribeError). This surfaced the live auth blocker as SEC_E_LOGON_DENIED (0x8009030C) at NTLM round 1 — framing is correct, the credential did not validate. Probable cause: stale file password or NAM-domain NTLM restriction (Kerberos/RDP works, NTLM denied; no SPN path over the tunnel). 216 unit tests pass; live gRPC probe passes. Sanitization scan clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
195 lines
8.5 KiB
C#
195 lines
8.5 KiB
C#
using System.Buffers.Binary;
|
|
using System.Diagnostics;
|
|
|
|
namespace AVEVA.Historian.Client.Wcf;
|
|
|
|
/// <summary>
|
|
/// 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
|
|
/// (<see cref="HistorianWcfAuthChainHelper"/>) and the 2023 R2 gRPC path
|
|
/// (<c>Grpc.HistorianGrpcReadOrchestrator</c>). The byte payloads are identical across
|
|
/// transports — only the envelope (WCF operation vs gRPC method) differs.
|
|
/// </summary>
|
|
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;
|
|
|
|
/// <summary>Result of one transport-level credential-token exchange.</summary>
|
|
internal readonly record struct TokenExchangeResult(bool Success, byte[] ServerOutput, byte[] Error);
|
|
|
|
/// <summary>
|
|
/// Performs a single credential-token round on the wire. <paramref name="handle"/> is the
|
|
/// upper-case context-key GUID, <paramref name="wrappedToken"/> is the AVEVA-wrapped SSPI
|
|
/// token (round byte + length + token). The WCF path maps this to
|
|
/// <c>Hist.ValidateClientCredential</c>; the gRPC path maps it to
|
|
/// <c>StorageService.ValidateClientCredential</c> (the op that kept the 2020 token framing).
|
|
/// </summary>
|
|
internal delegate TokenExchangeResult TokenExchange(string handle, byte[] wrappedToken, int round);
|
|
|
|
/// <summary>
|
|
/// Drives the SSPI/NTLM negotiate loop against the supplied <paramref name="exchange"/>
|
|
/// delegate until the server signals terminal success. Mirrors the native two-round
|
|
/// (69→239, 93→1) sequence.
|
|
/// </summary>
|
|
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.");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the native OpenConnection3 (Open2) version-6 request buffer. Identical bytes are
|
|
/// sent over WCF (<c>Hist.OpenConnection2</c>) and gRPC
|
|
/// (<c>HistoryService.OpenConnection.btConnectionRequest</c>).
|
|
/// </summary>
|
|
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]);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Decodes the OpenConnection response blob: byte 0 = protocol version, bytes 1..4 =
|
|
/// transient /Retr client handle (UInt32 LE), bytes 5..20 = storage session GUID.
|
|
/// </summary>
|
|
public static (uint ClientHandle, Guid StorageSessionId) ParseOpenConnectionResponse(ReadOnlySpan<byte> 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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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<byte> 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)}\"";
|
|
}
|
|
}
|