16 KiB
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:
SecKeyImportEphemeralat0x92e8callsSecItemImportat0x9390, followed by CF ownership cleanup.AddKeyToKeychaincallsSecItemExportat0xa0a8andSecItemImportat0xa11c, then retains an optional returned key and releases local containers.X509MoveToKeychainat0xa180calls AddKeyToKeychain at0xa240,SecCertificateAddToKeychainat0xa278, and a private-search-list identitySecItemCopyMatchingat0xa388, 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:
- After DER import, TrackItem(cert): copy/release its associated keychain. The null identity output is skipped, as the managed TrackItem does.
- 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.
- After identity certificate copy: copy/release copied cert's keychain for TrackItem.
- 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.
- Modern public/private and legacy private
SafeSecKeyRefHandlerelease 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:
- 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.
- 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.
- 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.
- 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.
- 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.
Association-driven correction after attempt A
Attempt A's exact original source is preserved with its failed receipts. The revised child retains all nine native wrapper copies but treats successful null results as unassociated, as the installed TrackItem does. Only non-null copies whose type, pointer and private generated path match the owned keychain create a logical owner. A previously tracked item's missing association at release fails; no owner is silently decremented. A copy of a retired keychain cannot consume a registry owner. Explicit fallback cleanup remains a failed diagnostic outcome.
The initial import-state owner is still released after original-certificate disposal. The actual last owner determines when the native delete and original CFRelease happen: import-state release if no item owner remains, otherwise the actual final tracked-item Untrack while its copy and item references remain alive. The receipt records that exact stage, live CF ownership at delete, and each copy's logical owner/native owner state before and after. The collector independently reconstructs the transitions from observed associations; it does not assume identity/copied-cert association or a fixed non-null count. Leaf identity checks remain first/last, and all returned CF outputs must reconcile.
This correction changes only the diagnostic ownership model. It does not alter the pinned bridge, imported material, fixed count, native calls, limits, permissions, cleanup, or production. The corrected code passed a separate Astra gate and coordinator serialization clearance before one fresh fixed-200 attempt. Attempt B results record its actual association/deletion order and native inventory outcome.