#!/usr/bin/env python3 """Exact unsigned-UAT kernel binding, separate from generic CVE floor policy.""" import argparse import hashlib import json from pathlib import Path import re import shutil import subprocess import tarfile import tempfile FIELDS = {'architecture', 'package', 'version', 'source', 'archive_sha256', 'vmlinuz_sha256'} PROOF = Path('/var/lib/archipelago/uat-kernel') def sha(path): with Path(path).open('rb') as stream: return hashlib.file_digest(stream, 'sha256').hexdigest() def validate(pin, architecture=None): if not isinstance(pin, dict) or set(pin) != FIELDS: raise ValueError('exact UAT kernel pin fields required') arch = pin['architecture'] if arch not in ('amd64', 'arm64') or (architecture is not None and arch != architecture): raise ValueError('UAT kernel architecture mismatch') match = re.fullmatch(r'linux-image-(\d+\.\d+\.\d+)\+deb13-' + arch, pin['package']) if (not match or not re.fullmatch(re.escape(match[1]) + r'-[1-9][0-9]*', pin['version']) or pin['source'] != 'linux-signed-' + arch): raise ValueError('noncanonical/incompatible exact kernel package/version/source') for key in ('archive_sha256', 'vmlinuz_sha256'): if not isinstance(pin[key], str) or not re.fullmatch('[0-9a-f]{64}', pin[key]): raise ValueError('invalid exact kernel digest') return pin['package'].removeprefix('linux-image-') def read(path, architecture=None): def exact(pairs): result = {} for key, value in pairs: if key in result: raise ValueError('duplicate kernel pin key') result[key] = value return result pin = json.loads(Path(path).read_text(), object_pairs_hook=exact) validate(pin, architecture) return pin def check_receipt(receipt, pin): release = validate(pin) if (any(receipt.get(k) != pin[k] for k in ('package', 'version', 'source', 'archive_sha256')) or receipt.get('origin') != 'Debian' or receipt.get('files', {}).get('/boot/vmlinuz-' + release) != pin['vmlinuz_sha256']): raise ValueError('kernel receipt differs from exact qualified UAT pin') def preferences(pin): validate(pin) # First matching specific-form record wins. Other versions of even the # selected package, generic metas, and other versioned images are forbidden. return (f'Package: {pin["package"]}\nPin: version {pin["version"]}\nPin-Priority: 1001\n\n' f'Package: {pin["package"]}\nPin: version *\nPin-Priority: -1\n\n' 'Package: linux-image-*\nPin: version *\nPin-Priority: -1\n') def check_packages(status, pin, allow_absent=False): installed = [] for paragraph in status.split('\n\n'): fields = dict(line.split(': ', 1) for line in paragraph.splitlines() if ': ' in line and not line.startswith(' ')) if fields.get('Package', '').startswith('linux-image-') and fields.get('Status') == 'install ok installed': installed.append((fields['Package'], fields['Version'])) if installed != [(pin['package'], pin['version'])] and not (allow_absent and not installed): raise ValueError('installed kernel/metapackage set differs from exact UAT pin') def qualified_apt_version(pin, cache): try: package = cache[pin['package']] version = package.versions[pin['version']] except KeyError as error: raise ValueError('exact kernel absent from current APT indexes; inspect qualified evidence, never substitute') from error origins = [o for o in version.origins if o.archive != 'now'] if (not origins or not all(o.trusted and o.origin == 'Debian' for o in origins) or version.source_name != pin['source'] or version.architecture != pin['architecture'] or version.record.get('SHA256') != pin['archive_sha256'] or package.candidate is None or package.candidate.version != pin['version']): raise ValueError('exact kernel unavailable in authenticated current APT indexes; inspect retained qualified evidence, never substitute') return version def prepare(pin): """Inside the build root only, after authenticated apt update; no fallback.""" import apt validate(pin, subprocess.check_output(['dpkg', '--print-architecture'], text=True).strip()) check_packages(Path('/var/lib/dpkg/status').read_text(), pin, allow_absent=True) target = Path('/etc/apt/preferences.d/00-archipelago-uat-kernel') target.write_text(preferences(pin)) version = qualified_apt_version(pin, apt.Cache()) plan = subprocess.check_output(['apt-get', '--simulate', '--no-install-recommends', 'install', pin['package'] + '=' + pin['version']], text=True) for line in plan.splitlines(): if line.startswith('Inst linux-image-') and line.split()[1] != pin['package']: raise ValueError('APT plan selects another kernel') PROOF.mkdir(parents=True, exist_ok=True) (PROOF / 'apt-plan.txt').write_text(plan) (PROOF / 'package-record.txt').write_text(str(version.record)) for path in Path('/var/lib/apt/lists').glob('*InRelease'): shutil.copyfile(path, PROOF / path.name) # APT's GPG, hash, Date and Valid-Until checks are never disabled or replaced. def check_initrd(path, release): listing = subprocess.check_output(['lsinitramfs', str(path)], text=True) versions = set(re.findall(r'(?:^|/)lib/modules/([^/\s]+)', listing, re.M)) if versions != {release}: raise ValueError('initrd module release differs from selected UAT kernel') def verify(pin, root=Path('/')): release = validate(pin) check_packages((root / 'var/lib/dpkg/status').read_text(), pin) receipt = json.loads((root / 'var/lib/archipelago/kernel-security' / (pin['package'] + '.json')).read_text()) check_receipt(receipt, pin) if {p.name for p in (root / 'lib/modules').iterdir()} != {release}: raise ValueError('module tree selection differs from exact UAT kernel') for alias, name in (('vmlinuz', 'vmlinuz-' + release), ('initrd.img', 'initrd.img-' + release)): link = root / alias if not link.is_symlink() or str(link.readlink()) not in ('boot/' + name, '/boot/' + name): raise ValueError('boot symlink selection differs from exact UAT kernel') image, initrd = root / 'boot' / ('vmlinuz-' + release), root / 'boot' / ('initrd.img-' + release) if sha(image) != pin['vmlinuz_sha256']: raise ValueError('selected kernel bytes differ from exact UAT pin') check_initrd(initrd, release) binding = dict(pin=pin, release=release, vmlinuz_sha256=sha(image), initrd_sha256=sha(initrd)) proof = root / PROOF.relative_to('/') proof.mkdir(parents=True, exist_ok=True) (proof / 'boot-binding.json').write_text(json.dumps(binding, sort_keys=True) + '\n') return binding def check_tar(path, pin): """Rehash a cached target without extraction/chroot or trusting its stamp.""" release = validate(pin) with tarfile.open(path) as tar: members = {} for member in tar: name = member.name.removeprefix('./').rstrip('/') if name.startswith('/') or '..' in name.split('/') or name in members: raise ValueError('noncanonical/duplicate kernel cache path') members[name] = member def raw(name): member = members[name] if not member.isfile(): raise ValueError('nonregular kernel cache payload') return tar.extractfile(member) check_packages(raw('var/lib/dpkg/status').read().decode(), pin) receipt = json.load(raw('var/lib/archipelago/kernel-security/' + pin['package'] + '.json')) check_receipt(receipt, pin) for name, expected in receipt['files'].items(): if not name.startswith('/') or '..' in name.split('/'): raise ValueError('unsafe kernel receipt path') key = name.lstrip('/') if key not in members and key.startswith('lib/'): key = 'usr/' + key if key not in members and key.startswith(('usr/share/doc/', 'usr/share/man/', 'usr/share/lintian/', 'usr/share/linda/')): continue if hashlib.file_digest(raw(key), 'sha256').hexdigest() != expected: raise ValueError('cached kernel payload hash mismatch') binding = json.load(raw('var/lib/archipelago/uat-kernel/boot-binding.json')) if binding.get('pin') != pin or binding.get('release') != release: raise ValueError('cached kernel boot binding mismatch') for alias, key, field in (('vmlinuz', 'boot/vmlinuz-' + release, 'vmlinuz_sha256'), ('initrd.img', 'boot/initrd.img-' + release, 'initrd_sha256')): link = members[alias] if not link.issym() or link.linkname not in (key, '/' + key): raise ValueError('cached boot selection mismatch') if hashlib.file_digest(raw(key), 'sha256').hexdigest() != binding[field]: raise ValueError('cached boot bytes mismatch') versions = {m.group(1) for name in members if (m := re.match(r'(?:usr/)?lib/modules/([^/]+)', name))} if versions != {release}: raise ValueError('cached module release mismatch') with tempfile.TemporaryDirectory(prefix='uat-kernel-initrd-') as temp: initrd = Path(temp) / 'initrd' with raw('boot/initrd.img-' + release) as source, initrd.open('wb') as target: shutil.copyfileobj(source, target) check_initrd(initrd, release) return binding def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('action', choices=('validate', 'prepare', 'verify', 'cache')) parser.add_argument('pin', type=Path) parser.add_argument('--architecture') parser.add_argument('--archive', type=Path) args = parser.parse_args() pin = read(args.pin, args.architecture) if args.action == 'validate': print(pin['package'] + '=' + pin['version']) elif args.action == 'prepare': prepare(pin) elif args.action == 'verify': print(json.dumps(verify(pin), sort_keys=True)) else: print(json.dumps(check_tar(args.archive, pin), sort_keys=True)) if __name__ == '__main__': main()