Files
lmxopcua/scripts/validation/opcua-lifecycle-memory/native-retention/native_import_timing.py
T
2026-11-10 17:52:51 -05:00

216 lines
15 KiB
Python

#!/usr/bin/env python3
"""One matched fixed-200/200 native keychain deletion timing diagnostic."""
import argparse
from collections import Counter
import os
from pathlib import Path
import stat
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from native_import import (BRIDGE, BRIDGE_SHA, CHILDREN, COPY_STAGES, CRYPTO_SHA, DOTNET, EXPECTED_SDK, HERE,
REAP_OUTCOMES, RUNTIME, STAGES, bounded, complete_rows, reap, save, sha, snapshot,
validate_operation, wipe_owned)
ORDERS = ['normal', 'deferred']
IMMUTABLE_B = {
'NativeImportControl.c': 'ed442bacbdbbc3d420864c4b8ddac9bb4a5799d1e1a1e67d33033678e8401648',
'native_import.py': 'fc4092ab69a9a8b8213697d83c171632248c84c5c4bcbcf59ea6d2faffa82189',
'ImportSeed.cs': 'feaac1c93bdb0f4e59bd0a9fb213d6de53cb89ece7393356c9d12d318b395c1b',
'pure_apple.py': '1b2148f1c20578529355379ab94b439931d7241608a5d5effce8fc86f4af7bc6',
}
def validate_timing(row, order):
# B's validator replays logical registry ownership. Physical ownership is checked separately here.
projected = {**row, 'deleteStage': row['logicalZeroStage'], 'deleteLiveReferences': row['logicalZeroLiveReferences'],
'copies': [{**copy, 'nativeOwnerBefore': copy['registryOwnerBefore'],
'nativeOwnerAfter': copy['registryOwnerAfter']} for copy in row['copies']]}
validate_operation(projected)
if row['ownedOutputsAtLogicalZero'] != 1 + sum(row['logicalZeroLiveReferences'].values()):
raise RuntimeError('Observed native CF ownership did not reconcile at logical zero')
for index, copy in enumerate(row['copies']):
for point in ['Before', 'After']:
expected = copy['registryOwner' + point] if order == 'normal' else index > 0
if copy['nativeOwner' + point] != expected:
raise RuntimeError('Physical keychain owner lifetime did not match selected timing')
if order == 'normal':
if (row['deleteStage'], row['deleteLiveReferences'], row['ownedOutputsAtDelete']) != (
row['logicalZeroStage'], row['logicalZeroLiveReferences'], row['ownedOutputsAtLogicalZero']):
raise RuntimeError('Normal delete did not occur at logical zero')
elif row['deleteStage'] != 'after-all-item-releases' or any(row['deleteLiveReferences'].values()) or row['ownedOutputsAtDelete'] != 1:
raise RuntimeError('Deferred delete did not hold exactly its one observed native owner')
def seed_stamp(root):
values = []
for name in ['leaf.der', 'private.pkcs1']:
info = (root / name).lstat()
if not stat.S_ISREG(info.st_mode) or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1:
raise RuntimeError('Shared seed inputs must be private0600 regular files')
values.append((info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns))
return values # Check same files privately; no private-key bytes, hashes or inode IDs in receipts.
def fingerprint_key(row):
if any(row[k] for k in ['defaultStatus', 'defaultPathStatus', 'searchStatus', 'searchPathStatus']):
raise RuntimeError('Keychain fingerprint query failed')
return row['defaultSha256'], row['searchSha256'], row['searchCount']
def arm(binary, seed, task, output, order):
task.mkdir(mode=0o700); (task / 'tmp').mkdir(mode=0o700); output.mkdir(mode=0o700)
started = time.monotonic()
with (output / 'samples.jsonl').open('w') as log:
process = subprocess.Popen([str(binary), str(task), str(BRIDGE), str(seed), order], stdout=log, stderr=subprocess.STDOUT,
env=dict(os.environ, TMPDIR=str(task / 'tmp')), start_new_session=True)
CHILDREN.append(process)
try:
deadline = time.monotonic() + 180
samples = []
for stage in STAGES:
while True:
rows = complete_rows(output / 'samples.jsonl')
if any(r['kind'] == 'failed' for r in rows):
raise RuntimeError(order + ' timing child failed; preserve raw statuses')
matching = [r for r in rows if r['kind'] == 'checkpoint' and r['stage'] == stage]
if matching:
samples.append({**matching[0], **snapshot(process.pid, task, output, stage)})
(task / ('continue-' + stage)).touch()
break
if process.poll() is not None or time.monotonic() >= deadline:
raise RuntimeError(order + ' checkpoint exceeded bound: ' + stage)
time.sleep(.05)
status = process.wait(timeout=15)
rows = complete_rows(output / 'samples.jsonl')
operations = [r for r in rows if r['kind'] == 'operation']
fingerprints = [r for r in rows if r['kind'] == 'fingerprint']
complete = [r for r in rows if r['kind'] == 'complete']
checkpoints = [r for r in rows if r['kind'] == 'checkpoint']
if (status or len(rows) != 207 or len(complete) != 1 or [r['iteration'] for r in operations] != list(range(1, 201))
or [r['stage'] for r in fingerprints] != ['before', 'after'] or [r['stage'] for r in checkpoints] != STAGES
or [r['operations'] for r in checkpoints] != [0, 1, 200, 200] or any(r['order'] != order or r['managedRuntimePresent'] for r in checkpoints)):
raise RuntimeError(order + ' fixed operation/checkpoint counts failed')
for row in operations:
validate_timing(row, order)
done = complete[0]
if (done['order'] != order or done['operations'] != 200 or done['createdRefs'] != 200 or done['releasedRefs'] != 200
or done['bridgeSha256'] != BRIDGE_SHA or done['managedRuntimePresent'] or not done['readOnlyDefaultAndSearchListUnchanged']
or done['ownedOutputs'] != sum(r['ownedOutputs'] for r in operations) or done['releasedOutputs'] != sum(r['releasedOutputs'] for r in operations)
or fingerprint_key(fingerprints[0]) != fingerprint_key(fingerprints[1])):
raise RuntimeError(order + ' completion/ownership/fingerprint guard failed')
if any(samples[-1][k] for k in ['ownedKeychainFdCount', 'ownedKeychainFileCount']):
raise RuntimeError(order + ' private keychain files/descriptors retained')
result = {'status': 'passed', 'order': order, 'exitCode': status, 'samples': samples, 'complete': done,
'fingerprints': fingerprints, 'wallSecondsIncludingProbes': time.monotonic() - started, 'fullSoakGate': False,
'logicalZeroStages': dict(Counter(r['logicalZeroStage'] for r in operations)),
'actualDeletionStages': dict(Counter(r['deleteStage'] for r in operations)),
'ownedOutputsAtDeleteCounts': dict(Counter(r['ownedOutputsAtDelete'] for r in operations)),
'returnedCopyReferencesByStage': {stage: sum(c['returnedRef'] for r in operations for c in r['copies'] if c['stage'] == stage) for stage in COPY_STAGES}}
save(output / 'result.json', result)
return result
finally:
reap(process)
def main():
os.umask(0o077)
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--references', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path, help='Fresh private paired output')
args = parser.parse_args()
if sys.platform != 'darwin' or os.uname().machine != 'arm64':
parser.error('Pinned diagnostic requires macOS arm64')
if {name: sha(HERE / name) for name in IMMUTABLE_B} != IMMUTABLE_B:
raise RuntimeError('Immutable B sources changed')
if sha(BRIDGE) != BRIDGE_SHA or sha(RUNTIME / 'System.Security.Cryptography.dll') != CRYPTO_SHA:
raise RuntimeError('Installed native/managed cryptography identity changed')
references = args.references.resolve()
if {p.name: sha(p) for p in references.glob('Opc.Ua*.dll')} != EXPECTED_SDK:
raise RuntimeError('Pinned SDK2 seed binary set changed')
output = args.output.resolve(); output.mkdir(parents=True, exist_ok=False)
binary = output / 'NativeImportTimingControl'
command = ['clang', '-std=c11', '-O0', '-g', '-Wall', '-Wextra', str(HERE / 'NativeImportTimingControl.c'),
'-framework', 'Security', '-framework', 'CoreFoundation', '-o', str(binary)]
provenance = {'immutableBCommit': 'ce11a5dcfff19f0d5501674e407b1aa331a083f3', 'immutableBSourceSha256': IMMUTABLE_B,
'sourcesSha256': {name: sha(HERE / name) for name in ['NativeImportTimingControl.c', 'native_import_timing.py']},
'nativeBridgeSha256': BRIDGE_SHA, 'managedCryptographySha256': CRYPTO_SHA, 'sdkSeedSha256': EXPECTED_SDK,
'compileCommand': command, 'compiler': subprocess.check_output(['clang', '--version'], text=True, timeout=10),
'os': subprocess.check_output(['sw_vers'], text=True, timeout=10), 'architecture': os.uname().machine,
'hostDotnet': subprocess.check_output([DOTNET, '--info'], text=True, timeout=10),
'order': ORDERS, 'repetitionsPerArm': 200, 'stages': STAGES, 'idleSeconds': 5, 'fullSoakGate': False,
'managedCallerInMeasuredChildren': False, 'sharedSingleFreshPrivateSeed': True, 'fixtureCreationUmask': '0077',
'onlyTimingChange': 'Retire logical ownership at zero in both; defer physical Delete+initial CFRelease until observed item/key/data/copy/error refs are released.',
'observedReturnedOwnershipOnly': True, 'checkpointDeadlineSecondsPerArm': 180}
save(output / 'provenance.json', provenance)
bounded(command, output / 'native-build.log', 60)
project = ET.Element('Project', Sdk='Microsoft.NET.Sdk'); properties = ET.SubElement(project, 'PropertyGroup')
for name, value in {'OutputType': 'Exe', 'TargetFramework': 'net10.0', 'ImplicitUsings': 'enable', 'Nullable': 'enable',
'EnableDefaultCompileItems': 'false', 'TreatWarningsAsErrors': 'true', 'UseSharedCompilation': 'false'}.items():
ET.SubElement(properties, name).text = value
items = ET.SubElement(project, 'ItemGroup'); ET.SubElement(items, 'FrameworkReference', Include='Microsoft.AspNetCore.App')
ET.SubElement(items, 'Compile', Include=str(HERE / 'ImportSeed.cs'))
for name in EXPECTED_SDK:
ref = ET.SubElement(items, 'Reference', Include=Path(name).stem); ET.SubElement(ref, 'HintPath').text = str(references / name)
ET.indent(project); ET.ElementTree(project).write(output / 'ImportSeed.csproj', encoding='unicode')
bounded([DOTNET, 'build', str(output / 'ImportSeed.csproj'), '-c', 'Release', '-m:1', '--disable-build-servers', '--nologo'], output / 'seed-build.log', 120)
provenance['binarySha256'] = sha(binary)
provenance['seedBinarySha256'] = sha(output / 'bin/Release/net10.0/ImportSeed.dll')
provenance['linkedLibraries'] = subprocess.check_output(['otool', '-L', str(binary)], text=True, timeout=10)
save(output / 'provenance.json', provenance)
task = output / 'task'; task.mkdir(mode=0o700); seed = task / 'seed'
for name in ['seed', 'seed-tmp', 'seed-data']:
(task / name).mkdir(mode=0o700)
started = time.monotonic()
try:
bounded([DOTNET, str(output / 'bin/Release/net10.0/ImportSeed.dll'), str(seed)], output / 'seed.jsonl', 75,
env=dict(os.environ, TMPDIR=str(task / 'seed-tmp'), XDG_DATA_HOME=str(task / 'seed-data')))
rows = complete_rows(output / 'seed.jsonl')
if len(rows) != 1 or rows[0]['kind'] != 'seed' or rows[0]['sdkVersion'] != '2.0.1.0' or rows[0]['cryptographyRuntime']['sha256'] != CRYPTO_SHA:
raise RuntimeError('Seed/runtime count or identity mismatch')
seed_row = rows[0]
if any(p.stat().st_mode & 0o077 for p in seed.rglob('*') if p.is_file()):
raise RuntimeError('Seed file permissions are not private')
wipe_owned(seed / 'pki'); wipe_owned(task / 'seed-tmp'); wipe_owned(task / 'seed-data')
same_seed = seed_stamp(seed)
results = {}
for order in ORDERS:
if seed_stamp(seed) != same_seed or sha(seed / 'leaf.der') != seed_row['leafSha256'] or sha(BRIDGE) != BRIDGE_SHA or sha(binary) != provenance['binarySha256']:
raise RuntimeError('Shared seed or pinned measured binary changed')
results[order] = arm(binary, seed, task / order, output / order, order)
if seed_stamp(seed) != same_seed or sha(seed / 'leaf.der') != seed_row['leafSha256']:
raise RuntimeError('Shared private seed changed across measured arms')
if len({fingerprint_key(row) for result in results.values() for row in result['fingerprints']}) != 1:
raise RuntimeError('Read-only keychain fingerprints differ across paired arms')
counts = {order: [r['AppleDatabaseCount'] for r in results[order]['samples']] for order in ORDERS}
normal = counts['normal']
save(output / 'result.json', {'status': 'passed', 'order': ORDERS, 'seed': seed_row, 'sharedSeedIdentityUnchanged': True,
'wallSecondsIncludingSeedAndBothArms': time.monotonic() - started, 'arms': results, 'AppleDatabaseCounts': counts,
'normalReproducedOnePerOperation': normal[1] == normal[0] + 1 and normal[2] == normal[3] == normal[0] + 200,
'fullSoakGate': False, 'nativeInventoryIsNotOpenHandlesOrExclusiveRss': True})
finally:
save(output / 'fixture-before-harness-cleanup.json', {'remainingOwnedKeychainFilesByArm': {
order: sum(p.is_file() and '.keychain' in p.name for p in (task / order / 'tmp').rglob('*')) for order in ORDERS}})
all_reaped = all(child.returncode is not None for child in CHILDREN)
cleanup = {'allLaunchedChildrenConfirmedReaped': all_reaped, 'reapOutcomes': REAP_OUTCOMES,
'privateMaterialOverwrittenThenUnlinkedAfterChildReaping': False, 'physicalSecureErasureClaimed': False}
if not all_reaped:
cleanup.update(status='failed-preserved-private-fixture', ownedFixtureRootRemoved=False)
save(output / 'fixture-cleanup.json', cleanup)
raise RuntimeError('A launched child was not reaped; private fixture preserved')
try:
wipe_owned(task)
cleanup.update(status='passed', ownedFixtureRootRemoved=not task.exists(),
privateMaterialOverwrittenThenUnlinkedAfterChildReaping=True)
except Exception as error:
cleanup.update(status='failed', errorType=type(error).__name__, ownedFixtureRootRemoved=not task.exists())
raise
finally:
save(output / 'fixture-cleanup.json', cleanup)
print(output)
if __name__ == '__main__':
main()