"""Offline boot-chain layout/refusal tests; never build or sign an image.""" import importlib.util import json from pathlib import Path from types import SimpleNamespace import subprocess import tempfile import unittest from unittest.mock import patch spec = importlib.util.spec_from_file_location('secure_boot', Path(__file__).parents[1] / 'secure_boot.py') boot = importlib.util.module_from_spec(spec) spec.loader.exec_module(boot) class SecureBootTests(unittest.TestCase): def setUp(self): self.temp = tempfile.TemporaryDirectory() self.addCleanup(self.temp.cleanup) self.root = Path(self.temp.name) / 'root' self.esp = Path(self.temp.name) / 'esp' self.root.mkdir() self.esp.mkdir() def fixture(self, arch='amd64'): receipt = {'schema': 1, 'architecture': arch, 'origin': 'Debian', 'packages': {}} for package, files in boot.required_files(arch).items(): claim = {'version': 'fixture-only', 'archive_sha256': 'a' * 64, 'files': {}} for name in files: path = self.root / name path.parent.mkdir(parents=True, exist_ok=True) path.write_bytes(('distinct-fixture:' + name).encode()) claim['files'][name] = boot.digest(path) receipt['packages'][package] = claim target = self.root / boot.RECEIPT target.parent.mkdir(parents=True, exist_ok=True) target.write_text(json.dumps(receipt)) return receipt @patch.object(boot, 'signed_payload') def test_both_architectures_use_shim_then_distinct_grub_and_preserve_other_vendor(self, signature): for arch, suffix in [('amd64', 'x64'), ('arm64', 'aa64')]: self.fixture(arch) vendor = self.esp / 'EFI' / 'other-vendor' vendor.mkdir(parents=True, exist_ok=True) (vendor / 'boot.efi').write_bytes(b'preserve') boot.stage(self.root, self.esp, arch, uuid='12345678-1234-1234-1234-123456789abc') fallback = self.esp / 'EFI/BOOT' self.assertEqual((fallback / f'BOOT{suffix.upper()}.EFI').read_bytes(), (self.root / f'usr/lib/shim/shim{suffix}.efi.signed').read_bytes()) self.assertNotEqual((fallback / f'BOOT{suffix.upper()}.EFI').read_bytes(), (fallback / f'grub{suffix}.efi').read_bytes()) self.assertTrue((fallback / f'mm{suffix}.efi').is_file()) self.assertEqual((fallback / 'grub.cfg').read_text(), (self.esp / 'EFI/debian/grub.cfg').read_text()) self.assertIn('search --no-floppy --fs-uuid --set=root 12345678-', (fallback / 'grub.cfg').read_text()) self.assertEqual((vendor / 'boot.efi').read_bytes(), b'preserve') self.assertEqual(signature.call_count, 6) @patch.object(boot, 'signed_payload') def test_installer_label_bootstraps_full_iso_menu(self, signature): self.fixture() boot.stage(self.root, self.esp, 'amd64', label='ARCHIPELAGO') config = (self.esp / 'EFI/BOOT/grub.cfg').read_text() self.assertIn('--label --set=root ARCHIPELAGO', config) self.assertIn('configfile $prefix/grub.cfg', config) @patch.object(boot, 'signed_payload') def test_tampered_stripped_missing_payload_refuses_before_esp_changes(self, signature): for mutation in ('tamper', 'strip', 'missing'): self.fixture() path = self.root / 'usr/lib/shim/shimx64.efi.signed' if mutation == 'missing': path.unlink() elif mutation == 'strip': path.write_bytes(path.read_bytes()[:3]) else: path.write_bytes(b'other signed or unsigned binary') with self.assertRaises((ValueError, FileNotFoundError)): boot.stage(self.root, self.esp, 'amd64', label='ARCHIPELAGO') self.assertEqual(list(self.esp.iterdir()), []) def test_missing_provenance_and_wrong_architecture_refuse(self): with self.assertRaises(FileNotFoundError): boot.stage(self.root, self.esp, 'amd64', label='ARCHIPELAGO') self.fixture() with self.assertRaisesRegex(ValueError, 'provenance'): boot.stage(self.root, self.esp, 'arm64', label='ARCHIPELAGO') @patch.object(boot, 'signed_payload') def test_unsafe_configuration_and_symlink_escape_refuse(self, signature): self.fixture() with self.assertRaises(ValueError): boot.stage(self.root, self.esp, 'amd64', label='ARCHIPELAGO\nreboot') (self.esp / 'EFI').symlink_to(self.root, target_is_directory=True) with self.assertRaisesRegex(ValueError, 'escapes'): boot.stage(self.root, self.esp, 'amd64', label='ARCHIPELAGO') def test_verifier_unavailable_unsigned_and_failure_refuse(self): for result in (FileNotFoundError('sbverify'), subprocess.CalledProcessError(1, 'sbverify'), SimpleNamespace(stdout='No signature table present\n')): with patch.object(boot.subprocess, 'run', side_effect=result if isinstance(result, Exception) else None, return_value=result): with self.assertRaises((FileNotFoundError, subprocess.CalledProcessError, ValueError)): boot.signed_payload(Path('fixture')) def test_debian_origin_and_sha256_are_required(self): good = SimpleNamespace(archive='stable', trusted=True, origin='Debian') version = SimpleNamespace(origins=[good], record={'SHA256': 'b' * 64}) self.assertEqual(boot.authenticated_version(version), 'b' * 64) for origin in (SimpleNamespace(archive='now', trusted=True, origin='Debian'), SimpleNamespace(archive='stable', trusted=False, origin='Debian'), SimpleNamespace(archive='stable', trusted=True, origin='Other')): version.origins = [origin] with self.assertRaises(ValueError): boot.authenticated_version(version) version.origins = [good] version.record = {'SHA256': ''} with self.assertRaises(ValueError): boot.authenticated_version(version) def test_reachable_builder_preserves_signed_chain_in_media_and_target(self): root = Path(__file__).resolve().parents[2] source = (root / 'image-recipe/_archived/build-auto-installer-iso.sh').read_text() self.assertNotIn('dpkg --purge shim', source) self.assertNotIn('grub-mkstandalone -O', source) self.assertNotIn('for shim_file in', source) self.assertIn('secure_boot.py record', source) self.assertIn('--esp /output/efi-tree', source) self.assertIn('--esp /boot/efi --uuid', source) self.assertIn('mcopy -s -i /output/efi.img /output/efi-tree/EFI ::/', source) if __name__ == '__main__': unittest.main()