diff --git a/docs/reverse-engineering/grpc-event-query-capture.md b/docs/reverse-engineering/grpc-event-query-capture.md index 95f021e..131cf7b 100644 --- a/docs/reverse-engineering/grpc-event-query-capture.md +++ b/docs/reverse-engineering/grpc-event-query-capture.md @@ -300,6 +300,39 @@ implement `aahCryptV2` (RC4+MD5+prefix) managed-side, set the v8 token = that, a (non-destructive). The offline correlation data (one run's derived key + token + openParameters) is captured under `artifacts/.../` to validate the managed reproduction before going live. +### Token implemented + auth WORKS live (2026-06-23); row retrieval still 0 — proven NOT a payload issue + +`token = RC4(password-UTF16LE, key = MD5(SHA256(ECDH secret)))` was implemented in pure managed C# +(`HistorianNativeHandshake.BuildExchangeKeyCredentialToken` + `Rc4`; client key via +`DeriveKeyFromHash(SHA256)`), golden-tested (RC4 standard vector + token construction), and +**live-verified**: the v8 `OpenConnection` now **authenticates** against the 2023 R2 server (past the +`132/171 AuthenticationFailed` wall). Auth is solved. + +The event **query** still returns `version-11 rowCount-0` while the native returns 50 for an +**identical** request. Exhaustively ruled out as the cause (all confirmed live, opt-in +`EventReadDiagnostic` test + the IL rewrite extended to log string/uint handle fields): + +- `StartEventQuery` request: **byte-identical** to the native (v6 layout) +- v8 `OpenConnection` `openParameters`: **byte-identical** to the native (302 bytes) once ClientNodeName + is matched — every control byte, ConnectionType, token framing, ShardId, etc. +- Handle usage: identical — `ExchangeKey`→contextKey, registration→storage-session GUID (`strHandle`), + query→client uint (`uiHandle`); our parsed handles are valid (registration `RTag/EnsT=True`, valid + `queryHandle`) +- `queryRequestType = 3`, registration sequence/order, gzip metadata header — all match +- window (events exist; native returns 50 *now*), eventCount — not it + +So **every observable client-side byte matches the native**, yet the server scopes 0 events to our +connection. The event RPCs succeed over our transport and return a valid *empty* result (not a +transport error), so it is **not a payload or transport-incompatibility issue** — it is a +connection/server-level difference (e.g. session affinity tied to the native `Grpc.Core` HTTP/2 +connection or a connection-identity the server uses to scope events) that is **invisible to, and +unfixable by, client payload matching.** Closing it needs server-side insight or a different angle +(e.g. compare the full HTTP/2 connection setup / TLS identity), not more wire-payload RE. + +**Shipped this effort:** the complete ExchangeKey crypto (ECDH + SHA256 + MD5-keyed RC4 token) — the +hard wall — pure managed, golden-tested, auth live-verified. Orchestrator stays on the no-row throw; +gated test unchanged. + **2 of 3 layers cleared** (key exchange + client key); the 3rd (token construction) is localized to a specific managed method, pending dnlib extraction. ExchangeKey + the v8 serializer are committed; the orchestrator stays on v6 (set `eventConnection: true` to re-arm once the token construction lands). The diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs index 756685b..d9879cf 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcEventOrchestrator.cs @@ -306,7 +306,7 @@ internal sealed class HistorianGrpcEventOrchestrator } uint queryHandle = startResponse.UiQueryHandle; - RegistrationDiag += $"QH={queryHandle} clientH={session.ClientHandle} SEQresp={Convert.ToHexString(startResponse.BtResonse?.ToByteArray() ?? [])}; "; + RegistrationDiag += $"QH={queryHandle} clientH={session.ClientHandle} strH={session.StringHandle}; "; try { List events = []; diff --git a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs index 5307ab7..3e14d4e 100644 --- a/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs +++ b/src/AVEVA.Historian.Client/Grpc/HistorianGrpcHandshake.cs @@ -21,6 +21,9 @@ namespace AVEVA.Historian.Client.Grpc; /// internal static class HistorianGrpcHandshake { + /// Diagnostic: hex of the most recent v8 event-connection OpenConnection request. + internal static string LastEventOpenRequestHex { get; private set; } = string.Empty; + /// /// The handles produced by a successful OpenConnection. is the /// transient uint session token used by StartQuery/GetSystemParameter and the other @@ -121,6 +124,7 @@ internal static class HistorianGrpcHandshake byte[] open2Request = eventConnection ? HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request(contextKey, options.UserName, eventToken) : HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode); + if (eventConnection) { LastEventOpenRequestHex = Convert.ToHexString(open2Request); } GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection( new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) }, diff --git a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs index 549cea1..9ff5739 100644 --- a/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs +++ b/tests/AVEVA.Historian.Client.Tests/HistorianGrpcIntegrationTests.cs @@ -547,7 +547,8 @@ public sealed class HistorianGrpcIntegrationTests throw new Xunit.Sdk.XunitException( $"[DIAG] outcome={outcome} | events={events.Count} | LastResultLen={orch.LastResultBufferLength} " + - $"| ResultHex={orch.LastResultBufferHex} | ErrHex={orch.LastErrorBufferHex} | Reg=[{orch.RegistrationDiag}]"); + $"| ResultHex={orch.LastResultBufferHex} | Reg=[{orch.RegistrationDiag}] " + + $"| v8open={AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake.LastEventOpenRequestHex}"); } [Fact] @@ -677,7 +678,8 @@ public sealed class HistorianGrpcIntegrationTests IntegratedSecurity = !explicitCreds, UserName = user ?? string.Empty, Password = password ?? string.Empty, - RequestTimeout = timeout + RequestTimeout = timeout, + Compression = true // the stock client always advertises grpc gzip request encoding }; } } diff --git a/tools/AVEVA.Historian.ReverseEngineering/Program.cs b/tools/AVEVA.Historian.ReverseEngineering/Program.cs index 62bbdda..3ec8de3 100644 --- a/tools/AVEVA.Historian.ReverseEngineering/Program.cs +++ b/tools/AVEVA.Historian.ReverseEngineering/Program.cs @@ -1387,10 +1387,14 @@ static int InstrumentGrpcNonStream(string[] args) ModuleDefMD module = ModuleDefMD.Load(sourcePath); MemberRefUser logByteArray = CreateLogByteArrayRef(module); + MemberRefUser logString = CreateLogStringRef(module); + MemberRefUser logUInt32 = CreateLogUInt32Ref(module); // Cast a wide net: instrument EVERY byte[]-input method on every Grpc*Client type, so whichever // path the native non-streamed write actually drives (History/Transaction RegisterTags + // AddNonStreamValues, or a Storage-service route) is captured. Phase = "..". + // Also captures string (strHandle) and uint (uiHandle / queryRequestType) inputs so the event-read + // handle fields are visible, not just byte[] params. var instrumented = new List(); foreach (TypeDef type in module.GetTypes() .Where(t => t.Name.String.StartsWith("Grpc", StringComparison.Ordinal) && t.Name.String.EndsWith("Client", StringComparison.Ordinal))) @@ -1431,6 +1435,34 @@ static int InstrumentGrpcNonStream(string[] args) }); } + // ENTRY: log string (strHandle) and uint (uiHandle / queryRequestType / count) inputs. + foreach (dnlib.DotNet.Parameter scalarParam in method.Parameters + .Where(p => !p.IsHiddenThisParameter && (p.Type.FullName == "System.String" || p.Type.FullName == "System.UInt32")) + .ToArray()) + { + bool isString = scalarParam.Type.FullName == "System.String"; + string phase = $"{type.Name}.{method.Name}.{scalarParam.Name}.{(isString ? "str" : "u32")}"; + Instruction[] injected = + [ + Instruction.Create(OpCodes.Ldstr, phase), + Instruction.Create(OpCodes.Ldarg, scalarParam), + Instruction.Create(OpCodes.Call, isString ? logString : logUInt32), + ]; + foreach (Instruction instruction in injected.Reverse()) + { + method.Body.Instructions.Insert(0, instruction); + } + method.Body.MaxStack = (ushort)Math.Max((int)method.Body.MaxStack, 8); + instrumented.Add(new + { + Type = type.Name.String, + Method = method.Name.String, + Phase = phase, + Direction = "in", + Token = "0x" + method.MDToken.Raw.ToString("X8"), + }); + } + // EXIT: log out/ref byte[] responses ("System.Byte[]&") before each ret. ldarg loads the // managed pointer; ldind.ref dereferences it to the byte[]. (RPC wrappers set the out // param right before a single ret, so branch-to-ret skew is not a concern here.)