feat: add auto secure boot to new kernel update

This commit is contained in:
2026-07-28 13:16:02 +02:00
parent 61e0e1e840
commit 937fced108
9 changed files with 436 additions and 9 deletions

View File

@ -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

View File

@ -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é ─────────────────────────────────────────────────────────────────

View File

@ -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(

113
lib/secure_boot.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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

View File

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

53
tests/test_secure_boot.py Normal file
View File

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