Files
lmxopcua/scripts/validation/opcua-lifecycle-memory/native-retention/native_import.py
T

285 lines
18 KiB
Python

#!/usr/bin/env python3
"""One fixed-200 native bridge import diagnostic; build/run only after review."""
import argparse
from collections import Counter
import hashlib
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import time
import xml.etree.ElementTree as ET
from pure_apple import complete_rows, save, snapshot, STAGES
HERE = Path(__file__).resolve().parent
RUNTIME = Path('/opt/homebrew/Cellar/dotnet/10.0.403/libexec/shared/Microsoft.NETCore.App/10.0.14')
BRIDGE = RUNTIME / 'libSystem.Security.Cryptography.Native.Apple.dylib'
BRIDGE_SHA = '0333994e217a3d111d36d7f31460eea69a286f9fc819e2d7c746df13d2aec46c'
CRYPTO_SHA = 'f4957d7218a004a27bf8964e45147df6a88092c6e7344582082840d62c6c579a'
DOTNET = '/opt/homebrew/Cellar/dotnet/10.0.403/libexec/dotnet'
EXPECTED_SDK = {
'Opc.Ua.Client.dll': '620da406fabdbc75d11f9827ea7e39d2d3c762471bd2264b0d11f1d6cd9247cd',
'Opc.Ua.Configuration.dll': '6ed6a594c93c3ff35d1a16539a9fe87c1e2640549cc2f48ed6bae333dc8bf0e7',
'Opc.Ua.Core.Schema.dll': '51cfad48657d95d38074bb88be29aea6f5b361a07397247d58838d066b99772e',
'Opc.Ua.Core.Types.dll': 'a7dd141b8dabe8848ff28c2fb8e16a85a0508e6a49e6b1a1c1e267175b99af96',
'Opc.Ua.Core.dll': '9d0857aa6b7f3c49037d4ea557b4da8edf07e146069464481783e66c5c9cd35a',
'Opc.Ua.Security.Certificates.dll': '7f4969aceb2f0db5328c6882cb4b117342f3b18905c38acdba600683860c4fd0',
'Opc.Ua.Server.dll': '930e714a2f6e15d393840bcdc95e1afa8012bc740c3738a655f958142721864e',
'Opc.Ua.Types.dll': 'c542f69882f4e8a4ebd5ccc28a3881d6967c7ffb6dcc3c56dc6c776f39c2ede1',
}
CHILDREN = []
REAP_OUTCOMES = []
COPY_STAGES = ['import-cert-track', 'move-outer-copy', 'move-cert-untrack', 'move-cert-track',
'move-identity-track', 'identity-cert-track', 'release-original-cert',
'release-copied-cert', 'release-identity']
def sha(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def reap(process):
outcome = {'processId': process.pid, 'confirmedReaped': False}
try:
if process.poll() is None:
try:
os.killpg(process.pid, signal.SIGTERM)
except ProcessLookupError:
pass
try:
process.wait(timeout=10)
except subprocess.TimeoutExpired:
try:
os.killpg(process.pid, signal.SIGKILL)
except ProcessLookupError:
pass
process.wait(timeout=5)
outcome['exitCode'] = process.wait(timeout=0)
outcome['confirmedReaped'] = True
except Exception as error:
outcome['errorType'] = type(error).__name__
raise
finally:
REAP_OUTCOMES.append(outcome)
def bounded(command, log, timeout, env=None):
with log.open('w') as stream:
process = subprocess.Popen(command, stdout=stream, stderr=subprocess.STDOUT,
env=env, start_new_session=True)
CHILDREN.append(process)
try:
status = process.wait(timeout=timeout)
if status:
raise RuntimeError('Bounded subprocess failed: ' + log.name)
finally:
reap(process)
def wipe_owned(root):
"""Best-effort caller-owned file overwrite/unlink; no physical secure-erasure claim."""
if not root.exists():
return
files = [p for p in root.rglob('*') if p.is_file() and not p.is_symlink()]
if len(files) > 100 or any(p.stat().st_size > 1024 * 1024 or p.stat().st_nlink != 1 for p in files):
raise RuntimeError('Unexpected owned fixture cleanup size/link layout; preserve private root')
for path in files:
size = path.stat().st_size
with path.open('r+b') as stream:
stream.write(bytes(size)); stream.flush(); os.fsync(stream.fileno())
shutil.rmtree(root)
def validate_operation(row):
if row['failed'] or row['fallbackCleanup'] or not row['privatePathVerified'] or not row['importStateReleased'] or row['logicalOwnersRemaining'] or row['modernErrorPresent'] or row['externalErrorPresent']:
raise RuntimeError('Native ownership/cleanup guard failed')
if any(row[k] for k in ['certStatus', 'createStatus', 'pathStatus', 'neverLockStatus', 'legacyStatus', 'moveStatus', 'copyCertStatus', 'deleteStatus', 'modernErrorCode', 'externalErrorCode']) or any(row[k] != 1 for k in ['certReturn', 'modernReturn', 'externalReturn', 'legacyReturn', 'moveReturn']):
raise RuntimeError('Native bridge statuses failed')
leaf_check = row['iteration'] in [1, 200]
if row['leafIdentityChecked'] != leaf_check or row['leafIdentityMatches'] != leaf_check:
raise RuntimeError('First/last native identity checks failed')
copies = row['copies']
if [c['stage'] for c in copies] != COPY_STAGES or any(c['status'] or c['returnedRef'] != c['privateAssociation'] for c in copies):
raise RuntimeError('Native wrapper-copy sequence/status/path failed')
# Independently reconstruct ownership from observed associations, with no fixed non-null pattern.
actions = ['track', 'lookup', 'untrack', 'track', 'track', 'track', 'untrack', 'untrack', 'untrack']
items = ['original', None, 'original', 'original', 'identity', 'copied', 'original', 'copied', 'identity']
tracked = {'original': False, 'copied': False, 'identity': False}
logical = 0
native_owner = False
deletion = None
live_at_delete = None
for index, (copy, action, item) in enumerate(zip(copies, actions, items)):
if index == 1: # Fresh native keychain created between DER TrackItem and Move wrapper.
logical = 1
native_owner = True
if index == 7: # Initial import state releases after disposing the original certificate.
if row['importOwnersBefore'] != logical or not native_owner:
raise RuntimeError('Initial import owner release was misplaced')
logical -= 1
if row['importOwnersAfter'] != logical or logical < 0:
raise RuntimeError('Initial import owner accounting failed')
if logical == 0:
deletion = 'release-import-state'
native_owner = False
live_at_delete = {'copy': False, 'originalCertificate': False, 'copiedCertificate': True,
'identity': True, 'legacyKey': True}
if copy['action'] != action or copy['logicalOwnersBefore'] != logical or copy['nativeOwnerBefore'] != native_owner:
raise RuntimeError('Native wrapper ownership before copy does not reconcile')
associated = copy['privateAssociation']
if action == 'track' and associated:
if tracked[item] or not native_owner:
raise RuntimeError('Track tried to duplicate or use a retired native owner')
tracked[item] = True
logical += 1
elif action == 'untrack':
if tracked[item]:
if not associated or not native_owner:
raise RuntimeError('Previously tracked item lost its association before Untrack')
tracked[item] = False
logical -= 1
if logical == 0:
if deletion is not None:
raise RuntimeError('Duplicate native deletion')
deletion = copy['stage']
native_owner = False
live_at_delete = {'copy': True, 'originalCertificate': index == 6, 'copiedCertificate': index in [6, 7],
'identity': True, 'legacyKey': True}
elif associated and native_owner:
raise RuntimeError('Untracked item would consume a still-registered owner')
if logical < 0 or copy['logicalOwnersAfter'] != logical or copy['nativeOwnerAfter'] != native_owner:
raise RuntimeError('Native wrapper ownership after copy does not reconcile')
if logical or native_owner or any(tracked.values()) or deletion is None or row['deleteStage'] != deletion or row['deleteLiveReferences'] != live_at_delete:
raise RuntimeError('Native logical owners or actual zero-owner delete stage did not reconcile')
expected_outputs = 8 + sum(c['returnedRef'] for c in copies) + int(leaf_check)
if row['ownedOutputs'] != expected_outputs or row['releasedOutputs'] != expected_outputs:
raise RuntimeError('Native returned CF ownership did not reconcile')
def main():
os.umask(0o077) # This collector and its children only; includes normal SDK seed PFX files.
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--references', required=True, type=Path)
parser.add_argument('--output', required=True, type=Path, help='Fresh private output directory')
args = parser.parse_args()
if sys.platform != 'darwin' or os.uname().machine != 'arm64':
parser.error('Pinned diagnostic requires macOS arm64')
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); os.chmod(output, 0o700)
binary = output / 'NativeImportControl'
command = ['clang', '-std=c11', '-O0', '-g', '-Wall', '-Wextra', str(HERE / 'NativeImportControl.c'),
'-framework', 'Security', '-framework', 'CoreFoundation', '-o', str(binary)]
provenance = {'sourcesSha256': {name: sha(HERE / name) for name in ['NativeImportControl.c', 'ImportSeed.cs', 'native_import.py', 'pure_apple.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),
'repetitions': 200, 'stages': STAGES, 'idleSeconds': 5, 'fullSoakGate': False,
'managedCallerInMeasuredChild': False, 'managedPfxParsingEmulated': False,
'legacyKeyLifetime': 'Explicit CFRelease after identity disposal; managed local has no explicit using scope.',
'wrapperNativeCopyCallsPerOperation': 9, 'logicalOwnersFromObservedPrivateAssociationsOnly': True, 'checkpointDeadlineSeconds': 180,
'deadlineScope': 'Checkpoint/child deadline; in-progress bounded probes and bounded reap add overhead.',
'defaultOrSearchListSetters': False, 'fixtureCreationUmask': '0077'}
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)
for name in ['seed', 'seed-tmp', 'seed-data', 'tmp']:
(task / name).mkdir(mode=0o700)
try:
bounded([DOTNET, str(output / 'bin/Release/net10.0/ImportSeed.dll'), str(task / 'seed')], output / 'seed.jsonl', 75,
env=dict(os.environ, TMPDIR=str(task / 'seed-tmp'), XDG_DATA_HOME=str(task / 'seed-data')))
seeds = complete_rows(output / 'seed.jsonl')
if len(seeds) != 1 or seeds[0]['kind'] != 'seed' or seeds[0]['sdkVersion'] != '2.0.1.0' or seeds[0]['cryptographyRuntime']['sha256'] != CRYPTO_SHA:
raise RuntimeError('Seed/runtime count or identity mismatch')
if sha(task / 'seed/leaf.der') != seeds[0]['leafSha256']:
raise RuntimeError('Seed leaf file identity mismatch')
if any(p.stat().st_mode & 0o077 for p in (task / 'seed').rglob('*') if p.is_file()):
raise RuntimeError('Seed fixture file permissions are not private0600')
for name in ['pki']:
wipe_owned(task / 'seed' / name)
wipe_owned(task / 'seed-tmp'); wipe_owned(task / 'seed-data')
if sha(BRIDGE) != BRIDGE_SHA:
raise RuntimeError('Native bridge changed before measured execution')
started = time.monotonic()
with (output / 'samples.jsonl').open('w') as log:
process = subprocess.Popen([str(binary), str(task), str(BRIDGE)], 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('Native import failed; preserve all status/ownership rows')
checkpoints = [r for r in rows if r['kind'] == 'checkpoint' and r.get('stage') == stage]
if checkpoints:
samples.append({**checkpoints[0], **snapshot(process.pid, task, output, stage)})
(task / ('continue-' + stage)).touch(); break
if process.poll() is not None or time.monotonic() >= deadline:
raise RuntimeError('Native 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']
if status or len(rows) != 207 or len(operations) != 200 or len(fingerprints) != 2 or len(complete) != 1 or [r['iteration'] for r in operations] != list(range(1, 201)):
raise RuntimeError('Native fixed operation/completion counts failed')
for row in operations:
validate_operation(row)
if sha(task / 'seed/leaf.der') != seeds[0]['leafSha256'] or complete[0]['operations'] != 200 or complete[0]['bridgeSha256'] != BRIDGE_SHA or complete[0]['ownedOutputs'] != sum(r['ownedOutputs'] for r in operations) or complete[0]['releasedOutputs'] != sum(r['releasedOutputs'] for r in operations) or complete[0]['createdRefs'] != 200 or complete[0]['releasedRefs'] != 200:
raise RuntimeError('Native identity/completion ownership guard failed')
if any(samples[-1][k] for k in ['ownedKeychainFdCount', 'ownedKeychainFileCount']):
raise RuntimeError('Private keychain files/descriptors retained')
save(output / 'result.json', {'status': 'passed', 'seed': seeds[0], 'samples': samples, 'fingerprints': fingerprints,
'complete': complete[0], 'wallSecondsIncludingProbes': time.monotonic() - started, 'fullSoakGate': False,
'nativeInventoryIsNotOpenHandlesOrExclusiveRss': True, 'measuredChildManagedRuntimePresent': False,
'actualDeletionStages': dict(Counter(r['deleteStage'] 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}})
finally:
reap(process)
finally:
save(output / 'fixture-before-harness-cleanup.json', {'remainingOwnedKeychainFiles': sum(p.is_file() and '.keychain' in p.name for p in (task / 'tmp').rglob('*'))})
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 without overwrite/unlink')
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()