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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user