gRPC 2023 R2: fix auth handshake op routing + accept History v12
First live-verified gRPC read against a real 2023 R2 Historian. The handshake previously failed at round 0 (cred-independent) because the SSPI/Negotiate token loop was routed to HistoryService.ExchangeKey. ExchangeKey is a separate key-exchange/cert-path op, not the Negotiate loop — the token loop belongs on StorageService.ValidateClientCredential, which kept the 2020 inBuff/outBuff token framing the SDK's WrapValidateClientCredentialToken/TryRead helpers already build. Captured + diffed against the recovered 2023 R2 protobuf contract and the decompiled stock client; routing the loop to ValidateClientCredential completes the full chain (ValidateClientCredential x N -> OpenConnection -> StartQuery -> GetNextQueryResultBuffer) and returns rows. - HistorianGrpcReadOrchestrator: token loop now calls StorageService.ValidateClientCredential(Handle, InBuff); corrected the op-map doc comment (was asserting the wrong ExchangeKey mapping). - HistorianServerVersionGate: accept History interface version 12 alongside 11. Live server reports History=12, Retrieval=4, Storage=4; the buffers are byte-identical (a live read returns rows), so 12 is buffer-compatible. Retrieval stays pinned at 4 (matches). New AcceptedVersions() supports multi-version gates. - New HistorianGrpcHandshakeRoutingTests: IL-level structural guardrail that disassembles the orchestrator (incl. lambda closures) and asserts the handshake invokes ValidateClientCredential and never ExchangeKey — fails if the regression returns. - Updated gate tests + CLAUDE.md gRPC op-map. 240 unit tests pass on the full stack; 210 on this branch's base. The byte payloads remain the proven 2020 protocol. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B6mcaT2PjRFKcogzp9UkfC
This commit is contained in:
@@ -5,6 +5,7 @@ using AVEVA.Historian.Client.Models;
|
||||
using AVEVA.Historian.Client.Wcf;
|
||||
using GrpcHistory = ArchestrA.Grpc.Contract.History;
|
||||
using GrpcRetrieval = ArchestrA.Grpc.Contract.Retrieval;
|
||||
using GrpcStorage = ArchestrA.Grpc.Contract.Storage;
|
||||
|
||||
namespace AVEVA.Historian.Client.Grpc;
|
||||
|
||||
@@ -16,17 +17,23 @@ namespace AVEVA.Historian.Client.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.
|
||||
/// LIVE-VERIFIED 2026-06-21 against a real 2023 R2 server (interface versions: History=12,
|
||||
/// Retrieval=4, Storage=4). The SSPI/Negotiate token loop maps to
|
||||
/// <c>StorageService.ValidateClientCredential(Handle, InBuff)→(status, OutBuff)</c> — the op that
|
||||
/// kept the 2020 inBuff/outBuff token framing. The gRPC HistoryService dropped
|
||||
/// ValidateClientCredential and gained <c>ExchangeKey</c>, but ExchangeKey is a SEPARATE
|
||||
/// key-exchange/cert-path op, NOT the Negotiate loop: feeding it an NTLM token is rejected at
|
||||
/// round 0 regardless of credentials. An earlier revision wrongly routed the loop to ExchangeKey;
|
||||
/// routing it to StorageService.ValidateClientCredential completes the full read chain. The byte
|
||||
/// payloads (OpenConnection3 v6, token framing, DataQueryRequest, row buffers) are the proven 2020
|
||||
/// protocol and transfer unchanged — only the History interface integer differs (12 vs the 2020
|
||||
/// value 11), and that version is buffer-compatible (a live read returns rows).
|
||||
/// </summary>
|
||||
internal sealed class HistorianGrpcReadOrchestrator
|
||||
{
|
||||
@@ -167,15 +174,16 @@ internal sealed class HistorianGrpcReadOrchestrator
|
||||
new GrpcHistory.GetInterfaceVersionRequest(), connection.Metadata, Deadline(), cancellationToken);
|
||||
HistorianServerVersionGate.Validate(HistorianServiceInterface.History, historyVersion.UiVersion, _options);
|
||||
|
||||
var storageClient = new GrpcStorage.StorageService.StorageServiceClient(connection.Channel);
|
||||
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);
|
||||
|
||||
@@ -43,6 +43,15 @@ internal static class HistorianServerVersionGate
|
||||
public const uint RetrievalInterfaceVersion = 4;
|
||||
public const uint TransactionInterfaceVersion = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The 2023 R2 gRPC HistoryService reports interface version 12. It is buffer-compatible with
|
||||
/// the 2020 version 11 — the OpenConnection3 v6 / token / DataQueryRequest / row buffers are
|
||||
/// byte-identical — confirmed by a live end-to-end gRPC read against a real 2023 R2 server
|
||||
/// (2026-06-21). So both 11 and 12 are accepted for History. (Retrieval reported 4, matching
|
||||
/// the 2020 value, so it needs no widening.)
|
||||
/// </summary>
|
||||
public const uint HistoryInterfaceVersionGrpc2023R2 = 12;
|
||||
|
||||
/// <summary>
|
||||
/// True when the service interface reports a meaningful version that should be matched.
|
||||
/// Status is reachability-only (its <c>GetInterfaceVersion</c> returns 0).
|
||||
@@ -56,7 +65,7 @@ internal static class HistorianServerVersionGate
|
||||
_ => false
|
||||
};
|
||||
|
||||
/// <summary>The interface version this SDK's serializers target for a value-gated service.</summary>
|
||||
/// <summary>The canonical interface version this SDK's serializers target for a value-gated service.</summary>
|
||||
public static uint ExpectedVersion(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
HistorianServiceInterface.History => HistoryInterfaceVersion,
|
||||
@@ -65,6 +74,18 @@ internal static class HistorianServerVersionGate
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// All interface versions accepted for a value-gated service. Usually a single value, but
|
||||
/// History accepts both the 2020 value (11) and the buffer-compatible 2023 R2 gRPC value (12).
|
||||
/// </summary>
|
||||
public static uint[] AcceptedVersions(HistorianServiceInterface service) => service switch
|
||||
{
|
||||
HistorianServiceInterface.History => [HistoryInterfaceVersion, HistoryInterfaceVersionGrpc2023R2],
|
||||
HistorianServiceInterface.Retrieval => [RetrievalInterfaceVersion],
|
||||
HistorianServiceInterface.Transaction => [TransactionInterfaceVersion],
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(service), service, "Service interface is not value-gated.")
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Throws <see cref="ProtocolEvidenceMissingException"/> when version verification is enabled
|
||||
/// and the server's reported interface version differs from the version this SDK targets.
|
||||
@@ -80,14 +101,15 @@ internal static class HistorianServerVersionGate
|
||||
return;
|
||||
}
|
||||
|
||||
uint expected = ExpectedVersion(service);
|
||||
if (reportedVersion == expected)
|
||||
uint[] accepted = AcceptedVersions(service);
|
||||
if (Array.IndexOf(accepted, reportedVersion) >= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string acceptedList = string.Join(", ", accepted);
|
||||
throw new ProtocolEvidenceMissingException(
|
||||
$"{service} interface version {reportedVersion} (this SDK's serializers target version {expected}); " +
|
||||
$"{service} interface version {reportedVersion} (this SDK's serializers target version {acceptedList}); " +
|
||||
$"set {nameof(HistorianClientOptions)}.{nameof(HistorianClientOptions.VerifyServerInterfaceVersion)}=false to bypass at your own risk");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user