diff --git a/scripts/validation/opcua-lifecycle-memory/native-retention/PRIVATE_UPSTREAM_HANDOFF.md b/scripts/validation/opcua-lifecycle-memory/native-retention/PRIVATE_UPSTREAM_HANDOFF.md new file mode 100644 index 000000000..8586308d3 --- /dev/null +++ b/scripts/validation/opcua-lifecycle-memory/native-retention/PRIVATE_UPSTREAM_HANDOFF.md @@ -0,0 +1,115 @@ +# Private handoff: macOS AppleDatabase retention after exportable PFX imports + +Private draft for upstream triage; nothing has been posted. On the tested macOS +build, 1,000 exportable RSA private-key imports added 1,000 +`Security::AppleDatabase` heap objects despite disposal. A measured C child +reproduces the growth through the installed .NET Apple crypto bridge +without CLR or SDK loaded. Changing the timing of native keychain delete and +initial release does not prevent it. The retained C++ owner and its share of RSS +remain unresolved; this is not proof of leaked open handles or a production fix. + +## Pinned environment + +- **macOS 26.5.2 (25F84), arm64**. The class inventory comes from Security.framework + via `heap --noContent -s`. Apple’s [public AppleDatabase source](https://github.com/apple-oss-distributions/Security/blob/db15acbe6a7f257a859ad9a3bb86097bfe0679d9/OSX/libsecurity_filedb/lib/AppleDatabase.h#L485) + gives class context and is not the installed framework source. +- **Homebrew .NET SDK 10.0.403 / runtime 10.0.14**, shared runtime under + `/opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.NETCore.App/10.0.14`. + `System.Security.Cryptography.dll`: informational version + `10.0.14+0427b9abbead3fa64c6655eb4290c537397131fa`, SHA-256 + `f4957d7218a004a27bf8964e45147df6a88092c6e7344582082840d62c6c579a`. + `libSystem.Security.Cryptography.Native.Apple.dylib`: SHA-256 + `0333994e217a3d111d36d7f31460eea69a286f9fc819e2d7c746df13d2aec46c`. + [Installed binary inspection](NATIVE_IMPORT_DESIGN.md) binds the actual ABI, + wrapper calls and disassembly. The public [.NET v10.0.14 release](https://github.com/dotnet/runtime/releases/tag/v10.0.14) + resolves to `6aa72daa24f66c01c3d7b6a32519e9d21396deec`; its + [native PAL](https://github.com/dotnet/runtime/blob/6aa72daa24f66c01c3d7b6a32519e9d21396deec/src/native/libs/System.Security.Cryptography.Native.Apple/pal_x509_macos.c) + is explanatory context, not asserted as the exact installed source commit. +- **OPC UA SDK 2.0.1**, assembly version 2.0.1.0, exact source + `07e62f72500cc0923a2bab940653c8c1116fa286`. + [Directory private-key loader at that commit](https://github.com/OPCFoundation/UA-.NETStandard/blob/07e62f72500cc0923a2bab940653c8c1116fa286/src/Opc.Ua.Core/Security/Certificates/DirectoryCertificateStore.cs#L758) + and [eight SDK binary hashes](evidence/2026-11-10/provenance.json) bind the + managed A/B and native fixture seed. The measured C processes did not load it. + +## Start with the plain PFX loop + +The recorded [single-owner PFX control](../CertificateControl.cs) creates a +fresh in-memory self-signed RSA certificate, exports PFX once, then imports and +disposes each of 1,000 copies with `X509KeyStorageFlags.Exportable`. The wrapper +owns the seed in that harness; the PFX loop itself only calls .NET. The +[existing runner and four modes](../README.md#reproduce-the-diagnostic-subset) +are reproducible from the pinned SDK references. Retained local raw snapshots: +`/tmp/opcua2-rss-investigation/pfx-control/single-owner/pfx/heap-{before,after}.txt`; +separate idle hold `/tmp/opcua2-rss-investigation/pfx-control/pfx-longhold/heap-{0,60,180}.txt`. + +The following extracts that API sequence without an OPC UA dependency. +This snippet has not been separately executed; the recorded counts below are +from the existing harness. Use a fresh private `TMPDIR`, a `net10.0` console app +with no additional packages and a bounded parent. Take `heap --noContent -s` +from a separate shell while the child is at each 15-second checkpoint. No user +or system keychain setter is needed. Reap the process before removing the +private directory. + +```csharp +using System.Security.Cryptography; +using System.Security.Cryptography.X509Certificates; + +using var rsa = RSA.Create(2048); +var request = new CertificateRequest("CN=NativeMemoryControl", rsa, + HashAlgorithmName.SHA256, RSASignaturePadding.Pkcs1); +using var original = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), + DateTimeOffset.UtcNow.AddDays(1)); +byte[] pfx = original.Export(X509ContentType.Pfx); +try +{ + Checkpoint("before"); + for (int i = 1; i <= 1000; ++i) + { + using (X509Certificate2 copy = X509CertificateLoader.LoadPkcs12( + pfx, (string?)null, X509KeyStorageFlags.Exportable)) { } + if (i % 100 == 0) Collect(); + } + Checkpoint("after"); +} +finally { CryptographicOperations.ZeroMemory(pfx); } + +static void Collect() { GC.Collect(); GC.WaitForPendingFinalizers(); } +static void Checkpoint(string stage) +{ + Collect(); + Console.WriteLine($"{stage} pid={Environment.ProcessId}"); + Thread.Sleep(TimeSpan.FromSeconds(15)); +} +``` + +## Recorded controls and the unresolved boundary + +| Control | AppleDatabase inventory | What it distinguishes | +|---|---:|---| +| Plain PFX or SDK private `AsX509Certificate2` copy, 1,000 each | 2 → 1,002 | Import/private-copy triggers. Public DER and shared-handle controls stayed at 2; separate PFX hold stayed at 1,002 for 180 seconds. | +| [SDK configured-store vs registry](README.md), 200 each | Store 3 → 203; registry 3 → 3 | Directory key reload reproduces growth despite observed SDK manager/core disposal, runtime lookup zero and owned files/FDs zero. Registry selection is a different update policy. | +| [Empty native keychain](PURE_APPLE.md), 200 | 1 → 1 | Plain create/delete/CFRelease without imported cert/key did not reproduce growth. | +| [Installed native bridge B](NATIVE_IMPORT.md), 200 | 1 → 2 → 201 → 201 | The DER/PKCS1 materialization, legacy import, move and identity copy sequence reproduces without managed caller/SDK; 200 deletes OSStatus 0, 2,402/2,402 returned CF owners, 3 FDs, no owned keychain files/FDs. | +| [Matched deletion timing pair](NATIVE_IMPORT_TIMING.md), 200 + 200 | Both 1 → 2 → 201 → 201 | Same seed/binary; delete at logical zero with five observed references vs after all other observed references with one. Both delete status and returned owner guards passed; the timing change did not prevent growth. | + +Sampled server CPU stacks point primarily to configured-directory private-key +loading, not the SDK's private-copy helper. The actual installed exportable +path uses managed PKCS12 decoding, then native key materialization/legacy key +import and move-to-keychain. The native control isolates the latter sequence; +it does not use `SecPKCS12Import` or claim to model managed finalizer timing. +Apple documents [separate release after keychain deletion](https://developer.apple.com/documentation/security/seckeychaindelete%28_%3A%29): +installed managed handle code already calls it, and both measured C orders +issued and balanced that pair. The controls do not observe internal native +references or establish native delete status in the separate managed A/B. + +The upstream question is which native owner retains the database objects after +a successful temporary-keychain delete and balanced returned owners, and +whether the supported exportable PFX path can release that inventory. The +installed bridge is still present in the positive native controls, so the data +do not separate bridge-native ownership from Security.framework caching or +claim a particular defect. [.NET documents that macOS private PFX loading +requires a keychain and does not support `EphemeralKeySet`](https://learn.microsoft.com/en-us/dotnet/standard/security/cross-platform-cryptography#read-a-pkcs12pfx-file). +No application-only fix preserving certificate selection, replacement, trust +and ownership semantics was found. These controls do not close #799 or waive +the product RSS gate. Share selected receipts only; private keys, raw native +heaps, user keychain paths and raw local logs are not part of this handoff.