fix(sphistorianclient): gRPC auth handshake uses StorageService.ValidateClientCredential

The RemoteGrpc orchestrator drove the SSPI/NTLM token loop through
HistoryService.ExchangeKey, which the 2023 R2 contract analysis shows is a
separate key-exchange/cert op — not the credential handshake. The server
rejected the NTLM Type-1 token at round 0. The Negotiate loop belongs on
StorageService.ValidateClientCredential (Handle/InBuff -> Status/OutBuff;
field names match the 2020 native contract). Live-verified end-to-end against
a 2023 R2 Historian (wonder-sql-vd03): SysTimeSec raw read returns correct
timestamped values.
This commit is contained in:
Joseph Doherty
2026-06-19 06:56:44 -04:00
parent 5f7d7e1b58
commit a0527f9b5a
@@ -5,6 +5,7 @@ using ZB.MOM.WW.SPHistorianClient.Models;
using ZB.MOM.WW.SPHistorianClient.Wcf;
using GrpcHistory = ArchestrA.Grpc.Contract.History;
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
namespace ZB.MOM.WW.SPHistorianClient.Grpc;
@@ -16,17 +17,19 @@ namespace ZB.MOM.WW.SPHistorianClient.Grpc;
///
/// Operation mapping (2020 WCF → 2023 R2 gRPC):
/// Hist.GetInterfaceVersion → HistoryService.GetInterfaceVersion
/// Hist.ValidateClientCredential (loop) → HistoryService.ExchangeKey (loop)
/// Hist.ValidateClientCredential (loop) → StorageService.ValidateClientCredential (loop)
/// Hist.OpenConnection2 → HistoryService.OpenConnection
/// Retr.StartQuery2 → RetrievalService.StartQuery
/// Retr.GetNextQueryResultBuffer2 (loop) → RetrievalService.GetNextQueryResultBuffer (loop)
/// Retr.EndQuery2 → RetrievalService.EndQuery
///
/// NOTE: not yet live-verified against a 2023 R2 server. The auth handshake uses
/// HistoryService.ExchangeKey because the gRPC HistoryService dropped ValidateClientCredential
/// (it now lives only on StorageService) and gained ExchangeKey with the identical
/// handle+token→token shape. If a live server rejects this, the handshake op is the first thing
/// to revisit — everything else is the proven 2020 byte protocol.
/// AUTH: the SSPI/Negotiate token loop runs through StorageService.ValidateClientCredential
/// (Handle + InBuff → Status + OutBuff) — per the 2023 R2 contract analysis, that op carries the
/// NTLM/SSPI tokens (the field names inBuff/outBuff match the 2020 native contract), whereas
/// HistoryService.ExchangeKey is a separate key-exchange/cert op (NOT the credential handshake).
/// OpenConnection and the retrieval chain stay on their original services; the server correlates
/// the validated context by the handshake GUID handle. Live-verified 2026-06-19 against a 2023 R2
/// server (wonder-sql-vd03) — earlier ExchangeKey wiring was rejected at token round 0.
/// </summary>
internal sealed class HistorianGrpcReadOrchestrator
{
@@ -162,18 +165,19 @@ internal sealed class HistorianGrpcReadOrchestrator
{
Guid contextKey = Guid.NewGuid();
var historyClient = new GrpcHistory.HistoryService.HistoryServiceClient(connection.Channel);
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
historyClient.GetInterfaceVersion(new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
HistorianNativeHandshake.RunTokenRounds(
(handle, wrapped, _) =>
{
GrpcHistory.ExchangeKeyResponse response = historyClient.ExchangeKey(
new GrpcHistory.ExchangeKeyRequest { StrHandle = handle, BtInput = ByteString.CopyFrom(wrapped) },
GrpcStorage.ValidateClientCredentialResponse response = storageClient.ValidateClientCredential(
new GrpcStorage.ValidateClientCredentialRequest { Handle = handle, InBuff = ByteString.CopyFrom(wrapped) },
connection.Metadata,
Deadline(),
cancellationToken);
byte[] serverOutput = response.BtOutput?.ToByteArray() ?? [];
byte[] serverOutput = response.OutBuff?.ToByteArray() ?? [];
byte[] error = response.Status?.BtError?.ToByteArray() ?? [];
bool success = response.Status?.BSuccess ?? false;
return new HistorianNativeHandshake.TokenExchangeResult(success, serverOutput, error);