Files
lmxopcua/scripts/validation/opcua-lifecycle-memory/native-retention/NATIVE_IMPORT_DESIGN.md
T
2026-11-10 17:31:50 -05:00

14 KiB
Raw Blame History

Native bridge import control — inspected design

Status: Astra approved the refined ABI/ownership design for implementation. The C child, separate seed and collector are implemented and submitted for actual-code review. The original assumptions below are retained to explain the exact attempt. Attempt A stopped at its first partial operation: the installed wrapper permits null item associations, contrary to the original fixed identity-association expectation. It produced no repeated retention result. The coordinator confirmed a single staged native-bridge control; a handwritten Apple import reproduction is deferred until the result shows whether it is useful. This is not a production routing/crypto change, a mitigation or an acceptance decision.

Installed binaries and retained inspection

Installed managed crypto: /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.

Installed native bridge: /opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.NETCore.App/10.0.14/libSystem.Security.Cryptography.Native.Apple.dylib. SHA-256 0333994e217a3d111d36d7f31460eea69a286f9fc819e2d7c746df13d2aec46c. Only this exact, unmodified dylib would be loaded by absolute path; required exports and actual loaded-image identity must match. No SDK/CLR may be loaded in the measured C child. Its native bridge dependency is explicitly part of this experiment, unlike the preceding plain-Apple negative control.

Retained actual-binary inspection files (read-only ILSpy 10.1.0.8386 / otool):

File Relevant locations
/tmp/opcua2-native-runtime-loader-decompiled.cs 836–870 load certs/keys/match, initialize import state and FromCertAndKey; 1555–1581 exportable branch; 1611–1640 keychain initialization and GetPrivateKey
/tmp/opcua2-native-runtime-pkcs12key-decompiled.cs AsymmetricAlgorithmPkcs12PrivateKey imports PKCS8 into its RSA algorithm
/tmp/opcua2-native-runtime-pkcs12key-base-decompiled.cs Dispose calls the algorithm's Dispose
/tmp/opcua2-native-runtime-rsa-decompiled.cs 95–108 ImportParameters/CreateDataKey/CopyPublicKey; 431–458 PKCS1 serialization to CreateDataKey; export uses native external representation
/tmp/opcua2-native-runtime-applepal-decompiled.cs 339–349 identity constructor/certificate copy and disposal; 555–564 MoveToKeychain
/tmp/opcua2-native-runtime-appleinterop-decompiled.cs PAL RSA enum 544–549; CreateDataKey/external representation/public copy 1982–2127; keychain create/never-lock 3054/3209/3481; legacy key import 3563/3596; cert import 3697/3944; move 3878/4036; identity cert copy 2629/4087
/tmp/opcua2-native-runtime-certhandle-decompiled.cs and identityhandle-decompiled.cs Both actual types inherit SafeKeychainItemHandle with no release override
/tmp/opcua2-native-runtime-keychainitem-decompiled.cs SafeKeychainItemHandle release: UntrackItem copy/release, then CFRelease; certificate and identity inherit this class
/tmp/opcua2-native-runtime-keypair-decompiled.cs Actual SecKeyPair Dispose: modern private-key owner before modern public-key owner
/tmp/opcua2-native-runtime-keyhandle-decompiled.cs SafeSecKeyRefHandle release: straight CFRelease, then optional managed parent release; no tracking copy
/tmp/opcua2-native-runtime-keychain-decompiled.cs TrackItem/UntrackItem each copy/release a keychain; matching dictionary only adds/releases managed SafeHandle ownership
/tmp/opcua2-native-runtime-x509contenttype-decompiled.cs Actual public Cert enum value 1; Pfx/Pkcs12 value 3
/tmp/opcua2-native-runtime-apple-disassembly.txt _AppleCryptoNative_SecKeyImportEphemeral, _AddKeyToKeychain, _AppleCryptoNative_X509MoveToKeychain, all required exports

The installed native call sites, not merely the public runtime tag, show:

  • SecKeyImportEphemeral at 0x92e8 calls SecItemImport at 0x9390, followed by CF ownership cleanup.
  • AddKeyToKeychain calls SecItemExport at 0xa0a8 and SecItemImport at 0xa11c, then retains an optional returned key and releases local containers.
  • X509MoveToKeychain at 0xa180 calls AddKeyToKeychain at 0xa240, SecCertificateAddToKeychain at 0xa278, and a private-search-list identity SecItemCopyMatching at 0xa388, with CF cleanup and one returned identity.

