d67f6f5e96
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
161 lines
6.7 KiB
C#
161 lines
6.7 KiB
C#
using System.Reflection;
|
|
using System.Reflection.Emit;
|
|
|
|
namespace AVEVA.Historian.Client.Tests;
|
|
|
|
/// <summary>
|
|
/// Structural guardrail pinning the 2023 R2 gRPC auth-handshake op routing. The SSPI/Negotiate
|
|
/// token loop MUST be carried by <c>StorageService.ValidateClientCredential</c> (the op that kept
|
|
/// the 2020 inBuff/outBuff token framing), NOT by <c>HistoryService.ExchangeKey</c> — ExchangeKey
|
|
/// is a separate key-exchange/cert-path op that rejects an NTLM token at round 0 regardless of
|
|
/// credentials (live-confirmed against a real 2023 R2 server, 2026-06-21). An earlier revision
|
|
/// routed the loop to ExchangeKey; this test fails if that regression returns.
|
|
///
|
|
/// It works by disassembling the IL of <c>HistorianGrpcHandshake</c> (and its
|
|
/// compiler-generated nested closure types — the token-loop call lives inside a lambda) and
|
|
/// collecting every method invoked.
|
|
/// </summary>
|
|
public sealed class HistorianGrpcHandshakeRoutingTests
|
|
{
|
|
[Fact]
|
|
public void Handshake_TokenLoop_UsesValidateClientCredential_NotExchangeKey()
|
|
{
|
|
// The auth token loop is a compiler-generated lambda (nested closure) passed to RunTokenRounds.
|
|
// It MUST carry the SSPI/Negotiate token via StorageService.ValidateClientCredential, never via
|
|
// HistoryService.ExchangeKey (the earlier regression). ExchangeKey is now legitimately called
|
|
// directly in OpenSession for the SEPARATE v8 event-connection key exchange (ECDH) — that is
|
|
// allowed, so the guardrail scopes the no-ExchangeKey rule to the token-loop closures only.
|
|
HashSet<string> tokenLoopCalls = CollectCalledMethodNames(
|
|
"AVEVA.Historian.Client.Grpc.HistorianGrpcHandshake", nestedClosuresOnly: true);
|
|
|
|
Assert.Contains("ValidateClientCredential", tokenLoopCalls);
|
|
Assert.DoesNotContain("ExchangeKey", tokenLoopCalls);
|
|
}
|
|
|
|
private static HashSet<string> CollectCalledMethodNames(string typeFullName, bool nestedClosuresOnly = false)
|
|
{
|
|
Assembly sdk = typeof(HistorianClientOptions).Assembly;
|
|
Type orchestrator = sdk.GetType(typeFullName, throwOnError: true)!;
|
|
Module module = orchestrator.Module;
|
|
|
|
// The compiler-generated nested types (lambda closures) — optionally with the orchestrator type
|
|
// itself. The token loop lives inside a closure; ExchangeKey (event key exchange) lives in the
|
|
// OpenSession body, so scoping to closures isolates the token-loop routing.
|
|
IEnumerable<Type> nested = orchestrator.GetNestedTypes(BindingFlags.Public | BindingFlags.NonPublic);
|
|
IEnumerable<Type> types = nestedClosuresOnly ? nested : new[] { orchestrator }.Concat(nested);
|
|
|
|
var names = new HashSet<string>(StringComparer.Ordinal);
|
|
foreach (Type t in types)
|
|
{
|
|
Type[] typeArgs = t.IsGenericType ? t.GetGenericArguments() : Type.EmptyTypes;
|
|
foreach (MethodInfo m in t.GetMethods(
|
|
BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance | BindingFlags.Static | BindingFlags.DeclaredOnly))
|
|
{
|
|
byte[]? il = m.GetMethodBody()?.GetILAsByteArray();
|
|
if (il is null)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
Type[] methodArgs = m.IsGenericMethodDefinition ? m.GetGenericArguments() : Type.EmptyTypes;
|
|
foreach (int token in EnumerateMethodTokens(il))
|
|
{
|
|
try
|
|
{
|
|
MethodBase? resolved = module.ResolveMethod(token, typeArgs, methodArgs);
|
|
if (resolved is not null)
|
|
{
|
|
names.Add(resolved.Name);
|
|
}
|
|
}
|
|
catch (Exception ex) when (ex is ArgumentException or BadImageFormatException)
|
|
{
|
|
// vararg / MethodSpec tokens that don't resolve cleanly — irrelevant here.
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return names;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Walks an IL byte stream, yielding the 4-byte metadata token of every call/callvirt/newobj
|
|
/// (any <see cref="OperandType.InlineMethod"/> opcode). Uses the reflection-emit opcode table so
|
|
/// operands of other instructions are skipped correctly rather than misread as opcodes.
|
|
/// </summary>
|
|
private static IEnumerable<int> EnumerateMethodTokens(byte[] il)
|
|
{
|
|
int pos = 0;
|
|
while (pos < il.Length)
|
|
{
|
|
OpCode op;
|
|
if (il[pos] == 0xFE && pos + 1 < il.Length)
|
|
{
|
|
op = TwoByteOpCodes[il[pos + 1]];
|
|
pos += 2;
|
|
}
|
|
else
|
|
{
|
|
op = OneByteOpCodes[il[pos]];
|
|
pos += 1;
|
|
}
|
|
|
|
switch (op.OperandType)
|
|
{
|
|
case OperandType.InlineMethod:
|
|
yield return BitConverter.ToInt32(il, pos);
|
|
pos += 4;
|
|
break;
|
|
case OperandType.InlineNone:
|
|
break;
|
|
case OperandType.ShortInlineBrTarget:
|
|
case OperandType.ShortInlineI:
|
|
case OperandType.ShortInlineVar:
|
|
pos += 1;
|
|
break;
|
|
case OperandType.InlineVar:
|
|
pos += 2;
|
|
break;
|
|
case OperandType.InlineI8:
|
|
case OperandType.InlineR:
|
|
pos += 8;
|
|
break;
|
|
case OperandType.InlineSwitch:
|
|
int count = BitConverter.ToInt32(il, pos);
|
|
pos += 4 + (4 * count);
|
|
break;
|
|
default:
|
|
// InlineBrTarget, InlineField, InlineI, InlineSig, InlineString, InlineTok,
|
|
// InlineType, ShortInlineR — all 4-byte operands.
|
|
pos += 4;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private static readonly OpCode[] OneByteOpCodes = BuildOpCodeTable(twoByte: false);
|
|
private static readonly OpCode[] TwoByteOpCodes = BuildOpCodeTable(twoByte: true);
|
|
|
|
private static OpCode[] BuildOpCodeTable(bool twoByte)
|
|
{
|
|
var table = new OpCode[256];
|
|
foreach (FieldInfo f in typeof(OpCodes).GetFields(BindingFlags.Public | BindingFlags.Static))
|
|
{
|
|
if (f.GetValue(null) is not OpCode op)
|
|
{
|
|
continue;
|
|
}
|
|
|
|
ushort value = unchecked((ushort)op.Value);
|
|
bool isTwoByte = (value & 0xFF00) == 0xFE00;
|
|
if (isTwoByte == twoByte)
|
|
{
|
|
table[value & 0xFF] = op;
|
|
}
|
|
}
|
|
|
|
return table;
|
|
}
|
|
}
|