test: compare native keychain deletion timing
This commit is contained in:
@@ -0,0 +1,215 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user