Files
histsdk/tests/AVEVA.Historian.Client.Tests/WcfOpen2ProtocolTests.cs
T
Joseph Doherty 6d0f5c4b8f 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
2026-06-23 11:46:00 -04:00

348 lines
13 KiB
C#

using System.Text;
using AVEVA.Historian.Client.Wcf;
namespace AVEVA.Historian.Client.Tests;
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]
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()
{
byte[] actual = HistorianOpen2Protocol.SerializeLegacyVersion1(new HistorianOpen2Request(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: "U",
Password: Encoding.Unicode.GetBytes("pw"),
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 2,
MetadataNamespace: HistorianMetadataNamespace.Empty));
byte[] expected =
[
0x01, 0x00,
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x01, 0x00, 0x00, 0x00, 0x50, 0x00,
0x04, 0x03, 0x02, 0x01,
0x01, 0x00, 0x00, 0x00, 0x55, 0x00,
0x04, 0x00, 0x00, 0x00, 0x70, 0x00, 0x77, 0x00,
0x04,
0x0B, 0x00,
0x02, 0x00, 0x00, 0x00,
0x01,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00
];
Assert.Equal(expected, actual);
}
[Fact]
public void LegacyVersion1SerializerUsesUtf16CodeUnitStringLengths()
{
byte[] actual = HistorianOpen2Protocol.SerializeLegacyVersion1(new HistorianOpen2Request(
HostName: "A\ud83d\ude00",
ProcessName: string.Empty,
ProcessId: 0,
UserName: string.Empty,
Password: [],
ClientType: 4,
ClientVersion: 0,
ConnectionMode: 2,
MetadataNamespace: HistorianMetadataNamespace.Empty));
Assert.Equal([0x03, 0x00, 0x00, 0x00], actual[2..6]);
Assert.Equal(Encoding.Unicode.GetBytes("A\ud83d\ude00"), actual[6..12]);
}
[Fact]
public void NativeErrorParserReadsObservedFiveByteBuffers()
{
HistorianNativeError? error = HistorianOpen2Protocol.TryReadNativeError([0x04, 0xAB, 0x00, 0x00, 0x00]);
Assert.NotNull(error);
Assert.Equal(4, error.Type);
Assert.Equal<uint>(171, error.Code);
Assert.Equal("AuthenticationFailed", error.Name);
}
[Fact]
public void NativeErrorParserRejectsShortBuffers()
{
Assert.Null(HistorianOpen2Protocol.TryReadNativeError([0x04, 0xAB, 0x00, 0x00]));
}
[Fact]
public void LegacyOpen2OutputParserReadsObservedWcfLayout()
{
byte[] buffer =
[
0x78, 0x56, 0x34, 0x12,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x08, 0x07, 0x06, 0x05,
0x04, 0x03, 0x02, 0x01,
0x44, 0x33, 0x22, 0x11
];
HistorianLegacyOpen2Output? output = HistorianOpen2Protocol.TryReadLegacyOpen2Output(buffer);
Assert.NotNull(output);
Assert.Equal<uint>(0x12345678, output.Handle);
Assert.Equal(new Guid("00112233-4455-6677-8899-aabbccddeeff"), output.StorageSessionId);
Assert.Equal(0x0102030405060708, output.ConnectTimeFileTimeUtc);
Assert.Equal<uint>(0x11223344, output.ServerStatus);
}
[Fact]
public void LegacyOpen2OutputParserRejectsNonLegacyLength()
{
Assert.Null(HistorianOpen2Protocol.TryReadLegacyOpen2Output([0x00]));
}
[Fact]
public void NativeOpen3OutputParserReadsObservedDeserializerLayout()
{
byte[] buffer =
[
0x03,
0x78, 0x56, 0x34, 0x12,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x08, 0x07, 0x06, 0x05,
0x04, 0x03, 0x02, 0x01,
0x18, 0x17, 0x16, 0x15,
0x14, 0x13, 0x12, 0x11,
0x44, 0x33, 0x22, 0x11,
0x00
];
HistorianNativeOpen3Output? output = HistorianOpen2Protocol.TryReadNativeOpen3Output(buffer);
Assert.NotNull(output);
Assert.Equal(3, output.ProtocolVersion);
Assert.Equal<uint>(0x12345678, output.Handle);
Assert.Equal(new Guid("00112233-4455-6677-8899-aabbccddeeff"), output.StorageSessionId);
Assert.Equal(0x0102030405060708, output.ConnectTimeFileTimeUtc);
Assert.Equal(0x1112131415161718, output.ServerTimeFileTimeUtc);
Assert.Equal([0x44, 0x33, 0x22, 0x11, 0x00], output.TrailingBytes);
}
[Fact]
public void NativeOpen3OutputParserRejectsUnsupportedVersion()
{
Assert.Null(HistorianOpen2Protocol.TryReadNativeOpen3Output([0x01, 0x00, 0x00, 0x00]));
}
[Fact]
public void NativeVersion3SerializerMatchesDecompiledFieldOrder()
{
byte[] actual = HistorianOpen2Protocol.SerializeNativeVersion3(
new HistorianOpen2Request(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty),
new HistorianClientCommonInfo(
FormatVersion: 3,
ServerNodeName: "S",
ClientNodeName: "C",
ProcessId: 0x11223344,
HcalVersion: 17,
ProcessName: "Proc",
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: new Guid("00112233-4455-6677-8899-aabbccddeeff"),
ClientVersion: 0x55667788,
ClientTimestamp: 0x0102030405060708,
ClientDllVersion: string.Empty));
byte[] expectedPrefix =
[
0x03,
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x02, 0x00, 0xAA, 0xBB,
0x04,
0x02, 0x04, 0x00, 0x00
];
Assert.Equal(expectedPrefix, actual[..expectedPrefix.Length]);
Assert.Contains<byte>(0x03, actual);
byte[] expectedSuffix = [0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01];
Assert.Equal(expectedSuffix, actual[^expectedSuffix.Length..]);
}
[Fact]
public void NativeOpenConnection3Version6SerializerAddsObservedPrefixBeforeContent()
{
HistorianOpen2Request request = new(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty);
HistorianClientCommonInfo commonInfo = new(
FormatVersion: 3,
ServerNodeName: "S",
ClientNodeName: "C",
ProcessId: 0x11223344,
HcalVersion: 17,
ProcessName: "Proc",
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: new Guid("00112233-4455-6677-8899-aabbccddeeff"),
ClientVersion: 0x55667788,
ClientTimestamp: 0x0102030405060708,
ClientDllVersion: string.Empty);
byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
request,
commonInfo,
new Guid("00112233-4455-6677-8899-aabbccddeeff"));
byte[] expectedPrefix =
[
0x06,
0x33, 0x22, 0x11, 0x00,
0x55, 0x44,
0x77, 0x66,
0x88, 0x99, 0xAA, 0xBB,
0xCC, 0xDD, 0xEE, 0xFF,
0x00
];
Assert.Equal(expectedPrefix, actual[..expectedPrefix.Length]);
byte[] expectedContentPrefix =
[
0x01, 0x00, 0x00, 0x00, 0x48, 0x00,
0x02, 0x00, 0xAA, 0xBB,
0x04,
0x02, 0x04, 0x00, 0x00,
0x01,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00,
0x01, 0x00, 0x00
];
Assert.Equal(expectedContentPrefix, actual[expectedPrefix.Length..(expectedPrefix.Length + expectedContentPrefix.Length)]);
}
[Fact]
public void NativeOpenConnection3Version6SerializerCanUseSeparateCredentialBlock()
{
HistorianOpen2Request request = new(
HostName: "H",
ProcessName: "P",
ProcessId: 0x01020304,
UserName: string.Empty,
Password: [0xAA, 0xBB],
ClientType: 4,
ClientVersion: 11,
ConnectionMode: 1026,
MetadataNamespace: HistorianMetadataNamespace.Empty);
HistorianClientCommonInfo commonInfo = new(
FormatVersion: 2,
ServerNodeName: string.Empty,
ClientNodeName: string.Empty,
ProcessId: 0,
HcalVersion: 17,
ProcessName: string.Empty,
Proxy: string.Empty,
DataSourceId: string.Empty,
ShardId: Guid.Empty,
ClientVersion: 0,
ClientTimestamp: 0,
ClientDllVersion: string.Empty);
byte[] actual = HistorianOpen2Protocol.SerializeNativeOpenConnection3Version6(
request,
commonInfo,
Guid.Empty,
[0x00, 0x00, 0x00, 0x00]);
int hostLengthOffset = 18;
int credentialLengthOffset = hostLengthOffset + 4 + Encoding.Unicode.GetByteCount("H");
Assert.Equal([0x04, 0x00], actual[credentialLengthOffset..(credentialLengthOffset + 2)]);
Assert.Equal([0x00, 0x00, 0x00, 0x00], actual[(credentialLengthOffset + 2)..(credentialLengthOffset + 6)]);
}
}