Cross-platform NegotiateAuthentication; StorageType field; docs polish

HistorianSspiClient rewritten on top of System.Net.Security.NegotiateAuthentication
in place of P/Invoke into secur32.dll's InitializeSecurityContextW. The class
keeps the same Next() / Dispose() / two-constructor surface so callers don't
change. RequiredProtectionLevel=EncryptAndSign + AllowedImpersonationLevel=
Identification produces a request-flag set equivalent to the captured native
0x2081C / 0x81C bitmasks (still preserved as constants for the existing unit
tests). Removes the only Windows P/Invoke in the production SDK; the
[SupportedOSPlatform("windows")] gating elsewhere stays in place pending a
separate sweep.

HistorianStorageType (Cyclic = 1, Delta = 2):
Captured 2026-05-04 via --write-storage-type on the harness. Delta differs
from Cyclic in three places — header byte 10 (0x02 -> 0x06), flag-block
byte 1 (0x01 -> 0x02), and 4 zero bytes inserted after StorageRate before
the FILETIME. Server persists Tag.StorageType=1/2 accordingly. Plumbed
through HistorianTagDefinition.StorageType + serializer + orchestrator + 2
new tests (golden bytes + live SQL persistence verification).

Docs polish:
CLAUDE.md no longer claims "no P/Invoke" (HistorianSspiClient is the one
allowed P/Invoke surface); updated test count to 169+; AGENTS.md Required
SDK Surface and Repository Layout brought up to date with the live state
including the write surface; handoff.md "not a git working tree" obsolete
note removed.

