diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/.gitignore b/scripts/validation/opcua-lifecycle-memory/native-retention/.gitignore new file mode 100644 index 00000000..c18dd8d8 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/README.md b/scripts/validation/opcua-lifecycle-memory/native-retention/README.md new file mode 100644 index 00000000..2187bbc6 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/README.md @@ -0,0 +1,213 @@ +# macOS private-key reload native retention — #799 + +The bounded control identifies **configured-directory private-key loading** as a +trigger for continuing `Security::AppleDatabase` heap inventory on this machine, +while observed SDK certificate-core ownership and tracked .NET temporary-keychain +ownership return to zero. No narrow built-in application ownership correction was +found that preserves the current raw-session factory, Directory PKI, certificate +selection/update behavior and certificate properties. **#799 remains open.** This +is private reproduction/support material, not an approved production mitigation. + +Only this new subfolder was changed. No product source, trust policy, SDK version, +certificate factory/crypto provider, exportability, key storage format, existing +PKI or resource budget was changed. No public upstream issue/message was posted. + +## Reviewed, executed control + +Astra reviewed the actual source and installed-runtime decompile before execution, +approved one fixed **200-acquisition** run per arm as attribution-only evidence, +and afterward independently matched all ten raw heap counts/bytes, checkpoint and +completion counts, source hashes, installed crypto hash and eight SDK DLL hashes. +The configured-store versus registry distinction below is supported by inspected +upstream regression source; that upstream regression test was **not executed**. + +Both arms ran in separate fresh processes on macOS **26.5 arm64**, .NET SDK +**10.0.403**, runtime **10.0.14**. A normal SDK ApplicationInstance created one RSA +application PKI; its persisted tree was copied into separate private arm roots. +Both used the same application URI, public DER identity and private key. Normal +SDK configuration validation/certificate checks and teardown were identical. The +loop called these public APIs, disposing each returned owner before checkpoints: + +| Arm | Public acquisition, repeated exactly 200 times | +|---|---| +| Store | `SecurityConfiguration.FindApplicationCertificateAsync(Basic256Sha256, privateKey: true, ...)` | +| Registry | `ICertificateRegistry.AcquireApplicationCertificateBySecurityPolicy(Basic256Sha256)` | + +Each arm sampled ready, first acquisition, acquisition 200, application disposed, +and after a fixed five-second idle. Every checkpoint used the same full-GC / +finalizer / full-GC / 250 ms schedule, then a parent-controlled heap/lsof barrier. +Matched initial/first/last RSA sign-and-verify checks confirmed private-key use. +No iteration called `AsX509Certificate2`; no network, server or session was opened. +`AutoAcceptUntrustedCertificates=false` stayed configured; this key-acquisition +control makes no additional peer-validation wire claim. + +Allocation-stack tracking was enabled in **both** arms, so its allocation/GC cost +is part of these observations. The 180-second child cancellation/checkpoint +limit is not a strict total wall deadline: a running heap probe (20 seconds), +lsof probe (five seconds), final wait and bounded terminate/kill reap can add +bounded overhead. Stdout is a file, with no unbounded `communicate` or pipe drain. +The recorded artifact-creation-to-result-write wall interval was **34.699 seconds** +(filesystem timestamps, not a monotonic acceptance gate). Ready-to-idle checkpoint +intervals were **21.504 seconds store** and **9.123 seconds registry**, including +probe barriers. Both process exits were zero and all children were reaped. + +## Native inventory and ownership results + +| Checkpoint | Store AppleDatabase | Registry AppleDatabase | Tracked runtime keychains, each arm | Outstanding SDK cores, each arm | Temporary keychain files, each arm | +|---|---:|---:|---:|---:|---:| +| Ready | 3 | 3 | 1 | 1 | 1 | +| First acquisition | 4 | 3 | 1 | 1 | 1 | +| Acquisition 200 | 203 | 3 | 1 | 1 | 1 | +| Application disposed | 203 | 3 | 0 | 0 | 0 | +| Five-second idle | 203 | 3 | 0 | 0 | 0 | + +Configured PKI FDs and temporary-keychain FDs were **zero at every checkpoint**. +One other task-TMPDIR descriptor remained in each process; it is reported +separately and is not labelled a keychain descriptor. Total numeric FDs were +110 ready / 112 final in both arms. Manager disposal was observed through an +exact-SDK, read-only `m_disposed` boolean probe. Store certificate-core counters +finished **407 created / 407 disposed**; registry finished **7 / 7**. These public +counters concern underlying certificate cores, not every AddRef wrapper. + +The exact installed runtime's internal temporary-keychain dictionary was read +under the same dictionary monitor, with a one-second bounded acquisition and +exact informational-version/type/field guards. Only its count was read; no native +pointer, handle, key or certificate material was enumerated or modified. Its +count returning zero establishes no remaining entries in that managed lookup. +The installed cleanup code removes the entry **before** calling native +`SecKeychainDelete`, suppresses delete errors, and then releases the CF handle. +The actual installed base `SafeKeychainHandle.ReleaseHandle` was separately +decompiled and does call `Interop.CoreFoundation.CFRelease(handle)`, then zeros +its handle. Apple's [SecKeychainDelete documentation](https://developer.apple.com/documentation/security/seckeychaindelete(_:).md) +distinguishes file/keychain deletion from releasing keychain-object memory; this +inspection finds the separate CFRelease in the installed runtime, not an obvious +missing base-release implementation. These are inspected calls, not native +return-status instrumentation. Therefore lookup zero does **not** prove that +native deletion succeeded. + +| Memory observation | Store ready → final idle | Registry ready → final idle | +|---|---:|---:| +| Managed live bytes | 831,080 → 1,222,192 | 831,128 → 1,130,616 | +| RSS bytes | 87,621,632 → 115,130,368 | 87,654,400 → 97,927,168 | +| Compiled methods | 1,192 → 4,638 | 1,192 → 2,251 | + +RSS includes different JIT, tracking, GC and allocator effects; this comparison +is not an exclusive native-byte attribution or a performance budget. The native +class's shallow heap bytes were 2,304 → 155,904 in store and 2,304 → 2,304 in +registry (768 bytes per classified object). Shallow class bytes exclude owned +allocations and do not explain the entire RSS difference. `PrivateMemorySize64` +returned zero on this runtime and is not used as a private-memory result. + +`heap --noContent -s` reports allocations classified as the C++ +`Security::AppleDatabase` class. Apple's published file-database source declares +that class as a `Database` subclass with database open/close/delete methods and +owned file/modifier state; it is not a file-descriptor counter. The published +source is conceptual evidence, **not** a claim that it is the exact source build +of this installed Security.framework. Retaining classified heap objects after +observed closure is different from proving an open handle, failed native delete, +or the identity of the retaining native owner. See the pinned +[Apple class definition](https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/OSX/libsecurity_filedb/lib/AppleDatabase.h#L485). + +The leaf DER SHA-256 was identical before/after and across arms: +`fadcc11ec694732b82fe4dc47bb41bce6023d113339e53aca9fee5b079f200b2`. +After samples and process exits, every seed/arm PKI, TMPDIR and data fixture +was removed; cleanup receipt is true. Raw native snapshots stay local and private. + +## Exact SDK path and public API limits + +The inspected SDK checkout is the clean **2.0.1** tag at commit +`07e62f72500cc0923a2bab940653c8c1116fa286`; the control references immutable actual +published SDK 2.0.1 DLLs, with all eight hashes in `evidence/2026-11-10/provenance.json`. + +| Source, pinned to the exact SDK commit | Relevant behavior | +|---|---| +| [DefaultSessionFactory.CreateChannelAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Client/Session/DefaultSessionFactory.cs#L406) | Loads a private certificate through the internal overload that defaults registry use to false. | +| [DefaultSessionFactory private CreateAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Client/Session/DefaultSessionFactory.cs#L579) | Constructs the raw Session with null certificate/chain. | +| [Session.LoadInstanceCertificateAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Client/Session/Session.cs#L6335) | Opening that Session independently loads its private certificate; registry preference depends on a channel manager, absent in the current raw factory. | +| [Session.LoadInstanceCertificateEntryAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Client/Session/Session.cs#L6432) | Documents configured-store selection for unmanaged callers and registry preference for managed callers. | +| [SecurityConfiguration.FindApplicationCertificateAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Core/Security/Certificates/SecurityConfiguration.cs#L182) | Private-key branch calls registry-less CertificateIdentifierResolver.LoadPrivateKeyAsync. | +| [CertificateIdentifierResolver.LoadPrivateKeyCoreAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Core/Security/Certificates/CertificateIdentifierResolver.cs#L249) | Opens the configured store and asks it to load the private key; returned reference is caller-owned. | +| [DirectoryCertificateStore.LoadPrivateKeyAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Core/Security/Certificates/DirectoryCertificateStore.cs#L758) | For PFX, macOS storage flags omit EphemeralKeySet and include Exportable, then import through X509CertificateLoader.LoadPkcs12. No public per-store private-key cache/flag setting is offered. | +| [ApplicationConfiguration.ValidateAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Core/Stack/Configuration/ApplicationConfiguration.cs#L735), [ApplicationInstance.CheckOrCreateCertificateAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Configuration/ApplicationInstance.cs#L425), [ServerBase.OnServerStartingAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Core/Stack/Server/ServerBase.cs#L1887) | Application validation, normal own-certificate checking and server bring-up also load configured private certificates. Checking disposes its temporary certificate; registry replacement disposes old entries. | +| [Certificate.AsX509Certificate2](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Security.Certificates/X509Certificate/Certificate.cs#L258) | Nonvirtual export/reimport copy for exportable private keys. Existing isolated controls demonstrate its PFX retention trigger, but prior sampled real-workload stacks identified directory loading as the dominant sampled import path. | + +`CertificateIdentifier` has **no Certificate preload/cache property** and only a +public default constructor. Actual-binary public API inventory is retained in +`seed-api-inventory.jsonl`. `RawData` carries public encoded certificate material; +its setter parses `Certificate.FromRawData` and derives metadata. It cannot carry +the reusable private Certificate owner needed by the raw private-key load path. +The [exact-tag manager documentation](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/docs/CertificateManager.md#materializing-a-certificate-from-a-certificateidentifier) +explicitly places materialized ownership in the manager registry. + +The public registry acquisition API is reference-counted and supported. However, +substituting it in the raw session path changes certificate selection semantics: +[UnmanagedCertificateLoadHonorsAnExplicitConfiguredStoreReplacementAsync](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/tests/Opc.Ua.Client.Tests/Session/SessionCertificateRotationRegressionTests.cs#L198) +requires a configured replacement to win over an older registry certificate and +verifies the registry API is never called. This is **inspected upstream test +source, not a test run here**. A cache/factory rewrite must additionally preserve +update/removal visibility, cancellation/fault cleanup, concurrent owners, +reconnect and certificate/issuer-chain selection; the B arm proves none of those +replacement semantics. ChannelManagerSessionFactory changes architecture and was +not used. Overriding/reimplementing channel and session construction is not a +narrow application disposal fix. + +Production source at `ed60970fc294ab171a80cda1b57942a7130b4c39` was inspected: +[OpcUaApplicationHost](../../../../src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs) +assigns configuration ownership before async validation and disposes its +ApplicationInstance; Shared transfers manager lifetime to the session adapter +and disposes failed configurations; driver/browser release their owned +ApplicationInstance after session cleanup. No missing owner was identified in +those paths. The store arm already keeps its one application/configuration and +registry alive across all 200 acquisitions, so simply lengthening that +application owner cannot remove these private-key imports. Existing full-client +lifecycle disposal checks are complementary +coordinator evidence, not additional executions in this control. + +## Runtime dependency and upstream-ready boundary + +Actual installed `System.Security.Cryptography.dll` informational version: +`10.0.14+0427b9abbead3fa64c6655eb4290c537397131fa`. +SHA-256: `f4957d7218a004a27bf8964e45147df6a88092c6e7344582082840d62c6c579a`. + +The official .NET `v10.0.14` tag resolves to +`6aa72daa24f66c01c3d7b6a32519e9d21396deec`, different from the installed binary's +informational commit. It is not presented as exact installed source. Read-only +ILSpy inspection of the **actual binary** verified the temporary-keychain +creation, dictionary/monitor and release behavior used by diagnostics. Paths and +hashes of those retained local decompilations are in `verification.json`. + +The public runtime source provides matching context for +[temporary keychain creation during non-persisted import and unsupported EphemeralKeySet](https://github.com/dotnet/runtime/blob/6aa72daa24f66c01c3d7b6a32519e9d21396deec/src/libraries/System.Security.Cryptography/src/System/Security/Cryptography/X509Certificates/X509CertificateLoader.macOS.cs) +and [tracked-keychain release ordering](https://github.com/dotnet/runtime/blob/6aa72daa24f66c01c3d7b6a32519e9d21396deec/src/libraries/Common/src/Interop/OSX/System.Security.Cryptography.Native.Apple/Interop.Keychain.macOS.cs). +Microsoft documents that macOS PFX private-key loading requires a keychain and +cannot use EphemeralKeySet in its [cross-platform cryptography guide](https://learn.microsoft.com/en-us/dotnet/standard/security/cross-platform-cryptography#read-a-pkcs12pfx-file). +Changing those flags, exportability, storage format or native crypto was not an +application mitigation attempted here. + +The remaining dependency is an upstream/platform investigation of why the +classified native file-database objects remain after observed SDK owner release, +tracked runtime owner release and backing-file cleanup, and whether there is a +supported correction at that boundary. Native delete status and the retaining +C++ owner are still unknown. This control does not identify a specific .NET or +Apple defect/fix. An upstream SDK improvement would also need to avoid redundant +imports **without losing the existing raw configured-store semantics**. No +supported application-only correction meeting the stated constraints was proven. + +## Private reproduction and evidence + +```sh +python3 scripts/validation/opcua-lifecycle-memory/native-retention/run.py \ + --references /tmp/opcua2-publish-hg100-20261110/linux-arm64/host \ + --output /tmp/opcua2-native-reload-FRESH +``` + +This builds only the standalone control against existing actual SDK DLLs, never +rebuilds product code, requires the exact installed runtime diagnostic layout, +and refuses an existing output directory. The fixed repetition count is not +configurable. Use a private fresh root and serialize with other ownership/native +controls. The checked-in selected receipts are in `evidence/2026-11-10/`; +raw artifacts are `/tmp/opcua2-native-reload-20261110a`. Raw heap/lsof output and +any failed-run private fixture material must not be redistributed wholesale. + +No long run, deployment, trust weakening, SDK fork/version change, budget change, +public upstream post or #799 closure is authorized or claimed by these results. diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/ReloadControl.cs b/scripts/validation/opcua-lifecycle-memory/native-retention/ReloadControl.cs new file mode 100644 index 00000000..d7069f15 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/ReloadControl.cs @@ -0,0 +1,176 @@ +using System.Diagnostics; +using System.Collections; +using System.Reflection; +using System.Runtime; +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; +using System.Text; +using System.Text.Json; +using Opc.Ua; +using Opc.Ua.Configuration; +using Opc.Ua.Security.Certificates; + +internal static class ReloadControl +{ + const string Uri = "urn:localhost:NativeRetention.Control"; + const int Repetitions = 200; + static readonly byte[] Challenge = Encoding.UTF8.GetBytes("native-retention-diagnostic-only"); + static string Root = ""; + static string Hash(Certificate cert) => Convert.ToHexString(SHA256.HashData(cert.RawData)).ToLowerInvariant(); + static void Require(bool yes, string message) { if (!yes) throw new InvalidOperationException(message); } + static ApplicationConfiguration Configuration(string pki, ITelemetryContext telemetry) => new(telemetry) + { + ApplicationName = "NativeRetention.Control", ApplicationUri = Uri, ApplicationType = ApplicationType.Client, + SecurityConfiguration = new SecurityConfiguration + { + ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, + StorePath = Path.Combine(pki, "own"), SubjectName = "CN=NativeRetention.Control" }, + TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "trusted") }, + TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "issuers") }, + RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pki, "rejected") }, + AutoAcceptUntrustedCertificates = false, + }, + ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 15000 }, + }; + static async Task Open(string pki, ITelemetryContext telemetry, CancellationToken ct) + { + var app = new ApplicationInstance(telemetry) { ApplicationType = ApplicationType.Client, + ApplicationName = "NativeRetention.Control", ApplicationConfiguration = Configuration(pki, telemetry) }; + try + { + await app.ApplicationConfiguration!.ValidateAsync(ApplicationType.Client, ct); + Require(await app.CheckApplicationInstanceCertificatesAsync(true, null, ct), "Normal application certificate check failed"); + return app; + } + catch { await app.DisposeAsync(); throw; } + } + static bool ManagerDisposed(ICertificateManager manager) + { + Require(manager.GetType().FullName == "Opc.Ua.CertificateManager" && manager.GetType().Assembly.GetName().Version?.ToString() == "2.0.1.0", "Exact SDK diagnostic layout changed"); + var field = manager.GetType().GetField("m_disposed", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + Require(field?.FieldType == typeof(bool), "Exact SDK disposal bool missing"); + return (bool)field!.GetValue(manager)!; + } + static int RuntimeTemporaryKeychainCount() + { + var assembly = typeof(X509Certificate2).Assembly; + Require(assembly.GetCustomAttribute()?.InformationalVersion == "10.0.14+0427b9abbead3fa64c6655eb4290c537397131fa", "Exact runtime diagnostic build changed"); + var type = assembly.GetType("System.Security.Cryptography.Apple.SafeTemporaryKeychainHandle", throwOnError: true)!; + var field = type.GetField("s_lookup", BindingFlags.Static | BindingFlags.NonPublic | BindingFlags.DeclaredOnly); + Require(field is not null && field.FieldType.IsGenericType && field.FieldType.GetGenericTypeDefinition() == typeof(Dictionary<,>) + && field.FieldType.GetGenericArguments()[0] == typeof(IntPtr) && field.FieldType.GetGenericArguments()[1] == type, + "Exact runtime keychain dictionary layout changed"); + var lookup = (IDictionary)(field!.GetValue(null) ?? throw new InvalidOperationException("Runtime keychain dictionary absent")); + Require(Monitor.TryEnter(lookup, TimeSpan.FromSeconds(1)), "Runtime keychain monitor did not become available"); + try { return lookup.Count; } + finally { Monitor.Exit(lookup); } + } + static void ValidateKey(Certificate certificate, string expected, bool sign) + { + Require(certificate.HasPrivateKey && Hash(certificate) == expected, "Wrong/missing private certificate"); + if (!sign) return; + using var privateKey = certificate.GetRSAPrivateKey(); + using var publicKey = certificate.GetRSAPublicKey(); + Require(privateKey is not null && publicKey is not null, "Expected normal SDK RSA application certificate"); + var signature = privateKey!.SignData(Challenge, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); + Require(publicKey!.VerifyData(Challenge, signature, HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1), "Private key mismatch"); + } + static async Task Stage(string mode, string stage, int operations, bool disposed, CancellationToken ct) + { + GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); + await Task.Delay(250, ct); + using var process = Process.GetCurrentProcess(); process.Refresh(); + Console.WriteLine(JsonSerializer.Serialize(new { kind = "checkpoint", mode, stage, operations, processId = Environment.ProcessId, + utc = DateTimeOffset.UtcNow, managerDisposed = disposed, managedBytes = GC.GetTotalMemory(false), rssBytes = process.WorkingSet64, + privateBytes = process.PrivateMemorySize64, allocatedBytes = GC.GetTotalAllocatedBytes(), compiledMethods = JitInfo.GetCompiledMethodCount(), + gc = new[] { GC.CollectionCount(0), GC.CollectionCount(1), GC.CollectionCount(2) }, + certificateCoresCreated = Certificate.InstancesCreated, certificateCoresDisposed = Certificate.InstancesDisposed, + certificateCoresOutstanding = Certificate.InstancesLeaked, + runtimeTrackedTemporaryKeychains = RuntimeTemporaryKeychainCount() })); + Console.Out.Flush(); + while (!File.Exists(Path.Combine(Root, "continue-" + stage))) await Task.Delay(25, ct); + } + static async Task Arm(string mode, CancellationToken ct) + { + var telemetry = DefaultTelemetry.Create(_ => { }); + var pki = Path.Combine(Root, "pki"); + var before = Directory.GetFiles(pki, "*.der", SearchOption.AllDirectories).Where(p => p.Contains(Path.DirectorySeparatorChar + "own" + Path.DirectorySeparatorChar + "certs" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + .Select(p => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p)))).Order().ToArray(); + Require(before.Length == 1, "Expected one copied own DER certificate"); + ApplicationInstance? app = null; + try + { + app = await Open(pki, telemetry, ct); + var config = app.ApplicationConfiguration!; + var manager = config.CertificateManager; + string expected; + using (var entry = manager.AcquireApplicationCertificateBySecurityPolicy(SecurityPolicies.Basic256Sha256)) + { + Require(entry is not null, "Normal initialization did not populate registry"); + expected = Hash(entry!.Certificate); ValidateKey(entry.Certificate, expected, true); + } + await Stage(mode, "ready", 0, ManagerDisposed(manager), ct); + for (var i = 1; i <= Repetitions; i++) + { + if (mode == "store") + { + using var certificate = await config.SecurityConfiguration.FindApplicationCertificateAsync( + SecurityPolicies.Basic256Sha256, true, telemetry, ct).WaitAsync(ct); + Require(certificate is not null, "Configured-store acquisition failed"); + ValidateKey(certificate!, expected, i is 1 or Repetitions); + } + else + { + using var entry = manager.AcquireApplicationCertificateBySecurityPolicy(SecurityPolicies.Basic256Sha256); + Require(entry is not null, "Registry acquisition failed"); + ValidateKey(entry!.Certificate, expected, i is 1 or Repetitions); + } + if (i is 1 or Repetitions) await Stage(mode, i == 1 ? "first" : "repeated", i, ManagerDisposed(manager), ct); + } + await app.DisposeAsync().AsTask().WaitAsync(ct); app = null; + await Stage(mode, "disposed", Repetitions, ManagerDisposed(manager), ct); + await Task.Delay(TimeSpan.FromSeconds(5), ct); + await Stage(mode, "idle", Repetitions, ManagerDisposed(manager), ct); + // Preserve managed and native post-disposal observations before any ownership assertion rejects the arm. + Require(ManagerDisposed(manager), "Application manager not disposed"); + Require(Certificate.InstancesLeaked == 0, "SDK certificate cores remain after owner disposal"); + Require(RuntimeTemporaryKeychainCount() == 0, "Runtime tracked temporary keychain owners remain after application disposal"); + var after = Directory.GetFiles(pki, "*.der", SearchOption.AllDirectories).Where(p => p.Contains(Path.DirectorySeparatorChar + "own" + Path.DirectorySeparatorChar + "certs" + Path.DirectorySeparatorChar, StringComparison.Ordinal)) + .Select(p => Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(p)))).Order().ToArray(); + Require(after.SequenceEqual(before), "Own application certificate changed"); + Console.WriteLine(JsonSerializer.Serialize(new { kind = "complete", mode, operations = Repetitions, sameLeafSha256 = expected, + certificateUnchanged = true, managerDisposed = true, certificateCoresOutstanding = Certificate.InstancesLeaked, fullSoakGate = false })); + } + finally { if (app is not null) await app.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(10)); } + } + public static async Task Main(string[] args) + { + Root = Path.GetFullPath(args[1]); Directory.CreateDirectory(Root); + using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(180)); + try + { + Require(OperatingSystem.IsMacOS(), "This control requires macOS native inventory"); + Require(Environment.Version.ToString() == "10.0.14", "Matched runtime must be .NET 10.0.14"); + if (args[0] == "seed") + { + await using var app = await Open(Path.Combine(Root, "pki"), DefaultTelemetry.Create(_ => { }), deadline.Token); + using var entry = app.ApplicationConfiguration!.CertificateManager.AcquireApplicationCertificateBySecurityPolicy(SecurityPolicies.Basic256Sha256); + Require(entry is not null, "Seed registry not populated"); + Console.WriteLine(JsonSerializer.Serialize(new { kind = "seed", leafSha256 = Hash(entry!.Certificate), runtime = Environment.Version.ToString(), + identifierPublicProperties = typeof(CertificateIdentifier).GetProperties(BindingFlags.Public | BindingFlags.Instance).Select(p => p.Name).Order().ToArray(), + identifierPublicConstructors = typeof(CertificateIdentifier).GetConstructors().Select(c => c.ToString()).ToArray(), + cryptographyRuntime = new { file = typeof(X509Certificate2).Assembly.Location, + informationalVersion = typeof(X509Certificate2).Assembly.GetCustomAttribute()?.InformationalVersion, + sha256 = Convert.ToHexString(SHA256.HashData(File.ReadAllBytes(typeof(X509Certificate2).Assembly.Location))).ToLowerInvariant() } })); + return 0; + } + Require(args[0] is "store" or "registry", "Unknown control mode"); + await Arm(args[0], deadline.Token); return 0; + } + catch (Exception error) + { + Console.WriteLine(JsonSerializer.Serialize(new { kind = "failed", errorType = error.GetType().FullName, message = error.Message })); + return 1; + } + } +} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/build.log b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/build.log new file mode 100644 index 00000000..1419ce61 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/build.log @@ -0,0 +1,9 @@ + Determining projects to restore... + Restored /private/tmp/opcua2-native-reload-20261110a/Control.csproj (in 34 ms). + Control -> /private/tmp/opcua2-native-reload-20261110a/bin/Release/net10.0/Control.dll + +Build succeeded. + 0 Warning(s) + 0 Error(s) + +Time Elapsed 00:00:00.63 diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/fixture-cleanup.json b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/fixture-cleanup.json new file mode 100644 index 00000000..4cd85074 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/fixture-cleanup.json @@ -0,0 +1,3 @@ +{ + "allOwnedFixtureDirectoriesRemoved": true +} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/provenance.json b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/provenance.json new file mode 100644 index 00000000..3f5aeefe --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/provenance.json @@ -0,0 +1,34 @@ +{ + "controlSourceSha256": "e8c959a076bf1eecaad7e1ae71d8df3c2c86d2978a4558c98792471e880efc27", + "runnerSourceSha256": "159ba76b3048d1a594e3a28032d350e36bc8ead4c93c525c5a60b3013699aeef", + "repetitionsPerArm": 200, + "stages": [ + "ready", + "first", + "repeated", + "disposed", + "idle" + ], + "idleSeconds": 5, + "fullSoakGate": false, + "leakTracking": true, + "trackingOverhead": "Both A/B processes capture allocation stacks; additional allocation and GC overhead is included in observed memory.", + "nativeReferences": { + "Opc.Ua.Client.dll": "620da406fabdbc75d11f9827ea7e39d2d3c762471bd2264b0d11f1d6cd9247cd", + "Opc.Ua.Configuration.dll": "6ed6a594c93c3ff35d1a16539a9fe87c1e2640549cc2f48ed6bae333dc8bf0e7", + "Opc.Ua.Core.Schema.dll": "51cfad48657d95d38074bb88be29aea6f5b361a07397247d58838d066b99772e", + "Opc.Ua.Core.Types.dll": "a7dd141b8dabe8848ff28c2fb8e16a85a0508e6a49e6b1a1c1e267175b99af96", + "Opc.Ua.Core.dll": "9d0857aa6b7f3c49037d4ea557b4da8edf07e146069464481783e66c5c9cd35a", + "Opc.Ua.Security.Certificates.dll": "7f4969aceb2f0db5328c6882cb4b117342f3b18905c38acdba600683860c4fd0", + "Opc.Ua.Server.dll": "930e714a2f6e15d393840bcdc95e1afa8012bc740c3738a655f958142721864e", + "Opc.Ua.Types.dll": "c542f69882f4e8a4ebd5ccc28a3881d6967c7ffb6dcc3c56dc6c776f39c2ede1" + }, + "hostDotnet": ".NET SDK:\n Version: 10.0.403\n Commit: 5a2162bea6\n Workload version: 10.0.400-manifests.a00bebb6\n MSBuild version: 18.9.15+5a2162bea\n\nRuntime Environment:\n OS Name: Mac OS X\n OS Version: 26.5\n OS Platform: Darwin\n RID: osx-arm64\n Base Path: /opt/homebrew/Cellar/dotnet/10.0.403/libexec/sdk/10.0.403/\n\n.NET workloads installed:\nThere are no installed workloads to display.\nConfigured to use workload sets when installing new manifests.\nNo workload sets are installed. Run \"dotnet workload restore\" to install a workload set.\n\nHost:\n Version: 10.0.14\n Architecture: arm64\n Commit: 0427b9abbe\n\n.NET SDKs installed:\n 10.0.403 [/opt/homebrew/Cellar/dotnet/10.0.403/libexec/sdk]\n\n.NET runtimes installed:\n Microsoft.AspNetCore.App 10.0.14 [/opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.AspNetCore.App]\n Microsoft.NETCore.App 10.0.14 [/opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.NETCore.App]\n\nOther architectures found:\n None\n\nEnvironment variables:\n DOTNET_ROOT [/opt/homebrew/Cellar/dotnet/10.0.403/libexec]\n\nglobal.json file:\n /.codex/worktrees/opcua2-client-lifecycle/OtOpcUa/global.json\n\nLearn more:\n https://aka.ms/dotnet/info\n\nDownload .NET:\n https://aka.ms/dotnet/download\n", + "os": "ProductName:\t\tmacOS\nProductVersion:\t\t26.5.2\nBuildVersion:\t\t25F84\n", + "architecture": "arm64", + "cryptographyRuntime": { + "file": "/opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.NETCore.App/10.0.14/System.Security.Cryptography.dll", + "informationalVersion": "10.0.14+0427b9abbead3fa64c6655eb4290c537397131fa", + "sha256": "f4957d7218a004a27bf8964e45147df6a88092c6e7344582082840d62c6c579a" + } +} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/registry-samples.jsonl b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/registry-samples.jsonl new file mode 100644 index 00000000..15967f76 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/registry-samples.jsonl @@ -0,0 +1,6 @@ +{"kind":"checkpoint","mode":"registry","stage":"ready","operations":0,"processId":55851,"utc":"2026-11-10T21:30:30.043224+00:00","managerDisposed":false,"managedBytes":831128,"rssBytes":87654400,"privateBytes":0,"allocatedBytes":1761744,"compiledMethods":1192,"gc":[2,2,2],"certificateCoresCreated":7,"certificateCoresDisposed":6,"certificateCoresOutstanding":1,"runtimeTrackedTemporaryKeychains":1} +{"kind":"checkpoint","mode":"registry","stage":"first","operations":1,"processId":55851,"utc":"2026-11-10T21:30:31.093095+00:00","managerDisposed":false,"managedBytes":1055944,"rssBytes":91095040,"privateBytes":0,"allocatedBytes":2051888,"compiledMethods":1777,"gc":[4,4,4],"certificateCoresCreated":7,"certificateCoresDisposed":6,"certificateCoresOutstanding":1,"runtimeTrackedTemporaryKeychains":1} +{"kind":"checkpoint","mode":"registry","stage":"repeated","operations":200,"processId":55851,"utc":"2026-11-10T21:30:32.143169+00:00","managerDisposed":false,"managedBytes":1098664,"rssBytes":97779712,"privateBytes":0,"allocatedBytes":7937936,"compiledMethods":2093,"gc":[6,6,6],"certificateCoresCreated":7,"certificateCoresDisposed":6,"certificateCoresOutstanding":1,"runtimeTrackedTemporaryKeychains":1} +{"kind":"checkpoint","mode":"registry","stage":"disposed","operations":200,"processId":55851,"utc":"2026-11-10T21:30:33.170061+00:00","managerDisposed":true,"managedBytes":1104720,"rssBytes":97927168,"privateBytes":0,"allocatedBytes":7949840,"compiledMethods":2207,"gc":[8,8,8],"certificateCoresCreated":7,"certificateCoresDisposed":7,"certificateCoresOutstanding":0,"runtimeTrackedTemporaryKeychains":0} +{"kind":"checkpoint","mode":"registry","stage":"idle","operations":200,"processId":55851,"utc":"2026-11-10T21:30:39.166632+00:00","managerDisposed":true,"managedBytes":1130616,"rssBytes":97927168,"privateBytes":0,"allocatedBytes":7966976,"compiledMethods":2251,"gc":[10,10,10],"certificateCoresCreated":7,"certificateCoresDisposed":7,"certificateCoresOutstanding":0,"runtimeTrackedTemporaryKeychains":0} +{"kind":"complete","mode":"registry","operations":200,"sameLeafSha256":"fadcc11ec694732b82fe4dc47bb41bce6023d113339e53aca9fee5b079f200b2","certificateUnchanged":true,"managerDisposed":true,"certificateCoresOutstanding":0,"fullSoakGate":false} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/result.json b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/result.json new file mode 100644 index 00000000..036a8288 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/result.json @@ -0,0 +1,366 @@ +{ + "status": "passed", + "seed": { + "kind": "seed", + "leafSha256": "fadcc11ec694732b82fe4dc47bb41bce6023d113339e53aca9fee5b079f200b2", + "runtime": "10.0.14", + "identifierPublicProperties": [ + "BinaryEncodingId", + "CertificateType", + "CertificateTypeString", + "RawData", + "StorePath", + "StoreType", + "SubjectName", + "Thumbprint", + "TypeId", + "ValidationOptions", + "XmlEncodingId" + ], + "identifierPublicConstructors": [ + "Void .ctor()" + ], + "cryptographyRuntime": { + "file": "/opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.NETCore.App/10.0.14/System.Security.Cryptography.dll", + "informationalVersion": "10.0.14+0427b9abbead3fa64c6655eb4290c537397131fa", + "sha256": "f4957d7218a004a27bf8964e45147df6a88092c6e7344582082840d62c6c579a" + } + }, + "arms": { + "store": { + "exitCode": 0, + "complete": { + "kind": "complete", + "mode": "store", + "operations": 200, + "sameLeafSha256": "fadcc11ec694732b82fe4dc47bb41bce6023d113339e53aca9fee5b079f200b2", + "certificateUnchanged": true, + "managerDisposed": true, + "certificateCoresOutstanding": 0, + "fullSoakGate": false + }, + "samples": [ + { + "kind": "checkpoint", + "mode": "store", + "stage": "ready", + "operations": 0, + "processId": 54422, + "utc": "2026-11-10T21:30:07.200103+00:00", + "managerDisposed": false, + "managedBytes": 831080, + "rssBytes": 87621632, + "privateBytes": 0, + "allocatedBytes": 1786744, + "compiledMethods": 1192, + "gc": [ + 2, + 2, + 2 + ], + "certificateCoresCreated": 7, + "certificateCoresDisposed": 6, + "certificateCoresOutstanding": 1, + "runtimeTrackedTemporaryKeychains": 1, + "AppleDatabaseCount": 3, + "AppleDatabaseShallowBytes": 2304, + "fdCount": 110, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 1 + }, + { + "kind": "checkpoint", + "mode": "store", + "stage": "first", + "operations": 1, + "processId": 54422, + "utc": "2026-11-10T21:30:10.186803+00:00", + "managerDisposed": false, + "managedBytes": 1112360, + "rssBytes": 91619328, + "privateBytes": 0, + "allocatedBytes": 2248768, + "compiledMethods": 2073, + "gc": [ + 4, + 4, + 4 + ], + "certificateCoresCreated": 9, + "certificateCoresDisposed": 8, + "certificateCoresOutstanding": 1, + "runtimeTrackedTemporaryKeychains": 1, + "AppleDatabaseCount": 4, + "AppleDatabaseShallowBytes": 3072, + "fdCount": 110, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 1 + }, + { + "kind": "checkpoint", + "mode": "store", + "stage": "repeated", + "operations": 200, + "processId": 54422, + "utc": "2026-11-10T21:30:21.723766+00:00", + "managerDisposed": false, + "managedBytes": 1191400, + "rssBytes": 115097600, + "privateBytes": 0, + "allocatedBytes": 31247912, + "compiledMethods": 4552, + "gc": [ + 9, + 6, + 6 + ], + "certificateCoresCreated": 407, + "certificateCoresDisposed": 406, + "certificateCoresOutstanding": 1, + "runtimeTrackedTemporaryKeychains": 1, + "AppleDatabaseCount": 203, + "AppleDatabaseShallowBytes": 155904, + "fdCount": 112, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 1 + }, + { + "kind": "checkpoint", + "mode": "store", + "stage": "disposed", + "operations": 200, + "processId": 54422, + "utc": "2026-11-10T21:30:22.710567+00:00", + "managerDisposed": true, + "managedBytes": 1196408, + "rssBytes": 115212288, + "privateBytes": 0, + "allocatedBytes": 31265712, + "compiledMethods": 4618, + "gc": [ + 11, + 8, + 8 + ], + "certificateCoresCreated": 407, + "certificateCoresDisposed": 407, + "certificateCoresOutstanding": 0, + "runtimeTrackedTemporaryKeychains": 0, + "AppleDatabaseCount": 203, + "AppleDatabaseShallowBytes": 155904, + "fdCount": 112, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 0 + }, + { + "kind": "checkpoint", + "mode": "store", + "stage": "idle", + "operations": 200, + "processId": 54422, + "utc": "2026-11-10T21:30:28.704218+00:00", + "managerDisposed": true, + "managedBytes": 1222192, + "rssBytes": 115130368, + "privateBytes": 0, + "allocatedBytes": 31283272, + "compiledMethods": 4638, + "gc": [ + 13, + 10, + 10 + ], + "certificateCoresCreated": 407, + "certificateCoresDisposed": 407, + "certificateCoresOutstanding": 0, + "runtimeTrackedTemporaryKeychains": 0, + "AppleDatabaseCount": 203, + "AppleDatabaseShallowBytes": 155904, + "fdCount": 112, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 0 + } + ], + "nativeCountIsNotOpenHandleOrExclusiveRssAttribution": true, + "registryArmIsNotApprovedProductionMitigation": true + }, + "registry": { + "exitCode": 0, + "complete": { + "kind": "complete", + "mode": "registry", + "operations": 200, + "sameLeafSha256": "fadcc11ec694732b82fe4dc47bb41bce6023d113339e53aca9fee5b079f200b2", + "certificateUnchanged": true, + "managerDisposed": true, + "certificateCoresOutstanding": 0, + "fullSoakGate": false + }, + "samples": [ + { + "kind": "checkpoint", + "mode": "registry", + "stage": "ready", + "operations": 0, + "processId": 55851, + "utc": "2026-11-10T21:30:30.043224+00:00", + "managerDisposed": false, + "managedBytes": 831128, + "rssBytes": 87654400, + "privateBytes": 0, + "allocatedBytes": 1761744, + "compiledMethods": 1192, + "gc": [ + 2, + 2, + 2 + ], + "certificateCoresCreated": 7, + "certificateCoresDisposed": 6, + "certificateCoresOutstanding": 1, + "runtimeTrackedTemporaryKeychains": 1, + "AppleDatabaseCount": 3, + "AppleDatabaseShallowBytes": 2304, + "fdCount": 110, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 1 + }, + { + "kind": "checkpoint", + "mode": "registry", + "stage": "first", + "operations": 1, + "processId": 55851, + "utc": "2026-11-10T21:30:31.093095+00:00", + "managerDisposed": false, + "managedBytes": 1055944, + "rssBytes": 91095040, + "privateBytes": 0, + "allocatedBytes": 2051888, + "compiledMethods": 1777, + "gc": [ + 4, + 4, + 4 + ], + "certificateCoresCreated": 7, + "certificateCoresDisposed": 6, + "certificateCoresOutstanding": 1, + "runtimeTrackedTemporaryKeychains": 1, + "AppleDatabaseCount": 3, + "AppleDatabaseShallowBytes": 2304, + "fdCount": 110, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 1 + }, + { + "kind": "checkpoint", + "mode": "registry", + "stage": "repeated", + "operations": 200, + "processId": 55851, + "utc": "2026-11-10T21:30:32.143169+00:00", + "managerDisposed": false, + "managedBytes": 1098664, + "rssBytes": 97779712, + "privateBytes": 0, + "allocatedBytes": 7937936, + "compiledMethods": 2093, + "gc": [ + 6, + 6, + 6 + ], + "certificateCoresCreated": 7, + "certificateCoresDisposed": 6, + "certificateCoresOutstanding": 1, + "runtimeTrackedTemporaryKeychains": 1, + "AppleDatabaseCount": 3, + "AppleDatabaseShallowBytes": 2304, + "fdCount": 112, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 1 + }, + { + "kind": "checkpoint", + "mode": "registry", + "stage": "disposed", + "operations": 200, + "processId": 55851, + "utc": "2026-11-10T21:30:33.170061+00:00", + "managerDisposed": true, + "managedBytes": 1104720, + "rssBytes": 97927168, + "privateBytes": 0, + "allocatedBytes": 7949840, + "compiledMethods": 2207, + "gc": [ + 8, + 8, + 8 + ], + "certificateCoresCreated": 7, + "certificateCoresDisposed": 7, + "certificateCoresOutstanding": 0, + "runtimeTrackedTemporaryKeychains": 0, + "AppleDatabaseCount": 3, + "AppleDatabaseShallowBytes": 2304, + "fdCount": 112, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 0 + }, + { + "kind": "checkpoint", + "mode": "registry", + "stage": "idle", + "operations": 200, + "processId": 55851, + "utc": "2026-11-10T21:30:39.166632+00:00", + "managerDisposed": true, + "managedBytes": 1130616, + "rssBytes": 97927168, + "privateBytes": 0, + "allocatedBytes": 7966976, + "compiledMethods": 2251, + "gc": [ + 10, + 10, + 10 + ], + "certificateCoresCreated": 7, + "certificateCoresDisposed": 7, + "certificateCoresOutstanding": 0, + "runtimeTrackedTemporaryKeychains": 0, + "AppleDatabaseCount": 3, + "AppleDatabaseShallowBytes": 2304, + "fdCount": 112, + "pkiFdCount": 0, + "temporaryDirectoryFdCount": 1, + "temporaryKeychainFdCount": 0, + "temporaryKeychainFileCount": 0 + } + ], + "nativeCountIsNotOpenHandleOrExclusiveRssAttribution": true, + "registryArmIsNotApprovedProductionMitigation": true + } + }, + "fullSoakGate": false +} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/seed-api-inventory.jsonl b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/seed-api-inventory.jsonl new file mode 100644 index 00000000..27729546 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/seed-api-inventory.jsonl @@ -0,0 +1 @@ +{"kind":"seed","leafSha256":"fadcc11ec694732b82fe4dc47bb41bce6023d113339e53aca9fee5b079f200b2","runtime":"10.0.14","identifierPublicProperties":["BinaryEncodingId","CertificateType","CertificateTypeString","RawData","StorePath","StoreType","SubjectName","Thumbprint","TypeId","ValidationOptions","XmlEncodingId"],"identifierPublicConstructors":["Void .ctor()"],"cryptographyRuntime":{"file":"/opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.NETCore.App/10.0.14/System.Security.Cryptography.dll","informationalVersion":"10.0.14\u002B0427b9abbead3fa64c6655eb4290c537397131fa","sha256":"f4957d7218a004a27bf8964e45147df6a88092c6e7344582082840d62c6c579a"}} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/source-map.json b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/source-map.json new file mode 100644 index 00000000..cac33d99 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/source-map.json @@ -0,0 +1,54 @@ +{ + "sdkCommit": "07e62f72500cc0923a2bab940653c8c1116fa286", + "sdkCheckoutDirty": "", + "files": [ + { + "path": "src/Opc.Ua.Client/Session/DefaultSessionFactory.cs", + "sha256": "366481e7f75fdaae48e2b35574a1624c685b2ae7f004c1c0204068465a737e3c" + }, + { + "path": "src/Opc.Ua.Client/Session/Session.cs", + "sha256": "2af9a217a37ec35c99244983a0896277fbec616f4656e68ce9be03356b3a3f20" + }, + { + "path": "src/Opc.Ua.Core/Security/Certificates/CertificateIdentifierResolver.cs", + "sha256": "2593c29171e83f8536c2923c9f8a394fadfc738a78532743dd7c1d07c6e7915f" + }, + { + "path": "src/Opc.Ua.Core/Security/Certificates/DirectoryCertificateStore.cs", + "sha256": "490b75486ef3278ae6400b1ff47437b0557254ce0d0eff29ff9ebe2f29d50650" + }, + { + "path": "src/Opc.Ua.Core/Security/Certificates/SecurityConfiguration.cs", + "sha256": "76222952f07c4cf97326132103684ede51bea46c43dae5f7ca1228f62255ae43" + }, + { + "path": "src/Opc.Ua.Core/Schema/ApplicationConfiguration.cs", + "sha256": "0204d16915e317360f922801b59da7be03dafefe11e68672926b25e9e349db22" + }, + { + "path": "src/Opc.Ua.Core/Stack/Configuration/ApplicationConfiguration.cs", + "sha256": "119b5c809154291451cfe6e4ae7b4a0c2b838e12f28231c1ac4a876a29e3efc6" + }, + { + "path": "src/Opc.Ua.Core/Stack/Server/ServerBase.cs", + "sha256": "b5ba516728998b421b2573316deafbbe774792af99a10558ed0843505b5d3a09" + }, + { + "path": "src/Opc.Ua.Core/Security/Certificates/CertificateManager/CertificateManager.cs", + "sha256": "e63a56b4e2cf1183e73737da925ee5f7da3d7c291ecaea361d9ddc6e58ea1695" + }, + { + "path": "src/Opc.Ua.Configuration/ApplicationInstance.cs", + "sha256": "b6186e78effbc2301725914098cde8bd948ddce3147ce756b703fd76db1d32b3" + }, + { + "path": "src/Opc.Ua.Security.Certificates/X509Certificate/Certificate.cs", + "sha256": "6f0be22e80e05607d1c3e35ce0fe218273446c397c18df909124be61ee437f56" + }, + { + "path": "tests/Opc.Ua.Client.Tests/Session/SessionCertificateRotationRegressionTests.cs", + "sha256": "ad904d541ee2f0a08af44ba1ad58eda3db1be19305c3bab43f476fe2c0d71ae6" + } + ] +} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/store-samples.jsonl b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/store-samples.jsonl new file mode 100644 index 00000000..bdd0898d --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/store-samples.jsonl @@ -0,0 +1,6 @@ +{"kind":"checkpoint","mode":"store","stage":"ready","operations":0,"processId":54422,"utc":"2026-11-10T21:30:07.200103+00:00","managerDisposed":false,"managedBytes":831080,"rssBytes":87621632,"privateBytes":0,"allocatedBytes":1786744,"compiledMethods":1192,"gc":[2,2,2],"certificateCoresCreated":7,"certificateCoresDisposed":6,"certificateCoresOutstanding":1,"runtimeTrackedTemporaryKeychains":1} +{"kind":"checkpoint","mode":"store","stage":"first","operations":1,"processId":54422,"utc":"2026-11-10T21:30:10.186803+00:00","managerDisposed":false,"managedBytes":1112360,"rssBytes":91619328,"privateBytes":0,"allocatedBytes":2248768,"compiledMethods":2073,"gc":[4,4,4],"certificateCoresCreated":9,"certificateCoresDisposed":8,"certificateCoresOutstanding":1,"runtimeTrackedTemporaryKeychains":1} +{"kind":"checkpoint","mode":"store","stage":"repeated","operations":200,"processId":54422,"utc":"2026-11-10T21:30:21.723766+00:00","managerDisposed":false,"managedBytes":1191400,"rssBytes":115097600,"privateBytes":0,"allocatedBytes":31247912,"compiledMethods":4552,"gc":[9,6,6],"certificateCoresCreated":407,"certificateCoresDisposed":406,"certificateCoresOutstanding":1,"runtimeTrackedTemporaryKeychains":1} +{"kind":"checkpoint","mode":"store","stage":"disposed","operations":200,"processId":54422,"utc":"2026-11-10T21:30:22.710567+00:00","managerDisposed":true,"managedBytes":1196408,"rssBytes":115212288,"privateBytes":0,"allocatedBytes":31265712,"compiledMethods":4618,"gc":[11,8,8],"certificateCoresCreated":407,"certificateCoresDisposed":407,"certificateCoresOutstanding":0,"runtimeTrackedTemporaryKeychains":0} +{"kind":"checkpoint","mode":"store","stage":"idle","operations":200,"processId":54422,"utc":"2026-11-10T21:30:28.704218+00:00","managerDisposed":true,"managedBytes":1222192,"rssBytes":115130368,"privateBytes":0,"allocatedBytes":31283272,"compiledMethods":4638,"gc":[13,10,10],"certificateCoresCreated":407,"certificateCoresDisposed":407,"certificateCoresOutstanding":0,"runtimeTrackedTemporaryKeychains":0} +{"kind":"complete","mode":"store","operations":200,"sameLeafSha256":"fadcc11ec694732b82fe4dc47bb41bce6023d113339e53aca9fee5b079f200b2","certificateUnchanged":true,"managerDisposed":true,"certificateCoresOutstanding":0,"fullSoakGate":false} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/verification.json b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/verification.json new file mode 100644 index 00000000..9f2973c7 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/evidence/2026-11-10/verification.json @@ -0,0 +1,71 @@ +{ + "purpose": "attribution-only, not a production mitigation or full soak", + "controlRunsPerArm": 1, + "sourceCommit": "ed60970fc294ab171a80cda1b57942a7130b4c39", + "sdkSourceCommit": "07e62f72500cc0923a2bab940653c8c1116fa286", + "rawArtifacts": "/private/tmp/opcua2-native-reload-20261110a", + "artifactCreationToFinalResultWriteWallSeconds": 34.69896697998047, + "wallIntervalIsFilesystemBasedNotMonotonicGate": true, + "sdkFileHashes": { + "Opc.Ua.Client.dll": "620da406fabdbc75d11f9827ea7e39d2d3c762471bd2264b0d11f1d6cd9247cd", + "Opc.Ua.Configuration.dll": "6ed6a594c93c3ff35d1a16539a9fe87c1e2640549cc2f48ed6bae333dc8bf0e7", + "Opc.Ua.Core.Schema.dll": "51cfad48657d95d38074bb88be29aea6f5b361a07397247d58838d066b99772e", + "Opc.Ua.Core.Types.dll": "a7dd141b8dabe8848ff28c2fb8e16a85a0508e6a49e6b1a1c1e267175b99af96", + "Opc.Ua.Core.dll": "9d0857aa6b7f3c49037d4ea557b4da8edf07e146069464481783e66c5c9cd35a", + "Opc.Ua.Security.Certificates.dll": "7f4969aceb2f0db5328c6882cb4b117342f3b18905c38acdba600683860c4fd0", + "Opc.Ua.Server.dll": "930e714a2f6e15d393840bcdc95e1afa8012bc740c3738a655f958142721864e", + "Opc.Ua.Types.dll": "c542f69882f4e8a4ebd5ccc28a3881d6967c7ffb6dcc3c56dc6c776f39c2ede1" + }, + "installedCryptography": { + "file": "/opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.NETCore.App/10.0.14/System.Security.Cryptography.dll", + "informationalVersion": "10.0.14+0427b9abbead3fa64c6655eb4290c537397131fa", + "sha256": "f4957d7218a004a27bf8964e45147df6a88092c6e7344582082840d62c6c579a" + }, + "checkpointIntervals": { + "store": { + "readyToIdleWallSeconds": 21.504115, + "readyUtc": "2026-11-10T21:30:07.200103+00:00", + "idleUtc": "2026-11-10T21:30:28.704218+00:00" + }, + "registry": { + "readyToIdleWallSeconds": 9.123408, + "readyUtc": "2026-11-10T21:30:30.043224+00:00", + "idleUtc": "2026-11-10T21:30:39.166632+00:00" + } + }, + "installedRuntimeDecompile": { + "temporaryKeychainHandle": { + "localPath": "/tmp/opcua2-native-runtime-keychain-decompiled.cs", + "sha256": "96c2e996638ffdd17c35483aaece7fdbc499fd004ab549e2e128bf82a64b5dc2", + "tool": "ilspycmd 10.1.0.8386", + "readOnly": true + }, + "certificateLoader": { + "localPath": "/tmp/opcua2-native-runtime-loader-decompiled.cs", + "sha256": "89434500d1723f419e83ff45fcb6e44e73b58847f4201265613587ad82a79424", + "tool": "ilspycmd 10.1.0.8386", + "readOnly": true + }, + "baseKeychainHandle": { + "localPath": "/tmp/opcua2-native-runtime-keychain-base-decompiled.cs", + "sha256": "6f2abdb5779646a9bfd589d8fc765cab2e0290edee8396b2c7b2e6a4470e2bb4", + "tool": "ilspycmd 10.1.0.8386", + "readOnly": true + } + }, + "review": { + "reviewer": "Astra independent security/ownership gate", + "preExecutionCodeApproved": true, + "postExecutionRawHeapAndHashReviewAccepted": true, + "productionMitigationApproved": false, + "scope": "one fixed 200-acquisition run per arm; no public upstream post or production patch" + }, + "rawProvenanceSha256": "2bd55b5eddef09f7e4e2fcaab91c98649e62abc084565f88755ceba9eed29bfb", + "selectedProvenanceProjection": { + "redactedFields": [ + "hostDotnet local home prefix" + ], + "sha256": "36d3820fc3a858f7fc39f35237475fe2a4897a6a38ceee19efbadf9426c6dcd4", + "rawReceiptUnchanged": true + } +} diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/run.py b/scripts/validation/opcua-lifecycle-memory/native-retention/run.py new file mode 100644 index 00000000..2581e94d --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/run.py @@ -0,0 +1,168 @@ +#!/usr/bin/env python3 +"""Bounded attribution-only macOS SDK private-key reload/registry controls.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys +import time +import xml.etree.ElementTree as ET + +HERE = Path(__file__).resolve().parent +STAGES = ['ready', 'first', 'repeated', 'disposed', 'idle'] + + +def save(path, value): + path.write_text(json.dumps(value, indent=2) + '\n') + + +def snapshots(pid, root, stage): + # These raw, no-content inventory files remain private/local and are not release artifacts. + heap = subprocess.run(['/usr/bin/heap', '--noContent', '-s', str(pid)], text=True, stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, timeout=20, check=True) + (root / ('heap-' + stage + '.txt')).write_text(heap.stdout) + match = re.search(r'^\s*(\d+)\s+(\d+)\s+[\d.]+\s+Security::AppleDatabase\s', heap.stdout, re.MULTILINE) + if not match: + raise RuntimeError('AppleDatabase inventory class missing; diagnostic cannot silently report zero') + descriptors = subprocess.run(['/usr/sbin/lsof', '-a', '-p', str(pid), '-Fnf'], text=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=5, check=True) + (root / ('lsof-' + stage + '.txt')).write_text(descriptors.stdout) + total = pki = temporary = keychain = 0 + in_descriptor = False + pki_root = (root / 'pki').resolve() + tmp_root = (root / 'tmp').resolve() + for line in descriptors.stdout.splitlines(): + if line.startswith('f'): + in_descriptor = len(line) > 1 and line[1].isdigit() + total += int(in_descriptor) + elif in_descriptor and line.startswith('n/'): + target = Path(line[1:].removesuffix(' (deleted)')).resolve() + pki += int(target == pki_root or pki_root in target.parents) + in_temp = target == tmp_root or tmp_root in target.parents + temporary += int(in_temp) + keychain += int(in_temp and '.keychain' in target.name) + if pki: + raise RuntimeError('Quiescent task-owned PKI descriptors are open') + if stage in ['disposed', 'idle'] and keychain: + raise RuntimeError('Owned temporary keychain descriptors remain after application disposal') + return {'stage': stage, 'AppleDatabaseCount': int(match[1]), 'AppleDatabaseShallowBytes': int(match[2]), + 'fdCount': total, 'pkiFdCount': pki, 'temporaryDirectoryFdCount': temporary, + 'temporaryKeychainFdCount': keychain, + 'temporaryKeychainFileCount': sum(p.is_file() and '.keychain' in p.name for p in (root / 'tmp').rglob('*'))} + + +def arm(app, root, mode): + (root / 'tmp').mkdir() + (root / 'data').mkdir() + env = dict(os.environ, OPCUA_CERTIFICATE_LEAK_TRACKING='1', TMPDIR=str(root / 'tmp'), XDG_DATA_HOME=str(root / 'data')) + with (root / 'samples.jsonl').open('w') as log: + process = subprocess.Popen(['dotnet', str(app), mode, str(root)], stdout=log, stderr=subprocess.STDOUT, env=env) + try: + deadline = time.monotonic() + 180 + values = [] + for stage in STAGES: + while True: + text = (root / 'samples.jsonl').read_text() + rows = [json.loads(line) for line in text.split('\n')[:-1]] + if any(row.get('kind') == 'failed' for row in rows): + raise RuntimeError('Control failed; see samples.jsonl') + matching = [row for row in rows if row.get('stage') == stage] + if matching: + values.append({**matching[0], **snapshots(process.pid, root, stage)}) + (root / ('continue-' + stage)).touch() + break + if process.poll() is not None or time.monotonic() >= deadline: + raise RuntimeError('Control checkpoint did not complete within bound: ' + stage) + time.sleep(.05) + status = process.wait(timeout=15) + rows = [json.loads(line) for line in (root / 'samples.jsonl').read_text().splitlines()] + complete = [row for row in rows if row.get('kind') == 'complete'] + if status or len(complete) != 1 or complete[0]['operations'] != 200: + raise RuntimeError('Control failed or acquisition count changed') + result = {'exitCode': status, 'complete': complete[0], 'samples': values, + 'nativeCountIsNotOpenHandleOrExclusiveRssAttribution': True, + 'registryArmIsNotApprovedProductionMitigation': True} + save(root / 'result.json', result) + return result + finally: + if process.poll() is None: + process.terminate() + try: + process.wait(timeout=10) + except subprocess.TimeoutExpired: + process.kill() + process.wait(timeout=5) + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--references', required=True, type=Path, help='Actual immutable published SDK2 DLL directory') + parser.add_argument('--output', required=True, type=Path, help='Fresh private output; contains PKI and native snapshots') + options = parser.parse_args() + if sys.platform != 'darwin': + parser.error('This native attribution control requires macOS') + out = options.output.resolve() + out.mkdir(parents=True, exist_ok=False) + os.chmod(out, 0o700) + sdk = sorted(options.references.resolve().glob('Opc.Ua*.dll')) + if not sdk: + raise RuntimeError('Missing actual SDK assemblies') + project = ET.Element('Project', Sdk='Microsoft.NET.Sdk') + props = ET.SubElement(project, 'PropertyGroup') + for k, v in {'OutputType': 'Exe', 'TargetFramework': 'net10.0', 'ImplicitUsings': 'enable', 'Nullable': 'enable', + 'EnableDefaultCompileItems': 'false', 'TreatWarningsAsErrors': 'true'}.items(): + ET.SubElement(props, k).text = v + items = ET.SubElement(project, 'ItemGroup') + ET.SubElement(items, 'FrameworkReference', Include='Microsoft.AspNetCore.App') + ET.SubElement(items, 'Compile', Include=str(HERE / 'ReloadControl.cs')) + for dll in sdk: + ref = ET.SubElement(items, 'Reference', Include=dll.stem) + ET.SubElement(ref, 'HintPath').text = str(dll) + ET.indent(project) + ET.ElementTree(project).write(out / 'Control.csproj', encoding='unicode') + sha = lambda p: hashlib.sha256(p.read_bytes()).hexdigest() + provenance = {'controlSourceSha256': sha(HERE / 'ReloadControl.cs'), 'runnerSourceSha256': sha(HERE / 'run.py'), + 'repetitionsPerArm': 200, 'stages': STAGES, 'idleSeconds': 5, 'fullSoakGate': False, + 'leakTracking': True, 'trackingOverhead': 'Both A/B processes capture allocation stacks; additional allocation and GC overhead is included in observed memory.', + 'nativeReferences': {p.name: sha(p) for p in sdk}, 'hostDotnet': subprocess.check_output(['dotnet', '--info'], text=True), + 'os': subprocess.check_output(['sw_vers'], text=True), 'architecture': os.uname().machine} + save(out / 'provenance.json', provenance) + with (out / 'build.log').open('w') as log: + subprocess.run(['dotnet', 'build', str(out / 'Control.csproj'), '-c', 'Release', '-m:1', '--nologo'], + stdout=log, stderr=subprocess.STDOUT, timeout=120, check=True) + app = out / 'bin/Release/net10.0/Control.dll' + seed = out / 'seed' + seed.mkdir() + (seed / 'tmp').mkdir() + (seed / 'data').mkdir() + results = {} + try: + with (seed / 'seed.jsonl').open('w') as log: + subprocess.run(['dotnet', str(app), 'seed', str(seed)], stdout=log, stderr=subprocess.STDOUT, timeout=60, check=True, + env=dict(os.environ, TMPDIR=str(seed / 'tmp'), XDG_DATA_HOME=str(seed / 'data'))) + seed_rows = [json.loads(x) for x in (seed / 'seed.jsonl').read_text().splitlines()] + assert len(seed_rows) == 1 and seed_rows[0]['kind'] == 'seed' + provenance['cryptographyRuntime'] = seed_rows[0]['cryptographyRuntime'] + save(out / 'provenance.json', provenance) + for mode in ['store', 'registry']: + root = out / mode + root.mkdir() + shutil.copytree(seed / 'pki', root / 'pki') + results[mode] = arm(app, root, mode) + assert results['store']['complete']['sameLeafSha256'] == results['registry']['complete']['sameLeafSha256'] == seed_rows[0]['leafSha256'] + save(out / 'result.json', {'status': 'passed', 'seed': seed_rows[0], 'arms': results, 'fullSoakGate': False}) + finally: + for root in [seed, out / 'store', out / 'registry']: + for name in ['pki', 'tmp', 'data']: + if (root / name).exists(): + shutil.rmtree(root / name) + save(out / 'fixture-cleanup.json', {'allOwnedFixtureDirectoriesRemoved': all(not (p / name).exists() for p in [seed, out / 'store', out / 'registry'] for name in ['pki', 'tmp', 'data'])}) + print(out) + + +if __name__ == '__main__': + main()