diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs index 4c2dfc9..4d3ca7b 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs @@ -138,11 +138,12 @@ internal sealed class HistorianGrpcEventOrchestrator private List RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken) { using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options); - // NOTE: the event read needs an Event-type (v8) connection, but the v8 OpenConnection is - // server-coupled to HistoryService.ExchangeKey (ECDH) — it rejects a v8 open made on our - // ValidateClientCredential session with native 132/34 "EstablishConnection Failed to get client - // key" (Path A, disproven 2026-06-23). Staying on the v6 session until ExchangeKey is - // implemented (Path B). The v8 serializer + BuildEventOpenConnectionVersion8Request are ready. + // Event reads need an Event-type (v8) connection. Path B established the v8 ExchangeKey (ECDH) + // client key — which cleared the "Failed to get client key" check — but the v8 OpenConnection + // then fails at native 132/171 AuthenticationFailed: the 26-byte credential token must be derived + // from the ECDH shared secret (the token-KDF is not yet reverse-engineered). Staying on the v6 + // session until that derivation lands; the ExchangeKey + v8 serializer are ready (set + // eventConnection: true to re-arm). See docs/reverse-engineering/grpc-event-query-capture.md. HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken); RegisterCmEventTag(connection, session, cancellationToken); diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs index eb0972c..4d5539d 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -1,3 +1,4 @@ +using System.Security.Cryptography; using Google.Protobuf; using Grpc.Core; using AVEVA.Historian.Client.Wcf; @@ -81,8 +82,31 @@ internal static class HistorianGrpcHandshake cancellationToken); // Event reads require an Event-type connection (ConnectionType=Event), which only the native - // v8 OpenConnection format carries — the v6 buffer has no such field. Reuse the same - // ValidateClientCredential session; the v8 credential token is zeroed (auth already done). + // v8 OpenConnection format carries — the v6 buffer has no such field. The v8 OpenConnection also + // looks up its client key in the registry HistoryService.ExchangeKey (ECDH) populates (not the + // one ValidateClientCredential does), so establish that key first via a P-256 key exchange. + if (eventConnection) + { + using ECDiffieHellman ecdh = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256); + byte[] clientHello = HistorianNativeHandshake.BuildExchangeKeyClientHello(ecdh); + string xkHandle = contextKey.ToString("D").ToUpperInvariant(); + GrpcHistory.ExchangeKeyResponse xk = historyClient.ExchangeKey( + new GrpcHistory.ExchangeKeyRequest { StrHandle = xkHandle, BtInput = ByteString.CopyFrom(clientHello) }, + connection.Metadata, + Deadline(), + cancellationToken); + if (!(xk.Status?.BSuccess ?? false)) + { + byte[] xkErr = xk.Status?.BtError?.ToByteArray() ?? []; + HistorianNativeError? xkDecoded = HistorianOpen2Protocol.TryReadNativeError(xkErr); + string xkAscii = new(xkErr.Where(b => b is >= 0x20 and < 0x7F).Select(b => (char)b).ToArray()); + throw new InvalidOperationException( + $"gRPC ExchangeKey failed (errorLen={xkErr.Length}, native={xkDecoded?.Type}/{xkDecoded?.Code}, ascii='{xkAscii}')."); + } + // The raw ECDH shared secret (if the v8 credential token later needs derivation) is: + // HistorianNativeHandshake.DeriveExchangeKeySecret(ecdh, xk.BtOutput?.ToByteArray() ?? []); + } + byte[] open2Request = eventConnection ? HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request(contextKey, options.UserName) : HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode); diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs index 77d9d4c..a2c2e93 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs @@ -1,5 +1,6 @@ using System.Buffers.Binary; using System.Diagnostics; +using System.Security.Cryptography; namespace AVEVA.Historian.Client.Wcf; @@ -28,6 +29,68 @@ internal static class HistorianNativeHandshake /// Result of one transport-level credential-token exchange. internal readonly record struct TokenExchangeResult(bool Success, byte[] ServerOutput, byte[] Error); + // HistoryService.ExchangeKey wire format (decoded from a live 2023 R2 capture): the ASCII magic + // "ECK1", a uint32 coordinate length (32 = P-256), then the raw EC public-key point X || Y. Used by + // the v8 (event) connection path to establish the client key the v8 OpenConnection requires. + private static readonly byte[] ExchangeKeyMagic = "ECK1"u8.ToArray(); + private const int ExchangeKeyCoordinateBytes = 32; // P-256 / secp256r1 + + /// + /// Builds the ExchangeKey client hello: "ECK1" + u32(32) + X(32) + Y(32) for the + /// supplied P-256 key. Pure managed (); no native AVEVA dependency. + /// + public static byte[] BuildExchangeKeyClientHello(ECDiffieHellman ecdh) + { + ECParameters parameters = ecdh.ExportParameters(includePrivateParameters: false); + byte[] x = LeftPadCoordinate(parameters.Q.X!); + byte[] y = LeftPadCoordinate(parameters.Q.Y!); + + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream); + writer.Write(ExchangeKeyMagic); + writer.Write((uint)ExchangeKeyCoordinateBytes); + writer.Write(x); + writer.Write(y); + return stream.ToArray(); + } + + /// + /// Parses the server's ExchangeKey hello (same "ECK1" + u32 + X + Y shape) and + /// derives the raw ECDH shared secret against . + /// + public static byte[] DeriveExchangeKeySecret(ECDiffieHellman ecdh, byte[] serverHello) + { + const int headerLength = 8; // 4-byte magic + 4-byte coordinate length + int needed = headerLength + (2 * ExchangeKeyCoordinateBytes); + if (serverHello.Length < needed) + { + throw new InvalidOperationException($"ExchangeKey server hello too short (len={serverHello.Length}, need>={needed})."); + } + + byte[] x = serverHello[headerLength..(headerLength + ExchangeKeyCoordinateBytes)]; + byte[] y = serverHello[(headerLength + ExchangeKeyCoordinateBytes)..(headerLength + (2 * ExchangeKeyCoordinateBytes))]; + + ECParameters serverParameters = new() + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint { X = x, Y = y }, + }; + using ECDiffieHellman serverKey = ECDiffieHellman.Create(serverParameters); + return ecdh.DeriveRawSecretAgreement(serverKey.PublicKey); + } + + private static byte[] LeftPadCoordinate(byte[] coordinate) + { + if (coordinate.Length == ExchangeKeyCoordinateBytes) + { + return coordinate; + } + + byte[] padded = new byte[ExchangeKeyCoordinateBytes]; + Array.Copy(coordinate, 0, padded, ExchangeKeyCoordinateBytes - coordinate.Length, coordinate.Length); + return padded; + } + /// /// Performs a single credential-token round on the wire. is the /// upper-case context-key GUID, is the AVEVA-wrapped SSPI diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs index fee9f3f..4cea64a 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcHandshakeRoutingTests.cs @@ -18,26 +18,31 @@ namespace AVEVA.Historian.Client.Tests; public sealed class HistorianGrpcHandshakeRoutingTests { [Fact] - public void Handshake_UsesValidateClientCredential_NotExchangeKey() + public void Handshake_TokenLoop_UsesValidateClientCredential_NotExchangeKey() { - // The auth token loop lives in the shared handshake helper (reused by the read, status, - // and future browse/metadata gRPC paths). - HashSet calledMethods = CollectCalledMethodNames( - "AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake"); + // The auth token loop is a compiler-generated lambda (nested closure) passed to RunTokenRounds. + // It MUST carry the SSPI/Negotiate token via StorageService.ValidateClientCredential, never via + // HistoryService.ExchangeKey (the earlier regression). ExchangeKey is now legitimately called + // directly in OpenSession for the SEPARATE v8 event-connection key exchange (ECDH) — that is + // allowed, so the guardrail scopes the no-ExchangeKey rule to the token-loop closures only. + HashSet tokenLoopCalls = CollectCalledMethodNames( + "AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake", nestedClosuresOnly: true); - Assert.Contains("ValidateClientCredential", calledMethods); - Assert.DoesNotContain("ExchangeKey", calledMethods); + Assert.Contains("ValidateClientCredential", tokenLoopCalls); + Assert.DoesNotContain("ExchangeKey", tokenLoopCalls); } - private static HashSet CollectCalledMethodNames(string typeFullName) + private static HashSet CollectCalledMethodNames(string typeFullName, bool nestedClosuresOnly = false) { Assembly sdk = typeof(HistorianClientOptions).Assembly; Type orchestrator = sdk.GetType(typeFullName, throwOnError: true)!; Module module = orchestrator.Module; - // The orchestrator type plus its compiler-generated nested types (lambda closures). - IEnumerable types = new[] { orchestrator } - .Concat(orchestrator.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic)); + // The compiler-generated nested types (lambda closures) — optionally with the orchestrator type + // itself. The token loop lives inside a closure; ExchangeKey (event key exchange) lives in the + // OpenSession body, so scoping to closures isolates the token-loop routing. + IEnumerable nested = orchestrator.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic); + IEnumerable types = nestedClosuresOnly ? nested : new[] { orchestrator }.Concat(nested); var names = new HashSet(StringComparer.Ordinal); foreach (Type t in types)