Public v10.0.14 native headers/source are explanatory cross-checks, not asserted as the exact installed source commit. In particular this Exportable path is managed PKCS12 decode / RSA materialization → legacy key import → move-to-keychain. A guessed SecPKCS12Import control would exercise a different path.

ABI and returned ownership map

All bridge integer return values below are signed 32-bit. The installed generated managed keychain-create declaration takes signed int utf8PassphraseLength; the explanatory public native header takes uint32. The control uses the same fixed positive bounded value 129, whose ABI representation is identical. Other byte lengths are signed 32-bit. Native pointers are opaque CF references and must never be printed. Every out location starts null/zero; each non-null owned output must be released once on success or failure. Borrowed CFData byte pointers are valid only while their owned CFData is held and are never separately freed. CFError is an owned output, recorded only by code and released once, without description/key logging.

Exact export Argument/return contract
AppleCryptoNative_X509ImportCertificate (uint8_t*, int32 len, int32 contentType=1, CFStringRef NULL, SecKeychainRef NULL, int32 exportable=1, SecCertificateRef*, SecIdentityRef*, int32 OSStatus*); bridge return 1 and OSStatus 0 required; DER must yield an owned cert and no identity
AppleCryptoNative_SecKeyCreateWithData (uint8_t*, int32 len, uint32 PAL_RSA=2, int32 isPublic=0, SecKeyRef*, CFErrorRef*); bridge return 1, owned modern RSA key
AppleCryptoNative_SecKeyCopyPublicKey (SecKeyRef) -> SecKeyRef; owned public-key copy, non-null required
AppleCryptoNative_SecKeyCopyExternalRepresentation (SecKeyRef, CFDataRef*, CFErrorRef*); bridge return 1, owned PKCS1 private-key data, no key bytes logged
AppleCryptoNative_SecKeychainCreate (const char*, int32 passwordLength=129 in installed managed ABI, const uint8_t*, SecKeychainRef*); OSStatus 0, owned fresh private keychain; same native false prompt flag
AppleCryptoNative_SetKeychainNeverLock (SecKeychainRef) -> OSStatus; 0 required; affects only the generated private keychain, matching installed import state
AppleCryptoNative_SecKeyImportEphemeral (uint8_t*, int32 len, int32 isPrivate=1, SecKeyRef*, int32 OSStatus*); bridge return 1 / OSStatus 0, owned legacy private key
AppleCryptoNative_X509MoveToKeychain (SecCertificateRef, SecKeychainRef, SecKeyRef, SecIdentityRef*, int32 OSStatus*); bridge return 1 / OSStatus 0 and owned non-null identity required; native helper scopes its identity query to target keychain and supplied cert
AppleCryptoNative_X509CopyCertFromIdentity (SecIdentityRef, SecCertificateRef*) -> OSStatus; 0 required, owned certificate copy
AppleCryptoNative_SecKeychainItemCopyKeychain (CFTypeRef item, SecKeychainRef*) -> int32; bridge-normalized OSStatus 0 required; non-null returned keychain is owned and CFReleased once. The installed bridge normalizes native -25304/-25294 to 0; null output is valid for an unassociated item. Preserve normalized status and output-present flag, not raw underlying status.
AppleCryptoNative_SecKeychainDelete (SecKeychainRef) -> OSStatus; recorded separately from subsequent CFRelease; only a returned keychain verified against this generated private path may be deleted

Matching native interactions from managed wrapper tracking

The child will include the bridge-normalized SecKeychainItemCopyKeychain calls made by installed wrappers, not merely the primary import/move exports. Every non-null copied keychain is released once. The map is:

  1. After DER import, TrackItem(cert): copy/release its associated keychain. The null identity output is skipped, as the managed TrackItem does.
  2. Around MoveToKeychain: acquire the outer cert-keychain copy and keep it alive across the entire move wrapper; separately copy/release cert's old keychain for UntrackItem; call Move; copy/release cert's new keychain and identity's keychain for the two TrackItem calls; then release the outer copy.
  3. After identity certificate copy: copy/release copied cert's keychain for TrackItem.
  4. Releasing initial cert, copied cert and identity: copy/release each item's keychain first (UntrackItem), then CFRelease the item. This preserves the installed PAL order: initial cert after the replacement PAL is constructed, then replacement cert before identity at disposal.
  5. Modern public/private and legacy private SafeSecKeyRefHandle release maps directly to CFRelease, with no keychain tracking native call. Its optional managed parent lifetime is not used by the inspected GetPrivateKey path.

