diff --git a/README.md b/README.md index 8483fe5..abe766a 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ Daemon de mises à jour automatiques pour CachyOS. Il vérifie les mises à jour - *Sûrs* : installés immédiatement en arrière-plan (aucune interruption) - *Reportés* : kernel, systemd, pilotes GPU, glibc, etc. — pré-téléchargés pendant la session, puis installés à la prochaine extinction - **Installation à l'extinction** — les paquets reportés sont installés pendant la séquence d'arrêt, sans accès réseau nécessaire (pré-téléchargés) +- **Secure Boot conservé** — si une clé MOK existe déjà, l'ESP reste montée et les noyaux/GRUB sont resignés et vérifiés après la transaction - **Interface graphique** — fenêtre GTK4/Libadwaita affichant les mises à jour en attente et l'historique des installations ## Dépendances @@ -19,6 +20,18 @@ Daemon de mises à jour automatiques pour CachyOS. Il vérifie les mises à jour - `libadwaita` - `pacman-contrib` (fournit `checkupdates`) +L'intégration Secure Boot est activée automatiquement lorsque +`/root/secureboot-mok/MOK.key` et `/root/secureboot-mok/MOK.crt` existent. Le +certificat correspondant doit déjà être enrôlé dans le firmware via MOK. Les +chemins peuvent être personnalisés dans +`/etc/cachyos-updater/secureboot.conf` : + +```bash +SECURE_BOOT_KEY=/root/secureboot-mok/MOK.key +SECURE_BOOT_CERT=/root/secureboot-mok/MOK.crt +SECURE_BOOT_ESP=/boot/efi +``` + ## Installation ```bash diff --git a/install.sh b/install.sh index 98272a2..c7411de 100755 --- a/install.sh +++ b/install.sh @@ -22,6 +22,13 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" info "Vérification des dépendances…" deps=(python python-gobject gtk4 libadwaita pacman-contrib) + +# Active l'intégration Secure Boot si une paire de clés MOK existe déjà. +secure_boot_configured=false +if [[ -r /root/secureboot-mok/MOK.key && -r /root/secureboot-mok/MOK.crt ]]; then + secure_boot_configured=true + deps+=(grub shim-signed sbsigntools) +fi missing=() for dep in "${deps[@]}"; do if ! pacman -Qi "$dep" &>/dev/null; then @@ -47,6 +54,19 @@ install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater /usr/bin/cachyos-updater install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater-ui /usr/bin/cachyos-updater-ui install -m 755 "$SCRIPT_DIR"/bin/cachyos-updater-shutdown /usr/bin/cachyos-updater-shutdown +if $secure_boot_configured; then + install -d /usr/local/sbin /etc/pacman.d/hooks + install -m 750 \ + "$SCRIPT_DIR"/secureboot/secureboot-sign-cachyos \ + /usr/local/sbin/secureboot-sign-cachyos + install -m 644 \ + "$SCRIPT_DIR"/secureboot/99-secureboot-sign-cachyos.hook \ + /etc/pacman.d/hooks/99-secureboot-sign-cachyos.hook + info "Intégration Secure Boot installée" +else + warn "Clés MOK absentes : intégration Secure Boot non installée" +fi + # Unités systemd install -m 644 "$SCRIPT_DIR"/systemd/cachyos-updater.service \ /usr/lib/systemd/system/cachyos-updater.service @@ -77,8 +97,10 @@ systemctl daemon-reload systemctl enable --now cachyos-updater.timer info "Timer activé : vérification au démarrage puis toutes les heures" -# Activer le service de shutdown (installation avant extinction) -systemctl enable cachyos-updater-shutdown.service +# Récrée les liens pour migrer les anciennes installations qui ciblaient +# directement poweroff.target/reboot.target, puis arme ExecStop. +systemctl reenable --now cachyos-updater-shutdown.service +systemctl start cachyos-updater-shutdown.service info "Service shutdown activé : installation des mises à jour reportées à l'extinction" # ── Résumé ───────────────────────────────────────────────────────────────── diff --git a/lib/package_manager.py b/lib/package_manager.py index 82d12b6..59e6e7c 100644 --- a/lib/package_manager.py +++ b/lib/package_manager.py @@ -4,6 +4,8 @@ from dataclasses import dataclass, field from pathlib import Path from typing import Optional +from secure_boot import prepare_secure_boot, refresh_secure_boot + logger = logging.getLogger(__name__) PACMAN_LOCK = Path("/var/lib/pacman/db.lck") @@ -75,12 +77,30 @@ def install_packages(names: list[str]) -> InstallResult: if not names: return InstallResult(success=True) + secure_boot = prepare_secure_boot(names) + if not secure_boot.success: + return InstallResult( + success=False, + failed=names, + error=f"Préparation Secure Boot impossible : {secure_boot.error}", + ) + result = subprocess.run( ["pacman", "-S", "--noconfirm", "--needed", "--noprogressbar"] + names, capture_output=True, text=True, timeout=600 ) if result.returncode == 0: + secure_boot = refresh_secure_boot(names) + if not secure_boot.success: + return InstallResult( + success=False, + failed=names, + error=( + "Les paquets ont été installés, mais la chaîne de démarrage " + f"n'a pas pu être sécurisée : {secure_boot.error}" + ), + ) return InstallResult(success=True, installed=names) else: return InstallResult( @@ -100,12 +120,30 @@ def upgrade_cached_packages(expected_names: list[str]) -> InstallResult: if not expected_names: return InstallResult(success=True) + secure_boot = prepare_secure_boot(expected_names) + if not secure_boot.success: + return InstallResult( + success=False, + failed=expected_names, + error=f"Préparation Secure Boot impossible : {secure_boot.error}", + ) + result = subprocess.run( ["pacman", "-Su", "--noconfirm", "--noprogressbar"], capture_output=True, text=True, timeout=600 ) if result.returncode == 0: + secure_boot = refresh_secure_boot(expected_names) + if not secure_boot.success: + return InstallResult( + success=False, + failed=expected_names, + error=( + "Les paquets ont été installés, mais la chaîne de démarrage " + f"n'a pas pu être sécurisée : {secure_boot.error}" + ), + ) return InstallResult(success=True, installed=expected_names) else: return InstallResult( diff --git a/lib/secure_boot.py b/lib/secure_boot.py new file mode 100644 index 0000000..b3bda1c --- /dev/null +++ b/lib/secure_boot.py @@ -0,0 +1,113 @@ +"""Intégration Secure Boot pour les transactions qui modifient le démarrage.""" + +import logging +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +logger = logging.getLogger(__name__) + +SIGNER_PATH = Path("/usr/local/sbin/secureboot-sign-cachyos") + + +@dataclass(frozen=True) +class SecureBootResult: + success: bool + configured: bool + error: str | None = None + + +def boot_artifacts_may_change(package_names: Iterable[str]) -> bool: + """Indique si la transaction peut remplacer un noyau ou un chargeur EFI.""" + for name in package_names: + if name in {"grub", "shim-signed", "systemd"}: + return True + if name == "linux" or name.startswith("linux-"): + return True + return False + + +def _run(command: list[str], timeout: int = 120) -> subprocess.CompletedProcess: + return subprocess.run( + command, + capture_output=True, + text=True, + timeout=timeout, + ) + + +def _error_message(result: subprocess.CompletedProcess) -> str: + output = (result.stderr or result.stdout).strip() + return output[-800:] if output else f"code de sortie {result.returncode}" + + +def prepare_secure_boot(package_names: Iterable[str]) -> SecureBootResult: + """Garantit que l'ESP est montée avant une transaction sensible. + + L'absence du signer signifie simplement que Secure Boot n'est pas géré par + cette installation. En revanche, une configuration présente mais + inutilisable bloque la transaction pour ne pas installer un noyau non signé. + """ + names = list(package_names) + if not boot_artifacts_may_change(names) or not SIGNER_PATH.is_file(): + return SecureBootResult(success=True, configured=False) + + if os.geteuid() != 0: + return SecureBootResult( + success=False, + configured=True, + error="la préparation Secure Boot doit être exécutée en root", + ) + + if not os.access(SIGNER_PATH, os.X_OK): + return SecureBootResult( + success=False, + configured=True, + error=f"le signer Secure Boot n'est pas exécutable : {SIGNER_PATH}", + ) + + try: + result = _run([str(SIGNER_PATH), "--prepare"]) + except (OSError, subprocess.TimeoutExpired) as error: + return SecureBootResult( + success=False, + configured=True, + error=f"impossible d'exécuter la pré-vérification : {error}", + ) + + if result.returncode != 0: + return SecureBootResult( + success=False, + configured=True, + error=f"pré-vérification du signer échouée : {_error_message(result)}", + ) + + return SecureBootResult(success=True, configured=True) + + +def refresh_secure_boot(package_names: Iterable[str]) -> SecureBootResult: + """Reconstruit et signe la chaîne CachyOS après la transaction pacman.""" + names = list(package_names) + if not boot_artifacts_may_change(names) or not SIGNER_PATH.is_file(): + return SecureBootResult(success=True, configured=False) + + try: + result = _run([str(SIGNER_PATH)], timeout=180) + except (OSError, subprocess.TimeoutExpired) as error: + return SecureBootResult( + success=False, + configured=True, + error=f"impossible d'exécuter le signer : {error}", + ) + + if result.returncode != 0: + return SecureBootResult( + success=False, + configured=True, + error=f"signature Secure Boot échouée : {_error_message(result)}", + ) + + logger.info("Chaîne de démarrage Secure Boot reconstruite et vérifiée") + return SecureBootResult(success=True, configured=True) diff --git a/secureboot/99-secureboot-sign-cachyos.hook b/secureboot/99-secureboot-sign-cachyos.hook new file mode 100644 index 0000000..2cd577c --- /dev/null +++ b/secureboot/99-secureboot-sign-cachyos.hook @@ -0,0 +1,20 @@ +[Trigger] +Operation = Install +Operation = Upgrade +Type = Package +Target = grub +Target = shim-signed + +[Trigger] +Operation = Install +Operation = Upgrade +Type = Path +Target = boot/vmlinuz-* + +[Action] +Description = Signing and verifying CachyOS Secure Boot artifacts... +When = PostTransaction +Depends = grub +Depends = shim-signed +Depends = sbsigntools +Exec = /usr/local/sbin/secureboot-sign-cachyos diff --git a/secureboot/secureboot-sign-cachyos b/secureboot/secureboot-sign-cachyos new file mode 100755 index 0000000..fd383d0 --- /dev/null +++ b/secureboot/secureboot-sign-cachyos @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +set -euo pipefail + +CONFIG=/etc/cachyos-updater/secureboot.conf +if [[ -r "$CONFIG" ]]; then + # Le fichier est installé et contrôlé par root. + # shellcheck source=/dev/null + source "$CONFIG" +fi + +KEY=${SECURE_BOOT_KEY:-/root/secureboot-mok/MOK.key} +CERT=${SECURE_BOOT_CERT:-/root/secureboot-mok/MOK.crt} +ESP=${SECURE_BOOT_ESP:-/boot/efi} +SBAT=${SECURE_BOOT_SBAT:-/usr/share/grub/sbat.csv} +GRUB_MODULES="all_video bli boot chain configfile echo efi_gop efi_uga ext2 fat font gettext gfxmenu gfxterm gzio linux loadenv normal part_gpt part_msdos png search search_fs_uuid search_label terminal video video_bochs video_cirrus" + +die() { + echo "Secure Boot : $*" >&2 + exit 1 +} + +[[ $EUID -eq 0 ]] || die "le signer doit être exécuté en root" + +for command in findmnt grub-install install sbverify sbsign; do + command -v "$command" >/dev/null 2>&1 \ + || die "commande requise introuvable : $command" +done + +[[ -r "$KEY" ]] || die "clé privée introuvable : $KEY" +[[ -r "$CERT" ]] || die "certificat introuvable : $CERT" +[[ -r "$SBAT" ]] || die "fichier SBAT introuvable : $SBAT" + +if ! findmnt --mountpoint "$ESP" >/dev/null 2>&1; then + echo "Secure Boot : montage de $ESP" + mount "$ESP" +fi + +ESP_FS=$(findmnt --noheadings --output FSTYPE --mountpoint "$ESP" | tr -d '[:space:]') +[[ "$ESP_FS" == "vfat" ]] \ + || die "$ESP n'est pas une partition EFI vfat montée (type : ${ESP_FS:-inconnu})" + +if [[ ${1:-} == "--prepare" ]]; then + echo "Secure Boot : pré-vérification réussie sur $ESP" + exit 0 +elif [[ $# -gt 0 ]]; then + die "option inconnue : $1" +fi + +sign_if_needed() { + local file=$1 + local tmp + + [[ -f "$file" ]] || return 0 + if sbverify --cert "$CERT" "$file" >/dev/null 2>&1; then + return 0 + fi + + tmp="${file}.signed.$$" + trap 'rm -f "$tmp"' RETURN + sbsign --key "$KEY" --cert "$CERT" --output "$tmp" "$file" >/dev/null + chown --reference="$file" "$tmp" + chmod --reference="$file" "$tmp" + mv "$tmp" "$file" + trap - RETURN +} + +verify_signature() { + local file=$1 + sbverify --cert "$CERT" "$file" >/dev/null 2>&1 \ + || die "signature MOK invalide : $file" +} + +grub-install \ + --target=x86_64-efi \ + --efi-directory="$ESP" \ + --bootloader-id=CACHYOS \ + --modules="$GRUB_MODULES" \ + --sbat="$SBAT" \ + --no-nvram + +install -d "$ESP/EFI/CACHYOS" "$ESP/EFI/BOOT" +for component in shimx64.efi mmx64.efi fbx64.efi; do + [[ -r "/usr/share/shim-signed/$component" ]] \ + || die "composant shim introuvable : $component" + install -m 0644 \ + "/usr/share/shim-signed/$component" \ + "$ESP/EFI/CACHYOS/${component^^}" + install -m 0644 \ + "/usr/share/shim-signed/$component" \ + "$ESP/EFI/BOOT/${component^^}" +done + +sign_if_needed "$ESP/EFI/CACHYOS/GRUBX64.EFI" +install -m 0644 \ + "$ESP/EFI/CACHYOS/GRUBX64.EFI" \ + "$ESP/EFI/BOOT/GRUBX64.EFI" + +shopt -s nullglob +kernels=(/boot/vmlinuz-*) +[[ ${#kernels[@]} -gt 0 ]] || die "aucun noyau trouvé dans /boot" +for kernel in "${kernels[@]}"; do + sign_if_needed "$kernel" +done + +verify_signature "$ESP/EFI/CACHYOS/GRUBX64.EFI" +verify_signature "$ESP/EFI/BOOT/GRUBX64.EFI" +for kernel in "${kernels[@]}"; do + verify_signature "$kernel" +done + +sync +echo "Secure Boot : GRUB et ${#kernels[@]} noyau(x) signés et vérifiés" diff --git a/systemd/cachyos-updater-shutdown.service b/systemd/cachyos-updater-shutdown.service index 1c7d7d8..1f35671 100644 --- a/systemd/cachyos-updater-shutdown.service +++ b/systemd/cachyos-updater-shutdown.service @@ -1,19 +1,20 @@ [Unit] Description=CachyOS Updater - Installation des mises à jour reportées avant extinction -DefaultDependencies=no -Before=poweroff.target halt.target reboot.target shutdown.target -# Après Plymouth pour pouvoir lui envoyer des messages +# L'ordre est inversé à l'arrêt : le service s'arrête avant les montages. After=local-fs.target plymouth-start.service +# Maintient explicitement l'ESP jusqu'à la fin de ExecStop. +RequiresMountsFor=/boot/efi [Service] Type=oneshot -ExecStart=/usr/bin/cachyos-updater-shutdown +ExecStart=/usr/bin/true +ExecStop=/usr/bin/cachyos-updater-shutdown +RemainAfterExit=yes # Jusqu'à 10 minutes pour installer les mises à jour (kernel, systemd...) -TimeoutStartSec=600 -RemainAfterExit=no +TimeoutStopSec=600 StandardOutput=journal StandardError=journal SyslogIdentifier=cachyos-updater-shutdown [Install] -WantedBy=poweroff.target halt.target reboot.target +WantedBy=multi-user.target diff --git a/tests/test_package_manager_secure_boot.py b/tests/test_package_manager_secure_boot.py new file mode 100644 index 0000000..be05182 --- /dev/null +++ b/tests/test_package_manager_secure_boot.py @@ -0,0 +1,55 @@ +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +import package_manager +from secure_boot import SecureBootResult + + +class SecureBootTransactionTests(unittest.TestCase): + @patch.object(package_manager.subprocess, "run") + @patch.object(package_manager, "prepare_secure_boot") + def test_aborts_before_pacman_when_preflight_fails( + self, prepare_mock, run_mock + ): + prepare_mock.return_value = SecureBootResult( + success=False, + configured=True, + error="ESP indisponible", + ) + + result = package_manager.install_packages(["linux-cachyos"]) + + self.assertFalse(result.success) + self.assertIn("ESP indisponible", result.error) + run_mock.assert_not_called() + + @patch.object(package_manager, "refresh_secure_boot") + @patch.object(package_manager.subprocess, "run") + @patch.object(package_manager, "prepare_secure_boot") + def test_reports_post_transaction_signing_failure( + self, prepare_mock, run_mock, refresh_mock + ): + prepare_mock.return_value = SecureBootResult( + success=True, + configured=True, + ) + run_mock.return_value = Mock(returncode=0, stdout="", stderr="") + refresh_mock.return_value = SecureBootResult( + success=False, + configured=True, + error="signature invalide", + ) + + result = package_manager.upgrade_cached_packages(["linux"]) + + self.assertFalse(result.success) + self.assertIn("paquets ont été installés", result.error) + self.assertIn("signature invalide", result.error) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_secure_boot.py b/tests/test_secure_boot.py new file mode 100644 index 0000000..05239a8 --- /dev/null +++ b/tests/test_secure_boot.py @@ -0,0 +1,53 @@ +import os +import sys +import unittest +from pathlib import Path +from unittest.mock import Mock, patch + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) + +import secure_boot + + +class BootArtifactsMayChangeTests(unittest.TestCase): + def test_detects_kernel_and_bootloader_packages(self): + for name in ("linux", "linux-cachyos", "linux-zen", "grub", "shim-signed"): + with self.subTest(name=name): + self.assertTrue(secure_boot.boot_artifacts_may_change([name])) + + def test_ignores_unrelated_packages(self): + self.assertFalse( + secure_boot.boot_artifacts_may_change(["firefox", "mesa", "curl"]) + ) + + +class PrepareSecureBootTests(unittest.TestCase): + @patch.object(secure_boot, "SIGNER_PATH") + def test_skips_when_signer_is_not_configured(self, signer_path): + signer_path.is_file.return_value = False + result = secure_boot.prepare_secure_boot(["linux-cachyos"]) + self.assertTrue(result.success) + self.assertFalse(result.configured) + + @patch.object(secure_boot, "_run") + @patch.object(os, "access", return_value=True) + @patch.object(os, "geteuid", return_value=0) + @patch.object(secure_boot, "SIGNER_PATH") + def test_mounts_and_validates_esp( + self, signer_path, _geteuid, _access, run_mock + ): + signer_path.is_file.return_value = True + signer_path.__str__ = Mock(return_value="/usr/local/sbin/signer") + run_mock.return_value = Mock(returncode=0, stdout="", stderr="") + + result = secure_boot.prepare_secure_boot(["linux-cachyos"]) + + self.assertTrue(result.success) + self.assertTrue(result.configured) + run_mock.assert_called_once_with( + ["/usr/local/sbin/signer", "--prepare"] + ) + + +if __name__ == "__main__": + unittest.main()