docs: prepare private native retention handoff

This commit is contained in:
Joseph Doherty
2026-11-10 17:58:27 -05:00
parent d8a6959bcf
commit 6591cf1a05
@@ -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.