No managed dictionary is recreated. A small logical owner counter preserves the known deterministic ownership/deletion order. It starts at one import-state owner. Successful TrackItem of an item associated with the generated keychain adds one; UntrackItem subtracts one. These logical increments are not CF retains. After the replacement PAL is constructed and the original cert is disposed, ImportState.Dispose relinquishes its initial logical ownership. The counter then contains only copied-cert and identity owners. Their Untrack calls subtract one before releasing each copied-keychain reference. The final identity Untrack reaches zero: it calls SecKeychainDelete and releases the initial native keychain owner while the copied keychain ref and identity are still alive, then releases the copied keychain ref, then CFReleases the identity. This matches the installed SafeHandle delayed-release ordering; deliberately postponing deletion to after all item CFReleases would be a different reduced sequence. The legacy private key is released explicitly after this sequence (the managed helper lacks an explicit local using scope); exact managed finalizer timing is not emulated. The installed bridge at 0x8a04 checks item type, calls the public Apple copy API, and normalizes -25304/-25294 to success. The control calls that pinned export rather than interpreting a raw public return differently. Any other returned status fails the operation but is retained in the receipt.

Proposed one-child sequence and boundaries

Prepare one fresh normal SDK-generated RSA fixture in a separate seed process, not in the measured child. Export its public DER certificate and RSA PKCS1 private key through public APIs into a private directory with files mode 0600. Preserve only public identity/source/binary hashes, never private bytes/passwords. The seed and measured child must be reaped before their key material is overwritten and unlinked; cleanup is confined to their fresh owned roots. No existing PKI is used.

One plain C child would then repeat exactly 200 times, without count/retry extension:

  1. Import public DER through the pinned bridge. Materialize the modern RSA key from PKCS1 through CreateWithData and obtain the owned public-key copy, matching the inspected RSA loader prelude without reproducing managed ASN1/PFX matching.
  2. Create one generated unrelated private keychain path and set its never-lock behavior through the pinned bridge. Use generated password bytes and no prompt; verify its returned path is exactly the generated private filename/backing file.
  3. Export modern private-key representation, import the legacy ephemeral key, then release exported CFData and modern RSA private/public owners in that order, as the managed Pkcs12Key is disposed before MoveToKeychain.
  4. Move certificate/key to that private keychain; require a typed identity. Copy its certificate as the AppleCertificatePal identity constructor does; release the original certificate. At first/last only, check returned public certificate identity against the input; no secret/private-key logging.
  5. Relinquish the initial import-state logical owner after original cert disposal. Release copied cert (Untrack/copy-release then cert CFRelease), then identity. Its final Untrack deletes/releases the verified initial keychain owner before releasing that Untrack copy and identity, as mapped above. Release the explicitly held legacy key. Preserve every bridge return, OSStatus, CFError code, owned-output/release count and failure path.

The modern/legacy distinction is explicit. The installed managed helper's local legacy SafeSecKeyRef has no explicit using scope in FromCertAndKey; the native control uses balanced explicit CF owners and does not claim that this is a proven GC bug, or exactly emulate managed finalizer timing.

Use the already accepted before/first/200/five-second-idle checkpoints, raw native heap inventory, Mach RSS, total/owned-keychain FDs and backing-file counts, and read-only successful default/search-list fingerprint equality. No list setter or restore call. Keep fixed parent/process/probe/reap bounds, complete JSONL parsing, private generated paths, fail-closed ABI/hash/image checks and child reaping.

A positive result would show that managed caller participation is not necessary for this observed import-path retention, while retaining the exact installed native bridge code. It would not distinguish a bridge-internal native owner from Apple caching; a later independently owned public-Apple sequence could be justified then. A flat result would not by itself prove a managed bug: omitted managed parsing/matching, registry bookkeeping or lifetime differences remain. No production implementation or additional experiment is approved by this design.