Files
archy/scripts/tests/test_secure_boot.py

136 lines
6.8 KiB
Python

"""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()