168 lines
7.6 KiB
Python
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()
|