Files

168 lines
7.6 KiB
Python

#!/usr/bin/env python3
"""Preserve Debian's authenticated shim -> GRUB chain; never create signatures.
Record while authenticated APT indexes exist, then verify again before staging
the removable and Debian ESP paths. The receipt inherits rootfs/release trust;
firmware trust databases, revocation and actual boot still need qualification.
"""
import argparse
import hashlib
import json
from pathlib import Path
import re
import shutil
import subprocess
import tarfile
import tempfile
RECEIPT = Path('usr/local/share/archipelago/secure-boot.json')
ARCHES = {'amd64': ('x64', 'x86_64-efi'), 'arm64': ('aa64', 'arm64-efi')}
def required_files(architecture):
suffix, target = ARCHES[architecture]
return {
'shim-signed': {f'usr/lib/shim/shim{suffix}.efi.signed': f'BOOT{suffix.upper()}.EFI'},
f'grub-efi-{architecture}-signed': {
f'usr/lib/grub/{target}-signed/grub{suffix}.efi.signed': f'grub{suffix}.efi'},
f'shim-helpers-{architecture}-signed': {
f'usr/lib/shim/mm{suffix}.efi.signed': f'mm{suffix}.efi'},
}
def digest(path):
with path.open('rb') as stream:
return hashlib.file_digest(stream, 'sha256').hexdigest()
def signed_payload(path):
result = subprocess.run(['sbverify', '--list', str(path)], capture_output=True,
text=True, check=True, timeout=15)
if not re.search(r'^signature [1-9][0-9]*\s*$', result.stdout, re.M):
raise ValueError(f'No PE signature in {path}')
def authenticated_version(version):
origins = [origin for origin in version.origins if origin.archive != 'now']
if not origins or not all(origin.trusted and origin.origin == 'Debian' for origin in origins):
raise ValueError('Signed boot package lacks authenticated Debian origin')
expected = version.record.get('SHA256', '')
if not re.fullmatch('[0-9a-f]{64}', expected):
raise ValueError('Signed boot package lacks archive SHA256')
return expected
def record(architecture):
import apt
cache = apt.Cache()
packages = {}
for package, paths in required_files(architecture).items():
installed = cache[package].installed
if installed is None:
raise ValueError(f'Missing signed boot package: {package}')
version = cache[package].versions[installed.version]
expected = authenticated_version(version)
with tempfile.TemporaryDirectory(prefix='secure-boot-provenance-') as work:
archive = Path(version.fetch_binary(work, progress=apt.progress.base.AcquireProgress()))
if digest(archive) != expected:
raise ValueError('Signed boot archive SHA256 mismatch')
process = subprocess.Popen(['dpkg-deb', '--fsys-tarfile', str(archive)], stdout=subprocess.PIPE)
files = {}
try:
with tarfile.open(fileobj=process.stdout, mode='r|') as payload:
for member in payload:
name = member.name.removeprefix('./')
if name in paths:
if not member.isfile() or name in files:
raise ValueError('Invalid signed boot archive member')
files[name] = hashlib.file_digest(payload.extractfile(member), 'sha256').hexdigest()
finally:
process.stdout.close()
status = process.wait()
if status or files.keys() != paths.keys():
raise ValueError('Signed boot archive is incomplete')
packages[package] = {'version': installed.version, 'archive_sha256': expected, 'files': files}
receipt = {'schema': 1, 'architecture': architecture, 'origin': 'Debian', 'packages': packages}
verify(Path('/'), architecture, receipt)
path = Path('/') / RECEIPT
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(receipt, sort_keys=True, indent=2) + '\n')
def verify(root, architecture, receipt):
expected = required_files(architecture)
if (receipt.get('schema') != 1 or receipt.get('architecture') != architecture
or receipt.get('origin') != 'Debian' or not isinstance(receipt.get('packages'), dict)
or receipt['packages'].keys() != expected.keys()):
raise ValueError('Missing/incompatible signed boot provenance')
for package, paths in expected.items():
claim = receipt['packages'][package]
if (not isinstance(claim.get('version'), str) or not claim['version']
or not re.fullmatch('[0-9a-f]{64}', claim.get('archive_sha256', ''))
or not isinstance(claim.get('files'), dict) or claim['files'].keys() != paths.keys()):
raise ValueError('Invalid signed boot package provenance')
for name in paths:
path = root / name
if (path.is_symlink() or not path.resolve().is_relative_to(root.resolve())
or digest(path) != claim['files'][name]):
raise ValueError(f'Changed signed boot payload: {name}')
signed_payload(path)
def stage(root, esp, architecture, *, uuid=None, label=None):
if bool(uuid) == bool(label):
raise ValueError('Exactly one root UUID or installation-media label is required')
value = uuid or label
if not re.fullmatch('[A-Za-z0-9_-]{1,64}', value):
raise ValueError('Unsafe boot filesystem identifier')
receipt = json.loads((root / RECEIPT).read_text())
verify(root, architecture, receipt) # Refuse before changing any ESP bytes.
config = (f'search --no-floppy --{"fs-uuid" if uuid else "label"} --set=root {value}\n'
'set prefix=($root)/boot/grub\nconfigfile $prefix/grub.cfg\n')
paths = required_files(architecture)
suffix = ARCHES[architecture][0]
for directory in ('BOOT', 'debian'):
target = esp / 'EFI' / directory
if target.is_symlink() or not target.resolve().is_relative_to(esp.resolve()):
raise ValueError('ESP path escapes destination')
target.mkdir(parents=True, exist_ok=True)
for files in paths.values():
for source, destination in files.items():
if directory == 'debian' and destination.startswith('BOOT'):
destination = f'shim{suffix}.efi'
output = target / destination
if output.is_symlink():
raise ValueError('Refusing symlink in ESP')
shutil.copyfile(root / source, output)
if digest(output) != digest(root / source):
raise ValueError('ESP copy verification failed')
if (target / 'grub.cfg').is_symlink():
raise ValueError('Refusing symlink in ESP configuration')
(target / 'grub.cfg').write_text(config)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('command', choices=('record', 'verify', 'stage'))
parser.add_argument('--architecture', choices=ARCHES, required=True)
parser.add_argument('--root', type=Path, default=Path('/'))
parser.add_argument('--esp', type=Path)
group = parser.add_mutually_exclusive_group()
group.add_argument('--uuid')
group.add_argument('--label')
args = parser.parse_args()
if args.command == 'record':
if args.root != Path('/'):
parser.error('record must run inside the image root')
record(args.architecture)
elif args.command == 'verify':
verify(args.root, args.architecture, json.loads((args.root / RECEIPT).read_text()))
else:
if args.esp is None:
parser.error('stage requires --esp')
stage(args.root, args.esp, args.architecture, uuid=args.uuid, label=args.label)
if __name__ == '__main__':
main()