0921e21bdb
Extends the instrument-grpc rewrite to log string (strHandle) + uint (uiHandle / queryRequestType) params, not just byte[], and captures our SDK's live v8 openParameters for a byte-diff against the native. Result of the exhaustive comparison (all live-confirmed via the opt-in EventReadDiagnostic test): - StartEventQuery request: byte-identical to the native (v6 layout) - v8 OpenConnection openParameters: byte-identical to the native (302B) once ClientNodeName matches — every control byte/ConnectionType/token/ShardId - handle usage identical: ExchangeKey->contextKey, registration->storage GUID (strHandle), query->client uint (uiHandle); handles valid (RTag/EnsT=True) - queryRequestType=3, registration order, gzip metadata header — all match - window has events (native returns 50 now); eventCount not it Every observable client-side byte matches the native, yet the server scopes 0 events to our connection. The event RPCs succeed over our transport and return a valid EMPTY result (not a transport error), so this is a connection/server-level difference (session affinity tied to the native Grpc.Core HTTP/2 connection or a connection identity used to scope events) — invisible to and unfixable by client payload matching. Needs server-side insight, not more wire RE. Added opt-in diagnostics (RegistrationDiag, LastResultBufferHex, LastEventOpenRequestHex). 326/326 offline; gated test still pins the no-row throw. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
150 lines
8.7 KiB
C#
150 lines
8.7 KiB
C#
using System.Security.Cryptography;
|
|
using Google.Protobuf;
|
|
using Grpc.Core;
|
|
using AVEVA.Historian.Client.Wcf;
|
|
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
|
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
|
|
|
namespace AVEVA.Historian.Client.Grpc;
|
|
|
|
/// <summary>
|
|
/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an
|
|
/// existing <see cref="HistorianGrpcConnection"/> and returns the transient client handle used by
|
|
/// the Retrieval/Status services. Extracted from <see cref="HistorianGrpcReadOrchestrator"/> so the
|
|
/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain:
|
|
/// <c>HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) →
|
|
/// HistoryService.OpenConnection</c>. The byte payloads (OpenConnection3 v6 request, NTLM token
|
|
/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf <c>bytes</c> fields.
|
|
///
|
|
/// See <see cref="HistorianGrpcReadOrchestrator"/> for the op-routing rationale (the Negotiate loop
|
|
/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey).
|
|
/// </summary>
|
|
internal static class HistorianGrpcHandshake
|
|
{
|
|
/// <summary>Diagnostic: hex of the most recent v8 event-connection OpenConnection request.</summary>
|
|
internal static string LastEventOpenRequestHex { get; private set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// The handles produced by a successful OpenConnection. <see cref="ClientHandle"/> is the
|
|
/// transient <c>uint</c> session token used by StartQuery/GetSystemParameter and the other
|
|
/// uint-handle ops. <see cref="StorageSessionId"/> is the storage-session GUID used (formatted
|
|
/// uppercase via <see cref="StringHandle"/>) by the string-handle ops
|
|
/// (GetTagInfosFromName, GetTagExtendedPropertiesFromName, ExecuteSqlCommand, ...).
|
|
/// </summary>
|
|
internal readonly record struct Session(uint ClientHandle, Guid StorageSessionId)
|
|
{
|
|
/// <summary>The storage GUID in the uppercase "D" form the native string-handle ops require.</summary>
|
|
public string StringHandle => StorageSessionId.ToString("D").ToUpperInvariant();
|
|
}
|
|
|
|
/// <summary>Convenience overload for callers that only need the uint client handle.</summary>
|
|
public static uint OpenAuthenticatedConnection(
|
|
HistorianGrpcConnection connection,
|
|
HistorianClientOptions options,
|
|
CancellationToken cancellationToken)
|
|
=> OpenSession(connection, options, cancellationToken).ClientHandle;
|
|
|
|
/// <param name="connectionMode">
|
|
/// The native Open2 connection mode. Defaults to read-only (<c>0x402</c>); pass
|
|
/// <see cref="HistorianWcfAuthChainHelper.NativeIntegratedWriteEnabledConnectionMode"/>
|
|
/// (<c>0x401</c>) for write-enabled sessions (e.g. the non-streamed/revision Transaction path,
|
|
/// which the read-only mode silently rejects with err 132 OperationNotEnabled).
|
|
/// </param>
|
|
public static Session OpenSession(
|
|
HistorianGrpcConnection connection,
|
|
HistorianClientOptions options,
|
|
CancellationToken cancellationToken,
|
|
uint connectionMode = HistorianWcfAuthChainHelper.NativeIntegratedReadOnlyConnectionMode,
|
|
bool eventConnection = false)
|
|
{
|
|
DateTime Deadline() => DateTime.UtcNow.Add(options.RequestTimeout);
|
|
|
|
Guid contextKey = Guid.NewGuid();
|
|
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
|
|
|
|
GrpcHistory.GetInterfaceVersionResponse historyVersion = historyClient.GetInterfaceVersion(
|
|
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
|
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, options);
|
|
|
|
// The v6 (read/write) path authenticates via StorageService.ValidateClientCredential (Negotiate).
|
|
// The v8 EVENT path authenticates entirely via ExchangeKey (ECDH) + the RC4 credential token —
|
|
// the native client does NOT run ValidateClientCredential for an event connection, and doing so
|
|
// establishes a different session scope under which the event query returns zero rows. So skip it.
|
|
if (!eventConnection)
|
|
{
|
|
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
|
HistorianNativeHandshake.RunTokenRounds(
|
|
(handle, wrapped, _) =>
|
|
{
|
|
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
|
|
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
|
|
connection.Metadata,
|
|
Deadline(),
|
|
cancellationToken);
|
|
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
|
|
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
|
|
bool success = response.Status?.BSuccess ?? false;
|
|
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);
|
|
},
|
|
contextKey,
|
|
options,
|
|
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. The v8 path authenticates
|
|
// via HistoryService.ExchangeKey (P-256 ECDH): the shared secret -> SHA256 = the client key, and
|
|
// 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)
|
|
{
|
|
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}').");
|
|
}
|
|
|
|
byte[] clientKey = HistorianNativeHandshake.DeriveExchangeKeyClientKey(ecdh, xk.BtOutput?.ToByteArray() ?? []);
|
|
eventToken = HistorianNativeHandshake.BuildExchangeKeyCredentialToken(clientKey, options.Password);
|
|
}
|
|
|
|
byte[] open2Request = eventConnection
|
|
? HistorianNativeHandshake.BuildEventOpenConnectionVersion8Request(contextKey, options.UserName, eventToken)
|
|
: HistorianNativeHandshake.BuildOpenConnection3Request(options.Host, contextKey, connectionMode);
|
|
if (eventConnection) { LastEventOpenRequestHex = Convert.ToHexString(open2Request); }
|
|
|
|
GrpcHistory.OpenConnectionResponse open2 = historyClient.OpenConnection(
|
|
new GrpcHistory.OpenConnectionRequest { BtConnectionRequest = ByteString.CopyFrom(open2Request) },
|
|
connection.Metadata,
|
|
Deadline(),
|
|
cancellationToken);
|
|
|
|
byte[] open2Response = open2.BtConnectionResponse?.ToByteArray() ?? [];
|
|
if (!(open2.Status?.BSuccess ?? false))
|
|
{
|
|
byte[] err = open2.Status?.BtError?.ToByteArray() ?? [];
|
|
HistorianNativeError? decoded = HistorianOpen2Protocol.TryReadNativeError(err);
|
|
string ascii = new(err.Where(b => b is >= 0x20 and < 0x7F).Select(b => (char)b).ToArray());
|
|
throw new InvalidOperationException(
|
|
$"gRPC OpenConnection failed (errorLen={err.Length}, responseLen={open2Response.Length}, " +
|
|
$"native={decoded?.Type}/{decoded?.Code}{(decoded?.Name is { } n ? $" {n}" : "")}, ascii='{ascii}').");
|
|
}
|
|
|
|
(uint clientHandle, Guid storageSessionId) = HistorianNativeHandshake.ParseOpenConnectionResponse(open2Response);
|
|
return new Session(clientHandle, storageSessionId);
|
|
}
|
|
}
|