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;
///
/// Shared 2023 R2 gRPC authentication handshake. Opens an authenticated History session over an
/// existing and returns the transient client handle used by
/// the Retrieval/Status services. Extracted from so the
/// read, status, and (future) browse/metadata gRPC paths all drive the identical chain:
/// HistoryService.GetInterfaceVersion → StorageService.ValidateClientCredential (token loop) →
/// HistoryService.OpenConnection. The byte payloads (OpenConnection3 v6 request, NTLM token
/// framing) are the proven 2020 protocol and transfer unchanged inside protobuf bytes fields.
///
/// See for the op-routing rationale (the Negotiate loop
/// belongs on StorageService.ValidateClientCredential, NOT HistoryService.ExchangeKey).
///
internal static class HistorianGrpcHandshake
{
/// Diagnostic: hex of the most recent v8 event-connection OpenConnection request.
internal static string LastEventOpenRequestHex { get; private set; } = string.Empty;
///
/// The handles produced by a successful OpenConnection. is the
/// transient uint session token used by StartQuery/GetSystemParameter and the other
/// uint-handle ops. is the storage-session GUID used (formatted
/// uppercase via ) by the string-handle ops
/// (GetTagInfosFromName, GetTagExtendedPropertiesFromName, ExecuteSqlCommand, ...).
///
internal readonly record struct Session(uint ClientHandle, Guid StorageSessionId)
{
/// The storage GUID in the uppercase "D" form the native string-handle ops require.
public string StringHandle => StorageSessionId.ToString("D").ToUpperInvariant();
}
/// Convenience overload for callers that only need the uint client handle.
public static uint OpenAuthenticatedConnection(
HistorianGrpcConnection connection,
HistorianClientOptions options,
CancellationToken cancellationToken)
=> OpenSession(connection, options, cancellationToken).ClientHandle;
///
/// The native Open2 connection mode. Defaults to read-only (0x402); pass
///
/// (0x401) for write-enabled sessions (e.g. the non-streamed/revision Transaction path,
/// which the read-only mode silently rejects with err 132 OperationNotEnabled).
///
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);
}
}