feat(grpc-events): ExchangeKey ECDH (Path B) — clears the v8 client-key check

Implements HistoryService.ExchangeKey as a pure-managed P-256 ECDH key exchange
and wires it ahead of the v8 Event OpenConnection.

- HistorianNativeHandshake.BuildExchangeKeyClientHello / DeriveExchangeKeySecret:
  .NET ECDiffieHellman (nistP256); wire format "ECK1" + u32(32) + X(32) + Y(32),
  decoded from the live capture. No native AVEVA dependency.
- HistorianGrpcHandshake.OpenSession(eventConnection: true): runs ExchangeKey on
  the context-key handle before the v8 OpenConnection.
- Guardrail HistorianGrpcHandshakeRoutingTests scoped to the token-loop closure:
  still pins that the Negotiate token loop routes to ValidateClientCredential (not
  ExchangeKey), while allowing the legitimate ExchangeKey call in OpenSession.

Live result: ExchangeKey succeeds (server accepts our public key) and the v8
OpenConnection error advances from 132/34 "Failed to get client key" to 132/171
AuthenticationFailed — the ECDH cleared the client-key layer. The remaining
blocker is the 26-byte v8 credential token, which must be derived from the ECDH
shared secret (token KDF, not yet recovered). Orchestrator stays on v6 (set
eventConnection: true to re-arm once the KDF lands). 323/323 offline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
Joseph Doherty
2026-06-23 10:31:37 -04:00
parent 7284fdc976
commit d67f6f5e96
4 changed files with 111 additions and 18 deletions
@@ -138,11 +138,12 @@ internal sealed class HistorianGrpcEventOrchestrator
private List<HistorianEvent> 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);
@@ -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);
@@ -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
/// <summary>Result of one transport-level credential-token exchange.</summary>
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
/// <summary>
/// Builds the <c>ExchangeKey</c> client hello: <c>"ECK1" + u32(32) + X(32) + Y(32)</c> for the
/// supplied P-256 key. Pure managed (<see cref="ECDiffieHellman"/>); no native AVEVA dependency.
/// </summary>
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();
}
/// <summary>
/// Parses the server's <c>ExchangeKey</c> hello (same <c>"ECK1" + u32 + X + Y</c> shape) and
/// derives the raw ECDH shared secret against <paramref name="ecdh"/>.
/// </summary>
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;
}
/// <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