#!/usr/bin/env bash # ^^^^^^^^^^^^^^^^^^^^ # The "shebang" line — tells the operating system which interpreter to use # when this file is executed directly. "#!/usr/bin/env bash" is preferred # over "#!/bin/bash" because it searches PATH for bash rather than assuming # bash lives at /bin/bash. On some systems (NixOS, some containers) bash # may be elsewhere. Using env makes the script more portable. # The file must be chmod +x (executable) for the shebang to take effect. set -euo pipefail # These are shell safety options. Always set them at the top of scripts # that run as root or automate sensitive operations. # # -e (errexit) Exit immediately if any command exits with a non-zero # status (i.e., if any command fails). Without this, bash # would continue running even after a failure, potentially # causing damage further down the script. # Exception: commands in if-conditions, while-conditions, # and those followed by || or && are not subject to -e. # # -u (nounset) Treat references to undefined variables as errors and # exit. Without this, a typo like $LUKS_DEV instead of # $LUKSDEV would silently expand to an empty string, # which could cause commands like "cryptsetup isLuks ''" # to behave unexpectedly. # # -o pipefail Normally, the exit status of a pipeline (cmd1 | cmd2) # is just the exit status of the LAST command. With # pipefail, if ANY command in a pipeline fails, the whole # pipeline is considered failed. This matters for chains # like "grep something | awk '...' | sort" — without # pipefail, a failing grep would go unnoticed. # ════════════════════════════════════════════════════════════════════════════ # kernel-update-tpm-apt-hook # # APT Pre-Install-Pkgs hook (Version 3 — receives full package list on stdin). # Detects boot-sensitive packages in the pending transaction and, if found, # runs kernel-update-tpm.sh Phase 1 BEFORE the packages are installed. # # This means the loose PCR-7 Clevis slot is in place and the strict slot is # removed before any postinst script calls update-initramfs or update-grub. # The phased reboot process then completes automatically. # # HOW APT HOOKS WORK # ─────────────────── # When you run "apt upgrade", APT goes through several stages: # 1. Resolve dependencies and build the list of packages to change # 2. Run Pre-Install-Pkgs hooks (this script runs here) # 3. Call dpkg to unpack and configure each package # 4. Each package's postinst script runs (initramfs rebuild, grub update) # 5. Run Post-Invoke hooks # # We must act at step 2. By the time step 4 runs, the new kernel files # are already being written to disk, and the PCR measurements are about # to change. If our Clevis slot is not already prepared at that point, # the next boot will fail to auto-unlock. # # WHY A HOOK RATHER THAN RUNNING MANUALLY # ───────────────────────────────────────── # Without the hook, the user must remember to run kernel-update-tpm.sh # before every kernel upgrade. That is easy to forget. The hook makes # the process automatic — the user can run "apt upgrade" normally and # the slot transition happens invisibly. The user is informed via terminal # output and the log file. # # FAILURE BEHAVIOR # ───────────────── # If this hook exits non-zero (due to a failure in kernel-update-tpm.sh # or any guard check), APT aborts the entire transaction. No packages are # installed. This is intentional: it is far safer to refuse a kernel # install than to let initramfs rebuild without the loose slot in place, # which would leave the system unable to auto-unlock after a reboot. # # Install: # sudo cp kernel-update-tpm-apt-hook /usr/local/sbin/ # sudo chmod 755 /usr/local/sbin/kernel-update-tpm-apt-hook # sudo cp 90-kernel-update-tpm.conf /etc/apt/apt.conf.d/ # # The apt config registers this hook: # DPkg::Pre-Install-Pkgs { "/usr/local/sbin/kernel-update-tpm-apt-hook"; }; # DPkg::Tools::Options::/usr/local/sbin/kernel-update-tpm-apt-hook::Version "3"; # # Guards: # - Does nothing if kernel-update-tpm.sh is not installed. # - Does nothing if a sentinel already exists (process already in flight). # - Does nothing if no boot-sensitive package is in the transaction. # - Logs all decisions to LOG regardless of whether it acts. # - A hook failure (exit non-zero) aborts the apt transaction intentionally: # it is safer to refuse the install than to let initramfs rebuild without # the loose slot in place. # ════════════════════════════════════════════════════════════════════════════ # ── Path to the main automation script ────────────────────────────────────── # # This is the INSTALLED location of kernel-update-tpm.sh. The hook does not # call the script from wherever it was downloaded — it calls the version # installed in /usr/local/sbin/. This is important for two reasons: # # 1. Consistency: APT runs hooks as root, in a minimal environment. Using # the installed path means we always get the same script version. # # 2. Security: /usr/local/sbin/ should only be writable by root (drwxr-xr-x). # If we used a path under /home/ or /tmp/, an attacker who could write to # those locations could replace the script with a malicious one that runs # as root during the next apt transaction. HOOK_SCRIPT="/usr/local/sbin/kernel-update-tpm.sh" # ── Sentinel file path ─────────────────────────────────────────────────────── # # The sentinel is a state file that kernel-update-tpm.sh writes when it starts # a multi-phase operation. Its existence means the script is already mid-run # (waiting for a reboot). If we see it here, we skip triggering again — # there is nothing to do, the process is already in flight. # # The /var/lib/ hierarchy is the standard location for persistent state files # created by programs. Unlike /tmp/ (cleared on reboot) or /run/ (tmpfs, # cleared on reboot), /var/lib/ survives reboots, which is what we need for # a multi-reboot workflow. SENTINEL="/var/lib/kernel-update-tpm/state" # ── Log file path ──────────────────────────────────────────────────────────── # # All hook decisions — whether it acted or skipped and why — are written to # this log. This is critical for debugging because hook output during an apt # run can scroll past quickly, and the hook runs non-interactively in many # automation contexts (cron, unattended-upgrades) where there is no terminal. # # /var/log/ is the standard location for log files on Linux systems. # Log files here persist across reboots and are managed by logrotate. LOG="/var/log/kernel-update-tpm-apt-hook.log" # ── Boot-sensitive package pattern ────────────────────────────────────────── # # This extended regular expression matches any package whose name STARTS WITH # one of these prefixes. It is used with "grep -Ei" to do case-insensitive # matching against the package list APT sends on stdin. # # Why these specific packages? # linux-image — The kernel itself. A new kernel means new PCR values. # linux-headers — Kernel headers. Not boot-critical alone, but often # installed alongside kernel images. # linux-kbuild — Kernel build tools. Same reasoning as headers. # linux-modules — Kernel modules. Required for the new kernel to work. # initramfs-tools — The tool that rebuilds the initramfs. Any update to # dracut — this (or dracut) could change initramfs content and # therefore PCR 9 (initramfs measurement). # grub — The bootloader. Changes to grub affect PCR 4 (boot # loader code) and PCR 8/9 (grub config/commands). # shim — The UEFI Secure Boot shim. Changes affect PCR 7. # mokutil — Tool for managing Machine Owner Keys (MOK). Changes # to MOK state affect PCR 7 measurements. # fwupd — Firmware update daemon. Firmware updates directly # affect PCR 0 (firmware code) and PCR 7. # firmware — Generic firmware packages. Same as fwupd. # microcode — CPU microcode updates. Loaded early in boot, affects # amd64-microcode — PCR 0 on some systems. # intel-microcode — # clevis — The TPM auto-unlock tool itself. An update could # change how Clevis interacts with the TPM. # cryptsetup — LUKS tool. An update could change LUKS behavior. # tpm2 — TPM2 tools and libraries. Core to the workflow. # libtss2 — TPM2 Software Stack library. TPM2 tools depend on it. # systemd — systemd manages the boot process and creates several # PCR measurements. A systemd update is boot-sensitive. # # The pattern uses alternation (|) inside a group (...). The grep -E flag # enables extended regular expressions which support | alternation. # The -i flag makes matching case-insensitive (handles uppercase variants). # # Trade-off — being too broad vs. too narrow: # Too broad (trigger on too many packages): causes unnecessary slot churn. # Because Phase 1 removes the strict slot and the apt hook then REBOOTS # the machine, an over-broad match reboots a running server on routine # upgrades. This is the failure we are correcting. # Too narrow (miss a relevant package): the auto-unlock breaks on the next # boot and the user must type their LUKS passphrase once, then re-enroll. # An inconvenience — NOT a lockout, because the human passphrase slot # always remains. The cost is asymmetric, so we favour precision. # # TWO precision rules, both essential: # # 1. ANCHOR to the start of the line ("^"). APT's Pre-Install-Pkgs v2/v3 # protocol sends its entire configuration space before the package list, # and those config lines literally contain our keywords as VALUES # (e.g. "APT::NeverAutoRemove::=^linux-image-..."). Package lines start # with the package NAME; config lines start with "APT::"/"DPkg::"/etc. # Anchoring, plus the preamble strip below (see PKGS), ensures we match # real packages only. Without this the hook fired on EVERY apt run. # # 2. Only list packages that actually change MEASURED boot components: # linux-image / linux-modules → kernel + initramfs (PCR 8,9) # linux-firmware / firmware-* → blobs pulled into the initramfs # initramfs-tools / dracut → rebuild the initramfs (PCR 9) # grub* / shim* → bootloader (PCR 4,5) # mokutil → MOK / Secure Boot state (PCR 7) # fwupd / *-microcode → firmware/microcode (PCR 0,1,2) # DELIBERATELY EXCLUDED: linux-headers/linux-kbuild (build files, not # booted), and clevis/cryptsetup/tpm2/libtss2/systemd — upgrading those # tools/libraries does NOT change the measured boot chain, yet their # bare names matched routine packages like libsystemd0, libcryptsetup12 # and libtss2-esys-*, forcing a reboot on nearly every apt upgrade. # If uname -r somehow changes without a match here, Phase 2's kernel # check still recovers (restore_strict_from_temp). PATTERN='^(linux-image|linux-modules|linux-firmware|firmware-|initramfs-tools|dracut|grub|shim|mokutil|fwupd|amd64-microcode|intel-microcode|microcode)' # ── Ensure log directory exists ────────────────────────────────────────────── # # mkdir -p creates the full directory path and does NOT error if it already # exists. The -p flag means "parents" — it will create intermediate directories # too (e.g., /var/log/ should exist, but this ensures it regardless). # # dirname "$LOG" extracts the directory component of the log path. # For "/var/log/kernel-update-tpm-apt-hook.log" it returns "/var/log". # We use this rather than hardcoding "/var/log" so that if LOG is changed # above, the mkdir automatically adjusts. DRY principle (Don't Repeat Yourself). mkdir -p "$(dirname "$LOG")" # ── Logging function ───────────────────────────────────────────────────────── # # This function writes a timestamped line to the log file. # # "$(date --iso-8601=seconds)" produces a timestamp like "2026-06-15T14:30:00+00:00" # ISO 8601 format is unambiguous across locales and sorts lexicographically, # which makes log files easy to read and grep through chronologically. # # ">>" appends to the file rather than overwriting it. This preserves the # history of all past hook invocations in a single log file, which is # essential for diagnosing problems after the fact. # # The function is called "log_hook" rather than just "log" to avoid shadowing # any existing shell function named "log" if this script is ever sourced or # extended. Naming conventions matter for maintainability. log_hook() { echo "[kernel-update-tpm-apt-hook] $(date --iso-8601=seconds) $*" >> "$LOG"; } # ── Read the package list from stdin ──────────────────────────────────────── # # APT (with Version 3 protocol) writes the full package list to this script's # standard input. We must read it into a variable NOW, before doing anything # else, because stdin is only available once. After the first read, it is # exhausted and subsequent reads would return empty. # # "cat" reads everything from stdin and writes it to stdout. # "$(...)" (command substitution) captures that output into a variable. # "|| true" ensures that if cat exits non-zero for any reason (e.g., APT sent # an empty stdin), this line does not cause the script to exit due to -e. # # Why read into a variable rather than piping directly to grep? # We need to use this data twice: once to log it (for debugging) and once to # grep for boot-sensitive packages. If we piped stdin directly to grep, we # could not also log it. Storing in a variable lets us use it multiple times. # # Trade-off: if the package list is enormous (thousands of packages), storing # it all in a shell variable uses memory. In practice, apt transactions rarely # exceed a few hundred packages, making this a non-issue. INPUT="$(cat || true)" # ── Log the full transaction ───────────────────────────────────────────────── # # We log the entire package list before making any decision. This is important # for post-incident analysis: if the hook did something unexpected, you can # look at the log and see exactly which packages were in the transaction. # # The blank log_hook "" line adds an empty separator line for readability. log_hook "--- apt transaction start ---" log_hook "$INPUT" log_hook "" # ── Isolate the package list from APT's stdin preamble ─────────────────────── # # Under the Pre-Install-Pkgs Version 2/3 protocol (which this hook requests), # APT does NOT send just the package list. It sends: # # VERSION 3 # # # # # The configuration space is nearly identical on every apt run AND it embeds # our own trigger keywords as config values (e.g. the NeverAutoRemove and # Package-Blacklist entries literally contain "linux-image", "grub", "shim", # "firmware"). Matching PATTERN against the whole stream therefore fired the # TPM transition on EVERY transaction — the root cause of the reboot churn. # # We keep only the package list: delete everything up to and including the # first blank line. If there is no blank line (Version 1 — bare package names, # no preamble), fall back to using the whole input unchanged. if printf '%s\n' "$INPUT" | grep -q '^$'; then PKGS="$(printf '%s\n' "$INPUT" | sed '1,/^$/d')" else PKGS="$INPUT" fi # ════════════════════════════════════════════════════════════════════════════ # GUARD: Main script not installed # ════════════════════════════════════════════════════════════════════════════ # # Before doing anything, check that the main script we want to call actually # exists and is executable. This prevents a confusing error if someone # installs this hook without also installing kernel-update-tpm.sh, or if # the script was accidentally deleted. # # [[ -x "$HOOK_SCRIPT" ]] tests whether the file: # -x = exists AND has the executable bit set for the current user (root) # # If the test FAILS (! negates it, so "not executable"), we log a SKIP message # and exit 0. Why exit 0 (success) instead of exit 1 (failure)? # - exit 1 would abort the entire apt transaction. That would be wrong: # the script not being present just means the automation is not set up # yet, not that something went wrong with THIS package installation. # - The user probably wants apt to continue and install their packages; # they just will not get the automatic TPM slot transition this time. # - We log the skip so the administrator can see why nothing happened. if [[ ! -x "$HOOK_SCRIPT" ]]; then log_hook "SKIP: $HOOK_SCRIPT not found or not executable" exit 0 fi # ════════════════════════════════════════════════════════════════════════════ # GUARD: Automation already in flight # ════════════════════════════════════════════════════════════════════════════ # # [[ -f "$SENTINEL" ]] checks whether the sentinel file EXISTS as a regular # file. The -f test is true for regular files (not directories, symlinks, etc.) # # If the sentinel exists, kernel-update-tpm.sh has already been triggered # (probably by a previous apt run or by running it manually) and is waiting # for a reboot to proceed to Phase 2. In this state: # - A loose PCR-7 Clevis slot is already in place # - The strict slot has already been removed # - A systemd unit is waiting to re-enroll after the next boot # # Running Phase 1 again would: # - Try to bind ANOTHER loose slot (unnecessary) # - Try to detect the "single existing strict slot" but find none # - Fail with an error or create a confusing slot state # # So we skip and exit 0 for the same reason as above — let apt continue. if [[ -f "$SENTINEL" ]]; then log_hook "SKIP: sentinel exists at $SENTINEL — process already in flight" exit 0 fi # ════════════════════════════════════════════════════════════════════════════ # GUARD: No boot-sensitive package in this transaction # ════════════════════════════════════════════════════════════════════════════ # # This is the key decision: does this apt transaction actually need TPM # slot preparation? # # "echo "$INPUT"" writes the package list to stdout. # That is piped to "grep -Eiq "$PATTERN"": # -E Extended regular expressions (required for the | alternation in PATTERN) # -i Case-insensitive matching # -q Quiet mode — grep produces no output, just exits 0 (found) or 1 (not found) # # "if ! grep" — we enter the if block if grep finds NO match (! negates the # exit code). If no boot-sensitive package is present, we skip and exit 0. # # Why is this guard important? # Running the full TPM slot transition takes time and causes a reboot. # We do not want that triggered by "apt install htop" or "apt upgrade curl". # This guard ensures the hook only activates for transactions that genuinely # risk changing the PCR values on the next boot. if ! echo "$PKGS" | grep -Eiq "$PATTERN"; then log_hook "SKIP: no boot-sensitive package detected in transaction" exit 0 fi # ── Identify and log which packages matched ────────────────────────────────── # # We know at least one boot-sensitive package matched. Now we identify # which ones for logging purposes — this is purely informational. # # "echo "$INPUT" | grep -Ei "$PATTERN"" — filter to only matching lines # "| awk '{print $1}'" — print just the first field (package name, not action) # "| tr '\n' ' '" — replace newlines with spaces to put all names on one line # # tr (translate) replaces characters. '\n' is a newline, ' ' is a space. # This collapses a multi-line list into a space-separated single line, # which is easier to read in the log. matched=$(echo "$PKGS" | grep -Ei "$PATTERN" | awk '{print $1}' | tr '\n' ' ') log_hook "TRIGGER: boot-sensitive packages detected: $matched" # ── Inform the user at the terminal ───────────────────────────────────────── # # APT hooks run in the same terminal session as the user's apt command. # Standard output (stdout) from hooks is typically suppressed by APT and # only goes to the log. Standard error (stderr) is shown to the user. # # We write to stderr (>&2) specifically so the user sees this message in # their terminal even though APT may redirect our stdout. # # "cat >&2 <&2 <>" and "2>&1" redirection: # >> "$LOG" — append stdout to the log file # 2>&1 — redirect stderr to the same place as stdout # This captures ALL output from kernel-update-tpm.sh into the log file so # it can be inspected later, whether the script succeeded or failed. # # "if ... ; then ... else ... fi" — conditional execution based on exit code: # Exit 0 (success): print a success message to stderr and let apt continue. # Exit non-zero (failure): print an error message to stderr and exit 1. # Exiting 1 causes APT to ABORT the transaction. No packages are installed. # This is the correct behavior: an unprotected initramfs rebuild is worse # than a failed apt transaction. The user can read the log and retry. log_hook "Invoking $HOOK_SCRIPT --hook-mode" if "$HOOK_SCRIPT" --hook-mode >> "$LOG" 2>&1; then # Phase 1 succeeded. The slot transition is prepared. APT will continue # installing packages. After apt finishes, a Post-Invoke hook prints a # REBOOT REQUIRED warning — the system does NOT reboot automatically. log_hook "Phase 1 (hook-mode) completed successfully" cat >&2 <> "$LOG" 2>&1 redirection above. log_hook "ERROR: $HOOK_SCRIPT --hook-mode failed — aborting apt transaction" cat >&2 <