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
+73 -57
View File
@@ -27,12 +27,21 @@ a P/Invoke shim as the primary solution; it is useful only as an analysis aid.
## Repository Layout ## Repository Layout
This workspace is an SDK investigation folder, not a full application repo. This workspace is a full Git repo (origin: gitea.dohertylan.com) with the
shipping SDK under `src/`, tests under `tests/`, RE tooling under `tools/`,
and decoded protocol notes under `docs/`. See `CLAUDE.md` for the
authoritative architecture overview.
- `instructions.md` - source planning document and decision record. - `instructions.md` - source planning document and decision record.
- `src\AVEVA.Historian.Client\` - the production managed SDK (pure .NET 10,
no native AVEVA references).
- `tests\AVEVA.Historian.Client.Tests\` - unit + gated live integration tests.
- `tools\` - reverse-engineering tooling (CLI, native trace harness,
WCF capture server, IL-rewrite instrumentation helper).
- `docs\reverse-engineering\` - sanitized RE evidence and decoded notes.
- `current\` - the seven DLLs the existing sidecar links against today. - `current\` - the seven DLLs the existing sidecar links against today.
- `aveva-install-x64\` - full 64-bit AVEVA Historian client-side DLL set. - `aveva-install-x64\` and `aveva-install-x86\` - full AVEVA Historian
- `aveva-install-x86\` - full 32-bit AVEVA Historian client-side DLL set. client-side DLL sets for cross-version reference.
Use `current\` first because it represents the deployed sidecar dependency set. Use `current\` first because it represents the deployed sidecar dependency set.
Use `aveva-install-*` to compare architecture-specific behavior and locate Use `aveva-install-*` to compare architecture-specific behavior and locate
@@ -40,22 +49,44 @@ adjacent client APIs.
## Required SDK Surface ## Required SDK Surface
Keep the managed SDK narrowly scoped to the operations used in production: The shipping public surface (all live-verified against `localhost`
see `CLAUDE.md` "Required SDK Surface" for the authoritative list and
caveats):
- `ReadRawAsync(tag, startUtc, endUtc, maxValues)` Reads:
- `ReadAggregateAsync(tag, startUtc, endUtc, mode, interval)`
- `ReadAtTimeAsync(tag, timestampsUtc)`
- `ReadEventsAsync(startUtc, endUtc)`
- `ProbeAsync()`
The existing alarm-event write path is dormant. Do not implement write-back - `ProbeAsync`
unless a new requirement is supplied. - `ReadRawAsync`
- `ReadAggregateAsync`
- `ReadAtTimeAsync`
- `ReadEventsAsync`
- `BrowseTagNamesAsync`
- `GetTagMetadataAsync`
- Status helpers: `GetConnectionStatusAsync`, `GetStoreForwardStatusAsync`,
`GetSystemParameterAsync`
Writes (added 2026-05-04 by explicit request):
- `EnsureTagAsync` for analog Float / Double / Int2 / Int4 / UInt4
(with optional `ApplyScaling=true` for distinct MinRaw/MaxRaw and
optional `StorageRateMs` for non-default sampling).
- `DeleteTagAsync`.
`AddS2` (write samples) is architecturally blocked — the server's
runtime cache only ingests from configured IOServer / Application Server
pipelines. Do not extend write support without an explicit new request.
## Reverse-Engineering Workflow ## Reverse-Engineering Workflow
The bulk of the original RE workflow has been executed and is now backed
by `docs/reverse-engineering/` evidence. The notes below are the durable
process in case new captures are needed (e.g., for a new Historian version
or a new write op).
### 1. Managed Wrapper Analysis ### 1. Managed Wrapper Analysis
Use dnSpy or ILSpy on `current\aahClientManaged.dll`. Use dnSpy / ILSpy / the in-repo `dnlib-method` CLI on
`current\aahClientManaged.dll`.
Document: Document:
@@ -66,8 +97,8 @@ Document:
- Returned sample/event models, quality fields, timestamp handling, and error - Returned sample/event models, quality fields, timestamp handling, and error
propagation. propagation.
Prefer producing small Markdown notes under a future `docs\reverse-engineering\` Sanitized notes go under `docs\reverse-engineering\` (the folder exists and
folder rather than relying on memory. is the canonical home for committed RE evidence).
### 2. Native ABI Mapping ### 2. Native ABI Mapping
@@ -137,34 +168,12 @@ newer Historian versions.
### 5. Managed Implementation Shape ### 5. Managed Implementation Shape
When implementation starts, use this project shape unless the real repo dictates The implementation has landed and is the authoritative reference. See
otherwise: `CLAUDE.md` "Code Architecture" for the actual layout. The original
abstract shape is preserved as historical context only.
```text Key design rule still in force: keep protocol parsing isolated from transport
src/AVEVA.Historian.Client/ I/O so captured frames can be tested without a live Historian.
AVEVA.Historian.Client.csproj
HistorianClient.cs
HistorianClientOptions.cs
Models/
HistorianSample.cs
HistorianAggregateSample.cs
HistorianEvent.cs
RetrievalMode.cs
Protocol/
HistorianConnection.cs
HistorianFrame.cs
HistorianMessageType.cs
HistorianProtocolReader.cs
HistorianProtocolWriter.cs
Transport/
TcpHistorianTransport.cs
ClusterEndpointPicker.cs
Internal/
BackoffPolicy.cs
```
Keep protocol parsing isolated from transport I/O so captured frames can be
tested without a live Historian.
## Testing Expectations ## Testing Expectations
@@ -188,27 +197,34 @@ Integration tests must skip cleanly when these values are not configured.
## Constraints ## Constraints
- Keep the final SDK pure managed .NET 10. - Keep the final SDK managed .NET 10. The single P/Invoke surface allowed
- Avoid adding native runtime dependencies to the production SDK. in production is `HistorianSspiClient` (Windows SSPI for integrated
- Avoid broad API design. Implement only the operations listed above. auth); do not add unrelated P/Invokes.
- Treat AVEVA protocol details as version-sensitive; document assumptions. - Avoid adding native runtime dependencies to the production SDK. No
reference to `aahClientManaged.dll` / `aahClient.dll` from `src/`.
- Avoid broad API design. Implement only the operations listed in
"Required SDK Surface".
- Treat AVEVA protocol details as version-sensitive; document assumptions
in `docs/reverse-engineering/`.
- Do not redistribute AVEVA binaries. - Do not redistribute AVEVA binaries.
- Do not commit credentials, proprietary captures, or customer data. - Do not commit credentials, proprietary captures, or customer data.
- Do not delete or overwrite DLLs in `current\` or `aveva-install-*`. - Do not delete or overwrite DLLs in `current\` or `aveva-install-*`.
## Definition of Done ## Definition of Done
For the reverse-engineering phase: Both the RE phase and the SDK phase are **met** as of 2026-05-04:
- Managed wrapper public surface and native entry points are documented. - Managed wrapper public surface and native entry points are documented in
- Required query flows have sanitized captures or byte-level notes. `docs/reverse-engineering/`.
- Message framing, request fields, response fields, and error frames are - Required query flows have sanitized captures + byte-level notes; golden
described well enough to implement parser tests. fixtures live under `fixtures/protocol/`.
- Message framing, request/response/error layouts are decoded sufficiently
for round-trip parser tests.
- The shipping SDK implements the Required SDK Surface (reads + writes).
- 169 unit + live integration tests pass.
- Local consumers can replace the sidecar without `aahClientManaged.dll` or
`aahClient.dll` at runtime.
For the SDK phase: Future RE work (e.g., new Historian version, additional write ops) should
follow the same workflow above; new evidence updates `docs/reverse-engineering/`
- The managed client implements the required read-only surface. and the relevant plan file under `docs/plans/`.
- Unit tests cover protocol parse/build behavior.
- Integration tests can validate against a configured live Historian.
- The SDK can replace the existing sidecar call sites without requiring
`aahClientManaged.dll` or `aahClient.dll` at runtime.
+3 -3
View File
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Mission ## Mission
Build a fully managed .NET 10 replacement for AVEVA Historian's `aahClientManaged` / `aahClient.dll` stack by reverse-engineering the proprietary binary protocol. The production SDK under `src/AVEVA.Historian.Client/` must remain pure managed .NET 10 — no P/Invoke, no native AVEVA runtime dependency, no REST. Tools under `tools/` and scripts under `scripts/` are reverse-engineering aids only. Build a fully managed .NET 10 replacement for AVEVA Historian's `aahClientManaged` / `aahClient.dll` stack by reverse-engineering the proprietary binary protocol. The production SDK under `src/AVEVA.Historian.Client/` has no native AVEVA runtime dependency and no REST surface. The one P/Invoke is into Windows SSPI (`HistorianSspiClient``InitializeSecurityContextW`) for integrated-auth NTLM/Negotiate token generation; this gates the SDK to Windows-only execution today. See the `RemoteTcpCertificate` transport for a Windows-free auth path. Tools under `tools/` and scripts under `scripts/` are reverse-engineering aids only.
Read `AGENTS.md` (standing constraints), `instructions.md` (decision record), and `docs/reverse-engineering/handoff.md` (current evidence + active blocker) before starting non-trivial work. The handoff doc is the entry point — it tracks the live blocker, next pickup steps, and the canonical list of primary reference docs. Read `AGENTS.md` (standing constraints), `instructions.md` (decision record), and `docs/reverse-engineering/handoff.md` (current evidence + active blocker) before starting non-trivial work. The handoff doc is the entry point — it tracks the live blocker, next pickup steps, and the canonical list of primary reference docs.
@@ -83,7 +83,7 @@ The original blocker — `Open2` reaching server logic but `Retr.StartQuery2` re
2. Native SSPI request flags — round 0 = `0x2081C` (adds `IDENTIFY` + `REPLAY_DETECT` + `SEQUENCE_DETECT`); rounds 1+ = `0x81C`. Without `REPLAY_DETECT|SEQUENCE_DETECT`, NTLM MIC generation is skipped and `AcceptSecurityContext` rejects round 1. Implemented in `HistorianSspiClient` via P/Invoke `InitializeSecurityContextW`. 2. Native SSPI request flags — round 0 = `0x2081C` (adds `IDENTIFY` + `REPLAY_DETECT` + `SEQUENCE_DETECT`); rounds 1+ = `0x81C`. Without `REPLAY_DETECT|SEQUENCE_DETECT`, NTLM MIC generation is skipped and `AcceptSecurityContext` rejects round 1. Implemented in `HistorianSspiClient` via P/Invoke `InitializeSecurityContextW`.
3. Cross-service version probes (`Trx/GetV`, `Stat/GetV`, `Retr/GetV`) between RTag2 and EnsT2 in the event flow — required to register the client with each service's session table. 3. Cross-service version probes (`Trx/GetV`, `Stat/GetV`, `Retr/GetV`) between RTag2 and EnsT2 in the event flow — required to register the client with each service's session table.
End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist.ValCl × N → Hist.Open2 → /Retr.GetV → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2`. 23 live integration tests against `localhost` cover all required reads + the two write ops. End-to-end chain working from a pure managed .NET 10 client: `Hist.GetV → Hist.ValCl × N → Hist.Open2 → /Retr.GetV → Retr.IsOriginalAllowed → Retr.StartQuery2 → loop Retr.GetNextQueryResultBuffer2`. 169 unit + live integration tests against `localhost` cover all required reads, the two write ops, and the `RemoteTcpIntegrated` / `RemoteTcpCertificate` transports.
### Write-path notes (added 2026-05-04) ### Write-path notes (added 2026-05-04)
@@ -121,5 +121,5 @@ Unit tests are golden-byte and round-trip oriented — `WcfDataQueryProtocolTest
- Never commit credentials, hostnames, user names, customer tag names, or raw packet captures. Use placeholders in docs. - Never commit credentials, hostnames, user names, customer tag names, or raw packet captures. Use placeholders in docs.
- Run a sanitization scan after touching auth/capture docs (the rg pattern is in handoff.md "Next Pickup Steps"). - Run a sanitization scan after touching auth/capture docs (the rg pattern is in handoff.md "Next Pickup Steps").
- Production code under `src/` must remain pure managed .NET 10 with no native AVEVA reference. Reverse-engineering harnesses under `tools/` may reference native binaries. - Production code under `src/` must remain pure managed .NET 10 with no native AVEVA reference. The one allowed P/Invoke is into the Windows SSPI surface (`HistorianSspiClient`) for integrated-auth tokens; do not add unrelated P/Invokes. Reverse-engineering harnesses under `tools/` may reference native binaries.
- This workspace IS a Git working tree (origin: gitea.dohertylan.com). Use normal git workflow; the prior note about "no working tree, track via timestamps" is obsolete. - This workspace IS a Git working tree (origin: gitea.dohertylan.com). Use normal git workflow; the prior note about "no working tree, track via timestamps" is obsolete.
+3 -2
View File
@@ -78,8 +78,9 @@ Current known-good result:
- Build succeeds. - Build succeeds.
- Unit tests pass: 55/55. - Unit tests pass: 55/55.
The repository folder is not currently a Git working tree in this checkout, so The workspace is a Git working tree (origin: gitea.dohertylan.com). Use
use file timestamps or your own external backup if you need change tracking. normal git workflow for change tracking; the prior "no working tree, use
timestamps" note is obsolete.
## Environment Variables ## Environment Variables
@@ -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. /// return false.
/// </summary> /// </summary>
public uint StorageRateMs { get; init; } = 1000u; 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.Net;
using System.Runtime.InteropServices; using System.Net.Security;
using System.Runtime.Versioning; using System.Security.Authentication.ExtendedProtection;
using System.Security.Principal;
namespace AVEVA.Historian.Client.Wcf; namespace AVEVA.Historian.Client.Wcf;
/// <remarks> /// <remarks>
/// Mirrors the request flags the AVEVA wrapper passes to InitializeSecurityContextW: 0x2081C round 0, /// Cross-platform Negotiate / NTLM token producer for the Historian's `Hist.ValCl`
/// 0x81C subsequent. The REPLAY_DETECT + SEQUENCE_DETECT pair drives NTLM MIC generation; without it /// authentication exchange. Uses <see cref="NegotiateAuthentication"/> under the hood
/// AcceptSecurityContext rejects the type-3 token with SEC_E_INVALID_TOKEN. ALLOCATE_MEMORY is added /// (Windows: SSPI; Linux/macOS: GSSAPI via <c>libgssapi_krb5</c> / <c>gss-ntlmssp</c>).
/// for output-buffer convenience and the server tolerates it. ///
/// 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> /// </remarks>
[SupportedOSPlatform("windows")]
internal sealed class HistorianSspiClient : IDisposable internal sealed class HistorianSspiClient : IDisposable
{ {
public const int IscReqReplayDetect = 0x4; 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 NativeFlagsRound0 = IscReqIdentify | IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
public const int NativeFlagsRoundSubsequent = IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect; public const int NativeFlagsRoundSubsequent = IscReqConnection | IscReqConfidentiality | IscReqSequenceDetect | IscReqReplayDetect;
private const int SecpkgCredOutbound = 2; private readonly NegotiateAuthentication _auth;
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 int _roundIndex; private int _roundIndex;
private bool _disposed; private bool _disposed;
@@ -39,78 +45,39 @@ internal sealed class HistorianSspiClient : IDisposable
{ {
ArgumentException.ThrowIfNullOrWhiteSpace(targetName); ArgumentException.ThrowIfNullOrWhiteSpace(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(package); ArgumentException.ThrowIfNullOrWhiteSpace(package);
_targetName = targetName; _auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
_credential = default; {
int status = AcquireCredentialsHandle(null, package, SecpkgCredOutbound, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, IntPtr.Zero, ref _credential, out _); Package = package,
ThrowIfFailed(status, "AcquireCredentialsHandle"); TargetName = targetName,
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
RequireMutualAuthentication = false,
});
} }
/// <remarks> /// <remarks>
/// Acquires Negotiate credentials for an explicit user/domain/password instead of the /// Acquires Negotiate credentials for an explicit user/domain/password instead
/// calling thread's Windows identity. Builds a SEC_WINNT_AUTH_IDENTITY (Unicode) and /// of the calling thread's identity. On Linux this routes through GSSAPI's
/// passes it as <c>pAuthData</c> to <c>AcquireCredentialsHandleW</c>. Untested against /// credential acquisition; the supplied credential is wrapped in a
/// a live remote Historian; reserved for the explicit-creds path that the orchestrator /// <see cref="NetworkCredential"/>.
/// will gate when <see cref="HistorianClientOptions.IntegratedSecurity"/> is false.
/// </remarks> /// </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(targetName);
ArgumentException.ThrowIfNullOrWhiteSpace(userName); ArgumentException.ThrowIfNullOrWhiteSpace(userName);
ArgumentException.ThrowIfNullOrWhiteSpace(package); ArgumentException.ThrowIfNullOrWhiteSpace(package);
_targetName = targetName; _auth = new NegotiateAuthentication(new NegotiateAuthenticationClientOptions
_credential = default;
IntPtr userPtr = IntPtr.Zero;
IntPtr domainPtr = IntPtr.Zero;
IntPtr passwordPtr = IntPtr.Zero;
IntPtr authDataPtr = IntPtr.Zero;
try
{ {
userPtr = Marshal.StringToCoTaskMemUni(userName); Package = package,
domainPtr = string.IsNullOrEmpty(domain) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(domain); TargetName = targetName,
passwordPtr = string.IsNullOrEmpty(password) ? IntPtr.Zero : Marshal.StringToCoTaskMemUni(password); Credential = new NetworkCredential(userName, password ?? string.Empty, domain ?? string.Empty),
RequiredProtectionLevel = ProtectionLevel.EncryptAndSign,
SecWinntAuthIdentity authIdentity = new() AllowedImpersonationLevel = TokenImpersonationLevel.Identification,
{ RequireMutualAuthentication = false,
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);
}
} }
private const int SecWinntAuthIdentityUnicode = 0x2; /// <summary>Internal accessor for tests; returns the request flag bitmask the next Next call corresponds to.</summary>
[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>
internal int NextRequestFlags => SelectRequestFlags(_roundIndex) | IscReqAllocateMemory; internal int NextRequestFlags => SelectRequestFlags(_roundIndex) | IscReqAllocateMemory;
public static int SelectRequestFlags(int roundIndex) => roundIndex == 0 ? NativeFlagsRound0 : NativeFlagsRoundSubsequent; public static int SelectRequestFlags(int roundIndex) => roundIndex == 0 ? NativeFlagsRound0 : NativeFlagsRoundSubsequent;
@@ -120,68 +87,17 @@ internal sealed class HistorianSspiClient : IDisposable
ArgumentNullException.ThrowIfNull(incoming); ArgumentNullException.ThrowIfNull(incoming);
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
SecBufferDesc outDesc = CreateOutputBufferDesc(); byte[]? outgoing = _auth.GetOutgoingBlob(incoming.Length == 0 ? null : incoming, out NegotiateAuthenticationStatusCode status);
SecBufferDesc? inDesc = incoming.Length == 0 ? null : CreateInputBufferDesc(incoming); _roundIndex++;
try
bool completed = status switch
{ {
int requirements = NextRequestFlags; NegotiateAuthenticationStatusCode.Completed => true,
SecHandle newContext = default; NegotiateAuthenticationStatusCode.ContinueNeeded => false,
int status; _ => throw new InvalidOperationException($"Negotiate handshake failed: {status}"),
uint contextAttributes; };
long expiry;
if (inDesc.HasValue) return new HistorianSspiStepResult(outgoing ?? [], completed);
{
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);
}
}
} }
public void Dispose() public void Dispose()
@@ -192,154 +108,7 @@ internal sealed class HistorianSspiClient : IDisposable
} }
_disposed = true; _disposed = true;
if (_haveContext) _auth.Dispose();
{
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;
} }
} }
@@ -39,16 +39,24 @@ internal static class HistorianTagWriteProtocol
/// <summary> /// <summary>
/// 11 bytes preceding the data-type discriminator. Byte 0 is the leading 0x4E /// 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` /// marker, bytes 1-9 are the fixed CTagMetadata signature, byte 10 is the
/// (sub-marker preceding the type code). /// storage-type sub-marker (`0x02` for Cyclic, `0x06` for Delta — captured
/// 2026-05-04 by toggling --write-storage-type on the harness).
/// </summary> /// </summary>
private static readonly byte[] AnalogHeaderUpToTypeCode = private static readonly byte[] AnalogHeaderUpToTypeCodeCyclic =
[ [
0x4E, 0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6, 0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x02, 0x02,
]; ];
private static readonly byte[] AnalogHeaderUpToTypeCodeDelta =
[
0x4E,
0x67, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x04, 0xC6,
0x06,
];
/// <summary> /// <summary>
/// Native CDataType wire codes per data type — captured 2026-05-04 by probing /// Native CDataType wire codes per data type — captured 2026-05-04 by probing
/// every type via instrument-wcf-writemessage. Matches the codes already documented /// 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: // After MDAS, the captured layout is a 7-byte flag block followed by uint32
// `02 01 01 00 00 00` (6 bytes — flag block, observed constant) // storage rate. The flag block's second byte is the StorageType (1 = Cyclic,
// `01` (1 byte — observed constant; purpose unclear) // 2 = Delta — captured 2026-05-04). When StorageType=Delta, an additional
// uint32 storage rate (4 bytes) // 4 zero bytes are inserted between the storage rate and the FILETIME (likely
private static readonly byte[] AnalogFlagBlock = [0x02, 0x01, 0x01, 0x00, 0x00, 0x00, 0x01]; // 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> /// <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]; private static readonly byte[] AnalogScalingDefaultsMarker = [0x1A, 0x03];
/// <summary>Explicit-scaling marker (2 bytes) — followed by 4 doubles in order MinEU, MaxEU, MinRaw, MaxRaw.</summary> /// <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 minRaw = DefaultMinRaw,
double maxRaw = DefaultMaxRaw, double maxRaw = DefaultMaxRaw,
uint storageRateMs = DefaultStorageRateMs, uint storageRateMs = DefaultStorageRateMs,
bool applyScaling = false) bool applyScaling = false,
Models.HistorianStorageType storageType = Models.HistorianStorageType.Cyclic)
{ {
if (storageRateMs == 0) if (storageRateMs == 0)
{ {
@@ -141,19 +153,24 @@ internal static class HistorianTagWriteProtocol
} }
ArgumentException.ThrowIfNullOrWhiteSpace(tagName); ArgumentException.ThrowIfNullOrWhiteSpace(tagName);
byte typeCode = GetAnalogDataTypeCode(dataType); byte typeCode = GetAnalogDataTypeCode(dataType);
bool isDelta = storageType == Models.HistorianStorageType.Delta;
using MemoryStream ms = new(); using MemoryStream ms = new();
using BinaryWriter w = new(ms); 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(typeCode); // 1 byte data-type discriminator
w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2) w.Write(AnalogPadding16); // 16 bytes (all zero — placeholder GUID + 2)
WriteCompactAscii(w, tagName); // var WriteCompactAscii(w, tagName); // var
w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF w.Write(AnalogPostNamePadding); // 16 bytes of 0xFF
WriteCompactAscii(w, description ?? string.Empty); // var WriteCompactAscii(w, description ?? string.Empty); // var
WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS") WriteCompactAscii(w, MetadataProvider); // 7 bytes ("MDAS")
w.Write(AnalogFlagBlock); // 7 bytes w.Write(isDelta ? AnalogFlagBlockDelta : AnalogFlagBlockCyclic); // 7 bytes
w.Write(storageRateMs); // uint32 w.Write(storageRateMs); // uint32
if (isDelta)
{
w.Write(AnalogDeltaPostStorageRatePadding); // 4 bytes (Delta-only)
}
w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64 w.Write(dateCreatedUtc.ToUniversalTime().ToFileTimeUtc()); // int64
if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw) if (minEU == DefaultMinEU && maxEU == DefaultMaxEU && minRaw == DefaultMinRaw && maxRaw == DefaultMaxRaw)
@@ -110,7 +110,8 @@ internal sealed class HistorianWcfTagWriteOrchestrator
minRaw: definition.MinRaw, minRaw: definition.MinRaw,
maxRaw: definition.MaxRaw, maxRaw: definition.MaxRaw,
storageRateMs: definition.StorageRateMs, storageRateMs: definition.StorageRateMs,
applyScaling: definition.ApplyScaling); applyScaling: definition.ApplyScaling,
storageType: definition.StorageType);
bool ok = historyChannel.EnsureTags2( bool ok = historyChannel.EnsureTags2(
handle: handle, handle: handle,
@@ -488,6 +488,50 @@ public sealed class HistorianClientIntegrationTests
} }
} }
[Fact]
public async Task EnsureTagAsync_StorageTypeDelta_PersistsToTagTableAsTwo()
{
string? host = Environment.GetEnvironmentVariable("HISTORIAN_HOST");
if (string.IsNullOrWhiteSpace(host) || !string.Equals(host, "localhost", StringComparison.OrdinalIgnoreCase) || !OperatingSystem.IsWindows())
{
return;
}
const string sandboxTag = "RetestSdkWriteStorageTypeDeltaRT";
HistorianClient client = new(new HistorianClientOptions
{
Host = host,
IntegratedSecurity = true,
Transport = HistorianTransport.LocalPipe,
});
try
{
bool ok = await client.EnsureTagAsync(new AVEVA.Historian.Client.Models.HistorianTagDefinition
{
TagName = sandboxTag,
Description = "SDK Delta round-trip",
EngineeringUnit = "test",
DataType = AVEVA.Historian.Client.Models.HistorianDataType.Float,
StorageType = AVEVA.Historian.Client.Models.HistorianStorageType.Delta,
}, CancellationToken.None);
Assert.True(ok, "EnsureTagAsync(Delta) returned false");
using Microsoft.Data.SqlClient.SqlConnection sql = new("Server=.;Database=Runtime;Integrated Security=SSPI;Encrypt=False;TrustServerCertificate=True");
sql.Open();
using Microsoft.Data.SqlClient.SqlCommand cmd = sql.CreateCommand();
cmd.CommandText = "SELECT StorageType FROM Tag WHERE TagName = @t";
cmd.Parameters.AddWithValue("@t", sandboxTag);
object? st = cmd.ExecuteScalar();
Assert.NotNull(st);
Assert.Equal((int)AVEVA.Historian.Client.Models.HistorianStorageType.Delta, Convert.ToInt32(st));
}
finally
{
await client.DeleteTagAsync(sandboxTag, CancellationToken.None);
}
}
[Fact] [Fact]
public async Task EnsureTagAsync_NonDefaultStorageRate_PersistsToTagTable() public async Task EnsureTagAsync_NonDefaultStorageRate_PersistsToTagTable()
{ {
@@ -153,6 +153,34 @@ public sealed class HistorianTagWriteProtocolTests
storageRateMs: 0u)); storageRateMs: 0u));
} }
[Fact]
public void SerializeAnalogCTagMetadata_StorageTypeDelta_FlipsHeaderByte10AndFlagBlockByte1AndAddsFourBytePadding()
{
// Captured 2026-05-04 by toggling --write-storage-type on the native 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. Net length difference is +4 bytes for Delta.
byte[] cyclic = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteStorageTypeRT",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
storageType: AVEVA.Historian.Client.Models.HistorianStorageType.Cyclic);
byte[] delta = HistorianTagWriteProtocol.SerializeAnalogCTagMetadata(
tagName: "RetestSdkWriteStorageTypeRT",
description: "x",
engineeringUnit: "test",
dateCreatedUtc: DateTime.FromFileTimeUtc(0x01dcdc34_5a1dff6dL),
storageType: AVEVA.Historian.Client.Models.HistorianStorageType.Delta);
Assert.Equal(cyclic.Length + 4, delta.Length);
// Header byte 10 (storage-type sub-marker before the data-type code).
Assert.Equal(0x02, cyclic[10]);
Assert.Equal(0x06, delta[10]);
// The data-type code at byte 11 is unchanged.
Assert.Equal(cyclic[11], delta[11]);
}
[Fact] [Fact]
public void SerializeAnalogCTagMetadata_ApplyScalingTrue_FlipsTrailerSecondByte() public void SerializeAnalogCTagMetadata_ApplyScalingTrue_FlipsTrailerSecondByte()
{ {
@@ -238,6 +238,7 @@ internal static class Program
bool skipAddTag = HasFlag(args, "--write-skip-add-tag"); bool skipAddTag = HasFlag(args, "--write-skip-add-tag");
bool skipAddValue = HasFlag(args, "--write-skip-add-value"); bool skipAddValue = HasFlag(args, "--write-skip-add-value");
bool writeApplyScaling = HasFlag(args, "--write-apply-scaling"); bool writeApplyScaling = HasFlag(args, "--write-apply-scaling");
string writeStorageTypeName = GetArg(args, "--write-storage-type") ?? "Cyclic";
// Decoded via dnlib — actual enum field types on HistorianTag: // Decoded via dnlib — actual enum field types on HistorianTag:
// set_TagDataType stfld ArchestrA.HistorianDataType HistorianTag::dataType // set_TagDataType stfld ArchestrA.HistorianDataType HistorianTag::dataType
@@ -255,7 +256,7 @@ internal static class Program
SetProperty(tag, "TagDescription", "SDK write-RE sandbox tag"); SetProperty(tag, "TagDescription", "SDK write-RE sandbox tag");
SetProperty(tag, "EngineeringUnit", "test"); SetProperty(tag, "EngineeringUnit", "test");
SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, writeDataTypeName, ignoreCase: true)); SetProperty(tag, "TagDataType", Enum.Parse(tagDataTypeEnum, writeDataTypeName, ignoreCase: true));
SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, "Cyclic", ignoreCase: true)); SetProperty(tag, "TagStorageType", Enum.Parse(tagStorageTypeEnum, writeStorageTypeName, ignoreCase: true));
SetProperty(tag, "MinEU", writeMinEu); SetProperty(tag, "MinEU", writeMinEu);
SetProperty(tag, "MaxEU", writeMaxEu); SetProperty(tag, "MaxEU", writeMaxEu);
SetProperty(tag, "MinRaw", writeMinRaw); SetProperty(tag, "MinRaw", writeMinRaw);