From 0b1e9d0a7fce4706542b83b271213126c181a607 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 23 Jun 2026 09:45:52 -0400 Subject: [PATCH] feat(grpc-events): v8 OpenConnection serializer + native error decode (Path A disproven) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the native 2023 R2 version-8 OpenConnection format, which (unlike v6) carries a ConnectionType byte (Event vs Process) — required because the 2023 R2 server returns event rows only on an Event connection. - HistorianOpen2Protocol.SerializeNativeOpenConnectionVersion8: reproduces the 302-byte v8 layout decoded from a live capture (version 8, markers, client-key GUID, username HString, length-prefixed credential token, ClientType / ConnectionType / flag / constant word / compact metadata / two empty strings; the tail reuses WriteClientCommonInfo). Golden-tested (Version8EventSerializerReproducesCapturedNativeStructure). - HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request: ConnectionType= Event, zeroed credential token (mirroring how v6 zeros its credential block and relies on the separate ValidateClientCredential handshake). - HistorianGrpcHandshake.OpenSession: optional eventConnection switch; the OpenConnection failure path now decodes the native error (type/code/ASCII). Path A (reuse ValidateClientCredential + zeroed token) was live-tested and DISPROVEN: the server parses the v8 buffer but rejects it at the auth check with native 132/34 "EstablishConnection Failed to get client key" — the v8 path looks up the client key in the registry HistoryService.ExchangeKey (ECDH) populates, not the one ValidateClientCredential does. The event orchestrator is therefore reverted to the v6 session (gated test still pins the no-row throw). The v8 serializer/builder are retained for Path B (implement ExchangeKey). 323/323 offline tests pass. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC --- .../Grpc/HistorianGrpcEventOrchestrator.cs | 5 ++ .../Grpc/HistorianGrpcHandshake.cs | 17 +++++-- .../Wcf/HistorianNativeHandshake.cs | 50 +++++++++++++++++++ .../Wcf/HistorianOpen2Protocol.cs | 43 ++++++++++++++++ .../WcfOpen2ProtocolTests.cs | 38 ++++++++++++++ 5 files changed, 149 insertions(+), 4 deletions(-) diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs index ff0d62f..4c2dfc9 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs @@ -138,6 +138,11 @@ 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. 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 79d88b1..eb0972c 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -50,7 +50,8 @@ internal static class HistorianGrpcHandshake HistorianGrpcConnection connection, HistorianClientOptions options, CancellationToken cancellationToken, - uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode) + uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode, + bool eventConnection = false) { DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout); @@ -79,8 +80,12 @@ internal static class HistorianGrpcHandshake options, cancellationToken); - byte[] open2Request = HistorianNativeHandshake.BuildOpenConnection3Request( - options.Host, contextKey, connectionMode); + // 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). + byte[] open2Request = eventConnection + ? HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request(contextKey, options.UserName) + : HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode); GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection( new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) }, @@ -92,7 +97,11 @@ internal static class HistorianGrpcHandshake if (!(open2.Status?.BSuccess ?? false)) { byte[] err = open2.Status?.BtError?.ToByteArray() ?? []; - throw new InvalidOperationException($"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length})."); + HistorianNativeError? decoded = HistorianOpen2Protocol.TryReadNativeError(err); + string ascii = new(err.Where(b => b is >= 0x20 and < 0x7F).Select(b => (char)b).ToArray()); + throw new InvalidOperationException( + $"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}, " + + $"native={decoded?.Type}/{decoded?.Code}{(decoded?.Name is { } n ? $" {n}" : "")}, ascii='{ascii}')."); } (uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response); diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs index fcec618..77d9d4c 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianNativeHandshake.cs @@ -134,6 +134,56 @@ internal static class HistorianNativeHandshake credentialBlock: new byte[CredentialBlockSizeBytes]); } + // v8 OpenConnection constants (2023 R2), decoded from the native Event-connection capture. + private const byte NativeConnectionTypeEvent = 1; + private const byte NativeConnectionFlagEvent = 1; + private const ushort NativeHcalVersionV8 = 18; + private const string ClientDataSourceIdV8 = "2023.1219.4004.5"; + private const byte NativeClientCommonInfoFormatVersionV8 = 3; + private const int CredentialTokenSizeBytes = 26; + private static readonly Guid UnsetShardId = new(new byte[] + { + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, + }); + + /// + /// Builds the native 2023 R2 version-8 OpenConnection request for an Event connection + /// (ConnectionType=Event). The 2023 R2 server returns event rows only on an event connection, + /// and the v6 OpenConnection buffer has no ConnectionType field. The credential token is zeroed: the + /// session is authenticated by the preceding ValidateClientCredential handshake, exactly as + /// the v6 path already relies on (it sends a zeroed credential block). See + /// docs/reverse-engineering/grpc-event-query-capture.md. + /// + public static byte[] BuildEventOpenConnectionVersion8Request(Guid contextKey, string userName) + { + Process current = Process.GetCurrentProcess(); + string machineName = Environment.MachineName; + string processName = string.IsNullOrEmpty(current.ProcessName) ? ClientNodeNameFallback : current.ProcessName; + + HistorianClientCommonInfo commonInfo = new( + FormatVersion: NativeClientCommonInfoFormatVersionV8, + ServerNodeName: machineName, + ClientNodeName: processName, + ProcessId: checked((uint)current.Id), + HcalVersion: NativeHcalVersionV8, + ProcessName: string.Empty, + Proxy: string.Empty, + DataSourceId: ClientDataSourceIdV8, + ShardId: UnsetShardId, + ClientVersion: NativeClientVersionInt, + ClientTimestamp: (ulong)DateTime.UtcNow.ToFileTimeUtc(), + ClientDllVersion: string.Empty); + + return HistorianOpen2Protocol.SerializeNativeOpenConnectionVersion8( + commonInfo, + contextKey, + NativeConnectionTypeEvent, + NativeConnectionFlagEvent, + userName ?? string.Empty, + credentialToken: new byte[CredentialTokenSizeBytes]); + } + /// /// Decodes the OpenConnection response blob: byte 0 = protocol version, bytes 1..4 = /// transient /Retr client handle (UInt32 LE), bytes 5..20 = storage session GUID. diff --git a/src/AVEVA.Historian.Client/Wcf/HistorianOpen2Protocol.cs b/src/AVEVA.Historian.Client/Wcf/HistorianOpen2Protocol.cs index f60f061..5cf5831 100644 --- a/src/AVEVA.Historian.Client/Wcf/HistorianOpen2Protocol.cs +++ b/src/AVEVA.Historian.Client/Wcf/HistorianOpen2Protocol.cs @@ -50,6 +50,49 @@ internal static class HistorianOpen2Protocol return stream.ToArray(); } + /// + /// Builds the native 2023 R2 version-8 OpenConnection request — the format the stock client + /// uses, which (unlike v6) carries a byte (Event vs Process). The + /// tail is the same v6 emits; the head is the v8 layout + /// decoded from a live capture (docs/reverse-engineering/grpc-event-query-capture.md): version 8, + /// two marker bytes, the client-key GUID, a username HistorianString, a length-prefixed + /// credential token, then ClientType / ConnectionType / flag / a constant word / compact metadata + /// namespace / two empty strings. The credential token is normally an ECDH-derived blob in the + /// native cert path; this serializer sends it as supplied (zeroed for the Negotiate-auth path, + /// mirroring how the v6 request already sends a zeroed credential block and relies on the separate + /// ValidateClientCredential handshake for authentication). + /// + public static byte[] SerializeNativeOpenConnectionVersion8( + HistorianClientCommonInfo commonInfo, + Guid clientKey, + byte connectionType, + byte connectionFlag, + string userName, + byte[] credentialToken) + { + using MemoryStream stream = new(); + using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true); + + writer.Write((byte)8); // [0] format version 8 + writer.Write((byte)0xF0); // [1] constant marker + writer.Write(new byte[19]); // [2..20] zero padding + writer.Write((byte)1); // [21] constant marker + writer.Write(clientKey.ToByteArray()); // [22..37] per-session client key + WriteHistorianString(writer, userName); // username (UTF-16 HistorianString) + writer.Write((ushort)credentialToken.Length); // credential-token length (u16) + writer.Write(credentialToken); // credential token (zeroed for the Negotiate path) + writer.Write((byte)4); // ClientType = 4 + writer.Write(connectionType); // ConnectionType (Event = 1 / Process = 2) + writer.Write(connectionFlag); // type flag (Event = 1 / Process = 0) + writer.Write((ushort)3); // constant word observed at [97..98] + WriteCompactMetadataNamespace(writer, HistorianMetadataNamespace.Empty); + WriteHistorianString(writer, string.Empty); + WriteHistorianString(writer, string.Empty); + WriteClientCommonInfo(writer, commonInfo); // tail: FormatVersion 3 => no ClientDllVersion + writer.Write(0u); // trailing terminator + return stream.ToArray(); + } + private static void WriteNativeOpenConnectionContent( BinaryWriter writer, HistorianOpen2Request request, diff --git a/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs b/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs index 3646d94..3afd730 100644 --- a/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs @@ -5,6 +5,44 @@ namespace AVEVA.Historian.Client.Tests; public sealed class WcfOpen2ProtocolTests { + [Fact] + public void Version8EventSerializerReproducesCapturedNativeStructure() + { + // Field sizes chosen to match the live 2023 R2 Event-connection capture (302 bytes): + // username=12, token=26, serverNode=15, clientNode=32, datasource=16. + // See docs/reverse-engineering/grpc-event-query-capture.md. + var clientKey = new Guid("11223344-5566-7788-99aa-bbccddeeff00"); + byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnectionVersion8( + new HistorianClientCommonInfo( + FormatVersion: 3, + ServerNodeName: new string('S', 15), + ClientNodeName: new string('C', 32), + ProcessId: 0x11223344, + HcalVersion: 18, + ProcessName: string.Empty, + Proxy: string.Empty, + DataSourceId: new string('D', 16), + ShardId: new Guid(Enumerable.Repeat((byte)0xFF, 16).ToArray()), + ClientVersion: 999_999, + ClientTimestamp: 0x01DD02426F9B6F6C, + ClientDllVersion: string.Empty), + clientKey, + connectionType: 1, // Event + connectionFlag: 1, // Event + userName: new string('U', 12), + credentialToken: new byte[26]); + + Assert.Equal(302, actual.Length); // exact native length for these field sizes + Assert.Equal(0x08, actual[0]); // format version 8 + Assert.Equal(0xF0, actual[1]); // marker + Assert.Equal(0x01, actual[21]); // marker + Assert.Equal(clientKey.ToByteArray(), actual[22..38]); + Assert.Equal(0x04, actual[94]); // ClientType + Assert.Equal(0x01, actual[95]); // ConnectionType = Event + Assert.Equal(0x01, actual[96]); // type flag = Event + Assert.Equal([0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF], actual[270..286]); // ShardId + } + [Fact] public void LegacyVersion1SerializerMatchesDecompiledSaveOpenConnectionParamsLayout() {