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); } }