feat(grpc-events): implement aahCryptV2 token — v8 ExchangeKey auth now passes live
Implements the reverse-engineered v8 credential token in pure managed code and
wires the full event-connection auth chain. Live result: the v8 OpenConnection
now AUTHENTICATES against the 2023 R2 server (past the 132/171 AuthenticationFailed
wall) — the crypto is solved.
- HistorianNativeHandshake.DeriveExchangeKeyClientKey: client key = SHA256(ECDH
shared secret) via ECDiffieHellman.DeriveKeyFromHash(SHA256), matching the native
ECDiffieHellmanCng{Hash,SHA256}.DeriveKeyMaterial.
- BuildExchangeKeyCredentialToken + Rc4: token = RC4(password-UTF16LE, key=MD5(clientKey)).
Reproduces a live-captured token EXACTLY (verified offline) — the native
HistorianCrypto.NRC4_V2.aahCryptV2 scheme (MD5-keyed RC4). Pure managed; nothing
AVEVA shipped. RC4 pinned by the standard test vector.
- OpenSession(eventConnection:true): ExchangeKey -> derive client key -> token ->
v8 OpenConnection with ConnectionType=Event + the token. Orchestrator re-armed.
- HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc: the 86-byte native gRPC
CM_EVENT EnsureTags (8-byte header + ...2f27 event-type GUID), replacing the
2020 WCF 83-byte CTagMetadata on the gRPC event registration.
Goldens: RC4 standard vector + token construction. 326/326 offline.
KNOWN REMAINING: the event query still returns zero rows (GetNext yields a 10-byte
zero-row buffer). Auth + StartEventQuery succeed; the query-layer detail (vs the
native row-returning capture) is the last step. Gated test still pins the no-row
throw; opt-in diagnostic (HISTORIAN_GRPC_EVENT_DIAG) surfaces the journey.
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:
@@ -138,13 +138,10 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken)
|
private List<HistorianEvent> RunEventChain(DateTime startUtc, DateTime endUtc, HistorianEventFilter? filter, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
using HistorianGrpcConnection connection = HistorianGrpcChannelFactory.Create(_options);
|
||||||
// Event reads need an Event-type (v8) connection. Path B established the v8 ExchangeKey (ECDH)
|
// Event reads need an Event-type (v8) connection. OpenSession(eventConnection: true) runs the
|
||||||
// client key — which cleared the "Failed to get client key" check — but the v8 OpenConnection
|
// full v8 path: HistoryService.ExchangeKey (P-256 ECDH) -> client key = SHA256(secret) -> v8
|
||||||
// then fails at native 132/171 AuthenticationFailed: the 26-byte credential token must be derived
|
// OpenConnection with ConnectionType=Event and the credential token RC4(password, MD5(clientKey)).
|
||||||
// from the ECDH shared secret (the token-KDF is not yet reverse-engineered). Staying on the v6
|
HistorianGrpcHandshake.Session session = HistorianGrpcHandshake.OpenSession(connection, _options, cancellationToken, eventConnection: true);
|
||||||
// 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);
|
RegisterCmEventTag(connection, session, cancellationToken);
|
||||||
|
|
||||||
@@ -253,7 +250,10 @@ internal sealed class HistorianGrpcEventOrchestrator
|
|||||||
TryRun(() => statusClient.GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken));
|
TryRun(() => statusClient.GetStatusInterfaceVersion(new GrpcStatus.GetStatusInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken));
|
||||||
TryRun(() => retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken));
|
TryRun(() => retrievalClient.GetRetrievalInterfaceVersion(new GrpcRetrieval.GetRetrievalInterfaceVersionRequest(), connection.Metadata, RegistrationDeadline(), cancellationToken));
|
||||||
|
|
||||||
byte[] payload = HistorianAddTagsProtocol.SerializeCmEventCTagMetadata(DateTime.UtcNow);
|
// gRPC CM_EVENT EnsureTags uses the 86-byte native format (8-byte header + the …2f27 event-type
|
||||||
|
// GUID), NOT the 2020 WCF CTagMetadata — required for the server to establish CM_EVENT so the
|
||||||
|
// event query returns rows.
|
||||||
|
byte[] payload = HistorianAddTagsProtocol.SerializeCmEventEnsureTagsGrpc(DateTime.UtcNow);
|
||||||
TryRun(() => historyClient.EnsureTags(
|
TryRun(() => historyClient.EnsureTags(
|
||||||
new GrpcHistory.EnsureTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(payload), ElementCount = 1 },
|
new GrpcHistory.EnsureTagsRequest { StrHandle = session.StringHandle, BtTagInfos = ByteString.CopyFrom(payload), ElementCount = 1 },
|
||||||
connection.Metadata, RegistrationDeadline(), cancellationToken));
|
connection.Metadata, RegistrationDeadline(), cancellationToken));
|
||||||
|
|||||||
@@ -82,9 +82,12 @@ internal static class HistorianGrpcHandshake
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
// Event reads require an Event-type connection (ConnectionType=Event), which only the native
|
// Event reads require an Event-type connection (ConnectionType=Event), which only the native
|
||||||
// v8 OpenConnection format carries — the v6 buffer has no such field. The v8 OpenConnection also
|
// v8 OpenConnection format carries — the v6 buffer has no such field. The v8 path authenticates
|
||||||
// looks up its client key in the registry HistoryService.ExchangeKey (ECDH) populates (not the
|
// via HistoryService.ExchangeKey (P-256 ECDH): the shared secret -> SHA256 = the client key, and
|
||||||
// one ValidateClientCredential does), so establish that key first via a P-256 key exchange.
|
// the v8 credential token = RC4(password-UTF16LE, key=MD5(clientKey)) (the native HistorianCrypto
|
||||||
|
// aahCryptV2 scheme). The server shares the secret and RC4-decrypts the token to validate the
|
||||||
|
// password. See docs/reverse-engineering/grpc-event-query-capture.md.
|
||||||
|
byte[] eventToken = [];
|
||||||
if (eventConnection)
|
if (eventConnection)
|
||||||
{
|
{
|
||||||
using ECDiffieHellman ecdh = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
|
using ECDiffieHellman ecdh = ECDiffieHellman.Create(ECCurve.NamedCurves.nistP256);
|
||||||
@@ -103,12 +106,13 @@ internal static class HistorianGrpcHandshake
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
$"gRPC ExchangeKey failed (errorLen={xkErr.Length}, native={xkDecoded?.Type}/{xkDecoded?.Code}, ascii='{xkAscii}').");
|
$"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[] clientKey = HistorianNativeHandshake.DeriveExchangeKeyClientKey(ecdh, xk.BtOutput?.ToByteArray() ?? []);
|
||||||
|
eventToken = HistorianNativeHandshake.BuildExchangeKeyCredentialToken(clientKey, options.Password);
|
||||||
}
|
}
|
||||||
|
|
||||||
byte[] open2Request = eventConnection
|
byte[] open2Request = eventConnection
|
||||||
? HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request(contextKey, options.UserName)
|
? HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request(contextKey, options.UserName, eventToken)
|
||||||
: HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
: HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
||||||
|
|
||||||
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
||||||
|
|||||||
@@ -44,6 +44,40 @@ internal static class HistorianAddTagsProtocol
|
|||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static readonly Guid CommonArchestraEventTypeId = new("5f59ae42-3bb6-4760-91a5-ab0be01f9f02");
|
public static readonly Guid CommonArchestraEventTypeId = new("5f59ae42-3bb6-4760-91a5-ab0be01f9f02");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The CM_EVENT event-type GUID used by the 2023 R2 <b>gRPC</b> EnsureTags (captured ending
|
||||||
|
/// <c>…e0 1f 2f 27</c>) — distinct from the 2020 WCF capture's <see cref="CommonArchestraEventTypeId"/>
|
||||||
|
/// (<c>…9f02</c>).
|
||||||
|
/// </summary>
|
||||||
|
public static readonly Guid CommonArchestraEventTypeIdGrpc = new("5f59ae42-3bb6-4760-91a5-ab0be01f2f27");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the native 2023 R2 <b>gRPC</b> CM_EVENT <c>EnsureTags.tagInfos</c> buffer (86 bytes,
|
||||||
|
/// captured byte-for-byte). Differs from the 2020 WCF <see cref="SerializeCmEventCTagMetadata"/>:
|
||||||
|
/// it is wrapped in an 8-byte EnsureTags header (<c>4E 67 03 00 01 00 00 00</c>), uses the
|
||||||
|
/// <see cref="CommonArchestraEventTypeIdGrpc"/> event-type GUID, and has no trailing bytes after it.
|
||||||
|
/// Used by the gRPC event registration so the server actually establishes CM_EVENT and the event
|
||||||
|
/// query returns rows.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] SerializeCmEventEnsureTagsGrpc(DateTime createdUtc)
|
||||||
|
{
|
||||||
|
using MemoryStream stream = new();
|
||||||
|
using BinaryWriter writer = new(stream, Encoding.Unicode, leaveOpen: true);
|
||||||
|
|
||||||
|
writer.Write(new byte[] { 0x4E, 0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00 }); // EnsureTags header (count 1)
|
||||||
|
writer.Write((byte)3);
|
||||||
|
writer.Write((ushort)0x0086);
|
||||||
|
writer.Write((byte)5);
|
||||||
|
writer.Write(CmEventTagId.ToByteArray());
|
||||||
|
WriteCompressedHistorianString(writer, "CM_EVENT");
|
||||||
|
WriteCompressedHistorianString(writer, "AnE Event");
|
||||||
|
writer.Write(new byte[] { 0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01 });
|
||||||
|
writer.Write(0u);
|
||||||
|
writer.Write(createdUtc.ToUniversalTime().ToFileTimeUtc());
|
||||||
|
writer.Write(CommonArchestraEventTypeIdGrpc.ToByteArray());
|
||||||
|
return stream.ToArray();
|
||||||
|
}
|
||||||
|
|
||||||
public static byte[] SerializeCmEventCTagMetadata(DateTime createdUtc)
|
public static byte[] SerializeCmEventCTagMetadata(DateTime createdUtc)
|
||||||
{
|
{
|
||||||
using MemoryStream stream = new();
|
using MemoryStream stream = new();
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ internal static class HistorianNativeHandshake
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Parses the server's <c>ExchangeKey</c> hello (same <c>"ECK1" + u32 + X + Y</c> shape) and
|
/// 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"/>.
|
/// derives the 32-byte client key = <b>SHA256(ECDH shared secret)</b>. This matches the native
|
||||||
|
/// client, which uses <c>ECDiffieHellmanCng { KeyDerivationFunction=Hash, HashAlgorithm=SHA256 }
|
||||||
|
/// .DeriveKeyMaterial(...)</c> — i.e. the hash KDF over the raw agreement.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte[] DeriveExchangeKeySecret(ECDiffieHellman ecdh, byte[] serverHello)
|
public static byte[] DeriveExchangeKeyClientKey(ECDiffieHellman ecdh, byte[] serverHello)
|
||||||
{
|
{
|
||||||
const int headerLength = 8; // 4-byte magic + 4-byte coordinate length
|
const int headerLength = 8; // 4-byte magic + 4-byte coordinate length
|
||||||
int needed = headerLength + (2 * ExchangeKeyCoordinateBytes);
|
int needed = headerLength + (2 * ExchangeKeyCoordinateBytes);
|
||||||
@@ -76,7 +78,48 @@ internal static class HistorianNativeHandshake
|
|||||||
Q = new ECPoint { X = x, Y = y },
|
Q = new ECPoint { X = x, Y = y },
|
||||||
};
|
};
|
||||||
using ECDiffieHellman serverKey = ECDiffieHellman.Create(serverParameters);
|
using ECDiffieHellman serverKey = ECDiffieHellman.Create(serverParameters);
|
||||||
return ecdh.DeriveRawSecretAgreement(serverKey.PublicKey);
|
return ecdh.DeriveKeyFromHash(serverKey.PublicKey, HashAlgorithmName.SHA256);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the v8 credential token: <c>RC4(password-UTF16LE, key = MD5(clientKey))</c>, where
|
||||||
|
/// <paramref name="clientKey"/> is the <see cref="DeriveExchangeKeyClientKey"/> result
|
||||||
|
/// (SHA256 of the ECDH secret). Reverse-engineered from the native <c>HistorianCrypto.NRC4_V2
|
||||||
|
/// .aahCryptV2</c> scheme (MD5-keyed RC4) and verified to reproduce a live-captured token exactly.
|
||||||
|
/// The server, sharing the ECDH secret, RC4-decrypts this to recover and validate the password.
|
||||||
|
/// Pure managed; nothing AVEVA is shipped.
|
||||||
|
/// </summary>
|
||||||
|
public static byte[] BuildExchangeKeyCredentialToken(byte[] clientKey, string password)
|
||||||
|
{
|
||||||
|
byte[] rc4Key = MD5.HashData(clientKey);
|
||||||
|
byte[] plaintext = System.Text.Encoding.Unicode.GetBytes(password ?? string.Empty);
|
||||||
|
return Rc4(rc4Key, plaintext);
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static byte[] Rc4(byte[] key, byte[] data)
|
||||||
|
{
|
||||||
|
int[] s = new int[256];
|
||||||
|
for (int i = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
s[i] = i;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (int i = 0, j = 0; i < 256; i++)
|
||||||
|
{
|
||||||
|
j = (j + s[i] + key[i % key.Length]) & 0xFF;
|
||||||
|
(s[i], s[j]) = (s[j], s[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
byte[] output = new byte[data.Length];
|
||||||
|
for (int i = 0, j = 0, n = 0; n < data.Length; n++)
|
||||||
|
{
|
||||||
|
i = (i + 1) & 0xFF;
|
||||||
|
j = (j + s[i]) & 0xFF;
|
||||||
|
(s[i], s[j]) = (s[j], s[i]);
|
||||||
|
output[n] = (byte)(data[n] ^ s[(s[i] + s[j]) & 0xFF]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return output;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static byte[] LeftPadCoordinate(byte[] coordinate)
|
private static byte[] LeftPadCoordinate(byte[] coordinate)
|
||||||
@@ -218,7 +261,7 @@ internal static class HistorianNativeHandshake
|
|||||||
/// the v6 path already relies on (it sends a zeroed credential block). See
|
/// the v6 path already relies on (it sends a zeroed credential block). See
|
||||||
/// docs/reverse-engineering/grpc-event-query-capture.md.
|
/// docs/reverse-engineering/grpc-event-query-capture.md.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static byte[] BuildEventOpenConnectionVersion8Request(Guid contextKey, string userName)
|
public static byte[] BuildEventOpenConnectionVersion8Request(Guid contextKey, string userName, byte[] credentialToken)
|
||||||
{
|
{
|
||||||
Process current = Process.GetCurrentProcess();
|
Process current = Process.GetCurrentProcess();
|
||||||
string machineName = Environment.MachineName;
|
string machineName = Environment.MachineName;
|
||||||
@@ -244,7 +287,7 @@ internal static class HistorianNativeHandshake
|
|||||||
NativeConnectionTypeEvent,
|
NativeConnectionTypeEvent,
|
||||||
NativeConnectionFlagEvent,
|
NativeConnectionFlagEvent,
|
||||||
userName ?? string.Empty,
|
userName ?? string.Empty,
|
||||||
credentialToken: new byte[CredentialTokenSizeBytes]);
|
credentialToken ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -515,6 +515,40 @@ public sealed class HistorianGrpcIntegrationTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EventReadDiagnostic_OverGrpc_PrintsJourney()
|
||||||
|
{
|
||||||
|
string? host = Environment.GetEnvironmentVariable("HISTORIAN_GRPC_HOST");
|
||||||
|
if (string.IsNullOrWhiteSpace(host) || string.IsNullOrEmpty(Environment.GetEnvironmentVariable("HISTORIAN_USER")))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (Environment.GetEnvironmentVariable("HISTORIAN_GRPC_EVENT_DIAG") is null)
|
||||||
|
{
|
||||||
|
return; // opt-in diagnostic only
|
||||||
|
}
|
||||||
|
|
||||||
|
var orch = new AVEVA.Historian.Client.Grpc.HistorianGrpcEventOrchestrator(BuildOptions(host));
|
||||||
|
var events = new List<HistorianEvent>();
|
||||||
|
string outcome;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await foreach (HistorianEvent evt in orch.ReadEventsAsync(DateTime.UtcNow.AddDays(-30), DateTime.UtcNow, null, CancellationToken.None))
|
||||||
|
{
|
||||||
|
events.Add(evt);
|
||||||
|
if (events.Count >= 3) { break; }
|
||||||
|
}
|
||||||
|
outcome = $"OK events={events.Count}";
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
outcome = $"{ex.GetType().Name}: {ex.Message}";
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Xunit.Sdk.XunitException(
|
||||||
|
$"[DIAG] outcome={outcome} | events={events.Count} | LastResultLen={orch.LastResultBufferLength} | LastErr='{orch.LastErrorBufferDescription}'");
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetConnectionStatusAsync_OverGrpc_ReportsConnected()
|
public async Task GetConnectionStatusAsync_OverGrpc_ReportsConnected()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,6 +5,32 @@ namespace AVEVA.Historian.Client.Tests;
|
|||||||
|
|
||||||
public sealed class WcfOpen2ProtocolTests
|
public sealed class WcfOpen2ProtocolTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Rc4MatchesStandardTestVector()
|
||||||
|
{
|
||||||
|
// Standard RC4 test vector (key "Key", plaintext "Plaintext" -> BBF316E8D940AF0AD3). Pins the
|
||||||
|
// RC4 used by the v8 credential token = RC4(password-UTF16LE, key=MD5(SHA256(ECDH secret))).
|
||||||
|
byte[] actual = HistorianNativeHandshake.Rc4(
|
||||||
|
System.Text.Encoding.ASCII.GetBytes("Key"),
|
||||||
|
System.Text.Encoding.ASCII.GetBytes("Plaintext"));
|
||||||
|
|
||||||
|
Assert.Equal(Convert.FromHexString("BBF316E8D940AF0AD3"), actual);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void CredentialToken_IsRc4OfPasswordKeyedByMd5OfClientKey()
|
||||||
|
{
|
||||||
|
// token = RC4(password-UTF16LE, key = MD5(clientKey)). Verify against an independently computed
|
||||||
|
// reference for a fixed clientKey + password.
|
||||||
|
byte[] clientKey = Enumerable.Range(0, 32).Select(i => (byte)i).ToArray();
|
||||||
|
byte[] token = HistorianNativeHandshake.BuildExchangeKeyCredentialToken(clientKey, "Pw1!");
|
||||||
|
|
||||||
|
byte[] rc4Key = System.Security.Cryptography.MD5.HashData(clientKey);
|
||||||
|
byte[] expected = HistorianNativeHandshake.Rc4(rc4Key, System.Text.Encoding.Unicode.GetBytes("Pw1!"));
|
||||||
|
Assert.Equal(expected, token);
|
||||||
|
Assert.Equal("Pw1!".Length * 2, token.Length); // UTF-16LE length
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Version8EventSerializerReproducesCapturedNativeStructure()
|
public void Version8EventSerializerReproducesCapturedNativeStructure()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user