216 lines
15 KiB
Python
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()
|