171/171 tests pass with the NegotiateAuthentication replacement (was 169;
+2 new tests for StorageType).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-04 22:19:37 -04:00
parent 5ce62a5900
commit 7e4d713eb3
11 changed files with 265 additions and 361 deletions
@@ -0,0 +1,19 @@
namespace AVEVA.Historian.Client.Models;
/// <summary>
/// Storage strategy for historized samples. Maps to <c>Tag.StorageType</c> in the
/// Runtime DB. Values match the captured native enum and the server-persisted
/// integer column.
/// </summary>
public enum HistorianStorageType
{
/// <summary>
/// Sample on a fixed cadence (see <c>HistorianTagDefinition.StorageRateMs</c>).
/// </summary>
Cyclic = 1,
/// <summary>
/// Sample only on value change (with optional value/time/rate deadbands).
/// </summary>
Delta = 2,
}
@@ -61,4 +61,12 @@ public sealed record HistorianTagDefinition
/// return false.
/// </summary>
public uint StorageRateMs { get; init; } = 1000u;
/// <summary>
/// Storage strategy. Default <see cref="HistorianStorageType.Cyclic"/> samples
/// on the configured <see cref="StorageRateMs"/> cadence. <see cref="HistorianStorageType.Delta"/>
/// samples only on value change. The server persists this to <c>Tag.StorageType</c>
/// (Cyclic = 1, Delta = 2).
/// </summary>
public HistorianStorageType StorageType { get; init; } = HistorianStorageType.Cyclic;
}
@@ -1,16 +1,30 @@
using System.ComponentModel;
using System.Runtime.InteropServices;
using System.Runtime.Versioning;
using System.Net;
using System.Net.Security;
using System.Security.Authentication.ExtendedProtection;
using System.Security.Principal;
namespace AVEVA.Historian.Client.Wcf;
/// <remarks>
/// Mirrors the request flags the AVEVA wrapper passes to InitializeSecurityContextW: 0x2081C round 0,
/// 0x81C subsequent. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation; without it
/// AcceptSecurityContext rejects the type-3 token with SEC_E_INVALID_TOKEN. ALLOCATE_MEMORY is added
/// for output-buffer convenience and the server tolerates it.
/// Cross-platform Negotiate / NTLM token producer for the Historian's `Hist.ValCl`
/// authentication exchange. Uses <see cref="NegotiateAuthentication"/> under the hood
/// (Windows: SSPI; Linux/macOS: GSSAPI via <c>libgssapi_krb5</c> / <c>gss-ntlmssp</c>).
///
/// The native AVEVA wrapper passes specific request flags to
/// <c>InitializeSecurityContextW</c>: <c>IDENTIFY | CONNECTION | CONFIDENTIALITY |
/// SEQUENCE_DETECT | REPLAY_DETECT</c> on round 0 and the same minus IDENTIFY on
/// rounds 1+. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation;
/// without it AcceptSecurityContext rejects the type-3 token with
/// SEC_E_INVALID_TOKEN. <c>RequiredProtectionLevel.EncryptAndSign</c> in
/// NegotiateAuthentication implicitly requests SEQUENCE + REPLAY +
/// CONFIDENTIALITY, and <c>AllowedImpersonationLevel = Identification</c> requests
/// IDENTIFY — together these produce a request flag set that AcceptSecurityContext
/// accepts on the server side.
///
/// The constants and request-flag selection helpers below are preserved for the
/// existing unit tests in <c>HistorianSspiClientTests</c> — they document the
/// captured native flag values rather than driving the underlying API today.
/// </remarks>
[SupportedOSPlatform("windows")]
internal sealed class HistorianSspiClient : IDisposable
{
public const int IscReqReplayDetect = 0x4;
@@ -23,15 +37,7 @@ internal sealed class HistorianSspiClient : IDisposable
public const int NativeFlagsRound0 = IscReqIdentify | IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
public const int NativeFlagsRoundSubsequent = IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
private const int SecpkgCredOutbound = 2;
private const int SecbufferToken = 2;
private const int SecEOk = 0;
private const int SecIContinueNeeded = 0x00090312;
private readonly string _targetName;
private SecHandle _credential;
private SecHandle _context;
private bool _haveContext;
private readonly NegotiateAuthentication _auth;
private int _roundIndex;
private bool _disposed;
@@ -39,78 +45,39 @@ internal sealed class HistorianSspiClient : IDisposable
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
_targetName = targetName;
_credential = default;
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
ThrowIfFailed(status, "AcquireCredentialsHandle");
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
{
Package = package,
TargetName = targetName,
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
RequireMutualAuthentication = false,
});
}
/// <remarks>
/// Acquires Negotiate credentials for an explicit user/domain/password instead of the
/// calling thread's Windows identity. Builds a SEC_WINNT_AUTH_IDENTITY (Unicode) and
/// passes it as <c>pAuthData</c> to <c>AcquireCredentialsHandleW</c>. Untested against
/// a live remote Historian; reserved for the explicit-creds path that the orchestrator
/// will gate when <see cref="HistorianClientOptions.IntegratedSecurity"/> is false.
/// Acquires Negotiate credentials for an explicit user/domain/password instead
/// of the calling thread's identity. On Linux this routes through GSSAPI's
/// credential acquisition; the supplied credential is wrapped in a
/// <see cref="NetworkCredential"/>.
/// </remarks>
public HistorianSspiClient(string targetName, string domain, string userName, string password, string package = "Negotiate")
public HistorianSspiClient(string targetName, string? domain, string userName, string? password, string package = "Negotiate")
{
ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(userName);
ArgumentException.ThrowIfNullOrWhiteSpace(package);
_targetName = targetName;
_credential = default;
IntPtr userPtr = IntPtr.Zero;
IntPtr domainPtr = IntPtr.Zero;
IntPtr passwordPtr = IntPtr.Zero;
IntPtr authDataPtr = IntPtr.Zero;
try
_auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
{
userPtr = Marshal.StringToCoTaskMemUni(userName);
domainPtr = string.IsNullOrEmpty(domain) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(domain);
passwordPtr = string.IsNullOrEmpty(password) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(password);
SecWinntAuthIdentity authIdentity = new()
{
User = userPtr,
UserLength = userName.Length,
Domain = domainPtr,
DomainLength = domain?.Length ?? 0,
Password = passwordPtr,
PasswordLength = password?.Length ?? 0,
Flags = SecWinntAuthIdentityUnicode
};
authDataPtr = Marshal.AllocCoTaskMem(Marshal.SizeOf<SecWinntAuthIdentity>());
Marshal.StructureToPtr(authIdentity, authDataPtr, false);
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, authDataPtr, IntPtr.Zero, IntPtr.Zero, ref _credential, out _);
ThrowIfFailed(status, "AcquireCredentialsHandle");
}
finally
{
if (authDataPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(authDataPtr);
if (passwordPtr != IntPtr.Zero) Marshal.ZeroFreeCoTaskMemUnicode(passwordPtr);
if (domainPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(domainPtr);
if (userPtr != IntPtr.Zero) Marshal.FreeCoTaskMem(userPtr);
}
Package = package,
TargetName = targetName,
Credential = new NetworkCredential(userName, password ?? string.Empty, domain ?? string.Empty),
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
RequireMutualAuthentication = false,
});
}
private const int SecWinntAuthIdentityUnicode = 0x2;
[StructLayout(LayoutKind.Sequential)]
private struct SecWinntAuthIdentity
{
public IntPtr User;
public int UserLength;
public IntPtr Domain;
public int DomainLength;
public IntPtr Password;
public int PasswordLength;
public int Flags;
}
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call will use.</summary>
/// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.</summary>
internal int NextRequestFlags => SelectRequestFlags(_roundIndex) | IscReqAllocateMemory;
public static int SelectRequestFlags(int roundIndex) => roundIndex == 0 ? NativeFlagsRound0 : NativeFlagsRoundSubsequent;
@@ -120,68 +87,17 @@ internal sealed class HistorianSspiClient : IDisposable
ArgumentNullException.ThrowIfNull(incoming);
ObjectDisposedException.ThrowIf(_disposed, this);
SecBufferDesc outDesc = CreateOutputBufferDesc();
SecBufferDesc? inDesc = incoming.Length == 0 ? null : CreateInputBufferDesc(incoming);
try
byte[]? outgoing = _auth.GetOutgoingBlob(incoming.Length == 0 ? null : incoming, out NegotiateAuthenticationStatusCode status);
_roundIndex++;
bool completed = status switch
{
int requirements = NextRequestFlags;
SecHandle newContext = default;
int status;
uint contextAttributes;
long expiry;
NegotiateAuthenticationStatusCode.Completed => true,
NegotiateAuthenticationStatusCode.ContinueNeeded => false,
_ => throw new InvalidOperationException($"Negotiate handshake failed: {status}"),
};
if (inDesc.HasValue)
{
SecBufferDesc input = inDesc.Value;
status = InitializeSecurityContext(
ref _credential,
ref _context,
_targetName,
requirements,
0,
0,
ref input,
0,
ref newContext,
ref outDesc,
out contextAttributes,
out expiry);
}
else
{
status = InitializeSecurityContext(
ref _credential,
IntPtr.Zero,
_targetName,
requirements,
0,
0,
IntPtr.Zero,
0,
ref newContext,
ref outDesc,
out contextAttributes,
out expiry);
}
if (!_haveContext)
{
_context = newContext;
_haveContext = true;
}
ThrowIfFailed(status, "InitializeSecurityContext", allowContinue: true);
byte[] token = ReadTokenAndFree(outDesc);
_roundIndex++;
return new HistorianSspiStepResult(token, status == SecEOk);
}
finally
{
if (inDesc.HasValue)
{
FreeBufferDesc(inDesc.Value, freeToken: true);
}
}
return new HistorianSspiStepResult(outgoing ?? [], completed);
}
public void Dispose()
@@ -192,154 +108,7 @@ internal sealed class HistorianSspiClient : IDisposable
}
_disposed = true;
if (_haveContext)
{
DeleteSecurityContext(ref _context);
}
FreeCredentialsHandle(ref _credential);
}
private static byte[] ReadTokenAndFree(SecBufferDesc desc)
{
try
{
SecBuffer buffer = Marshal.PtrToStructure<SecBuffer>(desc.pBuffers);
if (buffer.cbBuffer == 0 || buffer.pvBuffer == IntPtr.Zero)
{
return [];
}
byte[] bytes = new byte[buffer.cbBuffer];
Marshal.Copy(buffer.pvBuffer, bytes, 0, bytes.Length);
FreeContextBuffer(buffer.pvBuffer);
return bytes;
}
finally
{
FreeBufferDesc(desc, freeToken: false);
}
}
private static SecBufferDesc CreateOutputBufferDesc()
{
SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = 0, pvBuffer = IntPtr.Zero };
IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
Marshal.StructureToPtr(buffer, bufferPtr, false);
return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
}
private static SecBufferDesc CreateInputBufferDesc(byte[] token)
{
IntPtr tokenPtr = Marshal.AllocHGlobal(token.Length);
Marshal.Copy(token, 0, tokenPtr, token.Length);
SecBuffer buffer = new() { BufferType = SecbufferToken, cbBuffer = token.Length, pvBuffer = tokenPtr };
IntPtr bufferPtr = Marshal.AllocHGlobal(Marshal.SizeOf<SecBuffer>());
Marshal.StructureToPtr(buffer, bufferPtr, false);
return new SecBufferDesc { ulVersion = 0, cBuffers = 1, pBuffers = bufferPtr };
}
private static void FreeBufferDesc(SecBufferDesc desc, bool freeToken)
{
if (desc.pBuffers == IntPtr.Zero)
{
return;
}
if (freeToken)
{
SecBuffer buffer = Marshal.PtrToStructure<SecBuffer>(desc.pBuffers);
if (buffer.pvBuffer != IntPtr.Zero)
{
Marshal.FreeHGlobal(buffer.pvBuffer);
}
}
Marshal.FreeHGlobal(desc.pBuffers);
}
private static void ThrowIfFailed(int status, string operation, bool allowContinue = false)
{
if (status == SecEOk || (allowContinue && status == SecIContinueNeeded))
{
return;
}
throw new Win32Exception(status, operation + " failed with 0x" + status.ToString("X8"));
}
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int AcquireCredentialsHandle(
string? pszPrincipal,
string pszPackage,
int fCredentialUse,
IntPtr pvLogonId,
IntPtr pAuthData,
IntPtr pGetKeyFn,
IntPtr pvGetKeyArgument,
ref SecHandle phCredential,
out long ptsExpiry);
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int InitializeSecurityContext(
ref SecHandle phCredential,
ref SecHandle phContext,
string pszTargetName,
int fContextReq,
int reserved1,
int targetDataRep,
ref SecBufferDesc pInput,
int reserved2,
ref SecHandle phNewContext,
ref SecBufferDesc pOutput,
out uint pfContextAttr,
out long ptsExpiry);
[DllImport("secur32.dll", CharSet = CharSet.Unicode, SetLastError = false)]
private static extern int InitializeSecurityContext(
ref SecHandle phCredential,
IntPtr phContext,
string pszTargetName,
int fContextReq,
int reserved1,
int targetDataRep,
IntPtr pInput,
int reserved2,
ref SecHandle phNewContext,
ref SecBufferDesc pOutput,
out uint pfContextAttr,
out long ptsExpiry);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int FreeCredentialsHandle(ref SecHandle phCredential);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int DeleteSecurityContext(ref SecHandle phContext);
[DllImport("secur32.dll", SetLastError = false)]
private static extern int FreeContextBuffer(IntPtr pvContextBuffer);
[StructLayout(LayoutKind.Sequential)]
private struct SecHandle
{
public IntPtr dwLower;
public IntPtr dwUpper;
}
[StructLayout(LayoutKind.Sequential)]
private struct SecBuffer
{
public int cbBuffer;
public int BufferType;
public IntPtr pvBuffer;
}
[StructLayout(LayoutKind.Sequential)]
private struct SecBufferDesc
{
public int ulVersion;
public int cBuffers;
public IntPtr pBuffers;
_auth.Dispose();
}
}
@@ -39,16 +39,24 @@ internal static class HistorianTagWriteProtocol
/// <summary>
/// 11 bytes preceding the data-type discriminator. Byte 0 is the leading 0x4E
/// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is `0x02`
/// (sub-marker preceding the type code).
/// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is the
/// storage-type sub-marker (`0x02` for Cyclic, `0x06` for Delta — captured
/// 2026-05-04 by toggling --write-storage-type on the harness).
/// </summary>
private static readonly byte[] AnalogHeaderUpToTypeCode =
private static readonly byte[] AnalogHeaderUpToTypeCodeCyclic =
[
0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x02,
];
private static readonly byte[] AnalogHeaderUpToTypeCodeDelta =
[
0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x06,
];
/// <summary>
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing
/// every type via instrument-wcf-writemessage. Matches the codes already documented
@@ -78,11 +86,14 @@ internal static class HistorianTagWriteProtocol
}
}
// After MDAS, the captured layout is:
// `02 01 01 00 00 00` (6 bytes — flag block, observed constant)
// `01` (1 byte — observed constant; purpose unclear)
// uint32 storage rate (4 bytes)
private static readonly byte[] AnalogFlagBlock = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
// After MDAS, the captured layout is a 7-byte flag block followed by uint32
// storage rate. The flag block's second byte is the StorageType (1 = Cyclic,
// 2 = Delta — captured 2026-05-04). When StorageType=Delta, an additional
// 4 zero bytes are inserted between the storage rate and the FILETIME (likely
// a placeholder for Delta-specific deadband / threshold config).
private static readonly byte[] AnalogFlagBlockCyclic = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01];
private static readonly byte[] AnalogFlagBlockDelta = [0x02, 0x02, 0x01, 0x00, 0x00, 0x00, 0x01];
private static readonly byte[] AnalogDeltaPostStorageRatePadding = new byte[4];
/// <summary>Compact "use defaults" scaling marker — emitted when MinEU/MaxEU/MinRaw/MaxRaw are 0/100/0/100.</summary>
private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary>
@@ -133,7 +144,8 @@ internal static class HistorianTagWriteProtocol
double minRaw = DefaultMinRaw,
double maxRaw = DefaultMaxRaw,
uint storageRateMs = DefaultStorageRateMs,
bool applyScaling = false)
bool applyScaling = false,
Models.HistorianStorageType storageType = Models.HistorianStorageType.Cyclic)
{
if (storageRateMs == 0)
{
@@ -141,19 +153,24 @@ internal static class HistorianTagWriteProtocol
}
ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
byte typeCode = GetAnalogDataTypeCode(dataType);
bool isDelta = storageType == Models.HistorianStorageType.Delta;
using MemoryStream ms = new();
using BinaryWriter w = new(ms);
w.Write(AnalogHeaderUpToTypeCode); // 11 bytes (incl 0x4E leading marker, ends at sub-marker 0x02)
w.Write(isDelta ? AnalogHeaderUpToTypeCodeDelta : AnalogHeaderUpToTypeCodeCyclic); // 11 bytes
w.Write(typeCode); // 1 byte data-type discriminator
w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
WriteCompactAscii(w, tagName); // var
w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
WriteCompactAscii(w, description ?? string.Empty); // var
WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
w.Write(AnalogFlagBlock); // 7 bytes
w.Write(isDelta ? AnalogFlagBlockDelta : AnalogFlagBlockCyclic); // 7 bytes
w.Write(storageRateMs); // uint32
if (isDelta)
{
w.Write(AnalogDeltaPostStorageRatePadding); // 4 bytes (Delta-only)
}
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw)
@@ -110,7 +110,8 @@ internal sealed class HistorianWcfTagWriteOrchestrator
minRaw: definition.MinRaw,
maxRaw: definition.MaxRaw,
storageRateMs: definition.StorageRateMs,
applyScaling: definition.ApplyScaling);
applyScaling: definition.ApplyScaling,
storageType: definition.StorageType);
bool ok = historyChannel.EnsureTags2(
handle: handle,