#!/usr/bin/env bash
# ^^^^^^^^^^^^^^^^^^^^
# Shebang line. Tells the OS to run this script using bash, located by
# searching PATH. Using /usr/bin/env bash instead of #!/bin/bash makes
# the script portable to systems where bash lives at a non-standard path
# (e.g., /usr/local/bin/bash on some BSD-based or container environments).

set -euo pipefail
# Three critical bash safety options, combined into one line:
#
#   -e  (errexit)   Exit immediately when any simple command returns a
#       non-zero (failure) exit code. Without this, bash continues past
#       errors silently, potentially acting on bad data or a broken system.
#       Example: if "clevis luks pass" fails, we do NOT want to continue
#       and attempt to pipe empty output into the next command.
#
#   -u  (nounset)   Treat references to undefined variables as errors.
#       Without this, a typo like $LUKSDEVV (instead of $LUKSDEV) silently
#       expands to an empty string. That could cause "cryptsetup isLuks"
#       to check an empty string instead of a real device path.
#
#   -o pipefail     Make a pipeline fail if ANY command in it fails.
#       In bash, "cmd1 | cmd2" normally reports only cmd2's exit code.
#       With pipefail, if cmd1 fails, the whole pipeline reports failure.
#       Critical here because we use pipelines like:
#         clevis luks pass ... | clevis luks bind ...
#       If "clevis luks pass" fails, we need to know.

# ════════════════════════════════════════════════════════════════════════════
# kernel-update-tpm.sh
#
# PURPOSE: Safe kernel update with Clevis/TPM2 PCR re-enrollment
#
# WHAT PROBLEM DOES THIS SOLVE?
# ──────────────────────────────
# This system uses LUKS2 full-disk encryption (FDE) with auto-unlock via
# a TPM2 chip. Here is how the auto-unlock works:
#
#   1. When you first set up the system, you enroll a LUKS key slot using
#      Clevis/TPM2. Clevis seals the LUKS decryption key inside the TPM,
#      together with a POLICY specifying which PCR values must match.
#
#   2. PCRs (Platform Configuration Registers) are measurements stored in
#      the TPM. Different PCRs measure different parts of the boot chain:
#        PCR 0: BIOS/UEFI firmware code
#        PCR 1: Firmware configuration
#        PCR 4: Bootloader (GRUB stage 1) code
#        PCR 5: Bootloader configuration
#        PCR 7: Secure Boot state and enrolled keys
#        PCR 8: GRUB command line
#        PCR 9: GRUB-loaded files (kernel image path, initramfs path)
#
#   3. On every boot, the TPM remeasures those components and compares
#      the values to the stored policy. If they match, the TPM releases
#      the LUKS key and the disk decrypts automatically. If they don't
#      match (e.g., because the kernel was updated), the TPM refuses,
#      and the user is dropped to a passphrase prompt.
#
# THE PROBLEM: Installing a kernel update changes the files that PCRs 8
# and 9 measure (kernel image path, initramfs). After installation, the
# PCR values will be DIFFERENT on the next boot. The old Clevis policy will
# no longer match, and auto-unlock will fail.
#
# THE SOLUTION (THREE PHASES):
# ─────────────────────────────
#   Phase 1 (before the update):
#     Create a TEMPORARY "loose" Clevis slot bound only to PCR 7 (Secure
#     Boot state). PCR 7 doesn't change during a kernel update — it only
#     changes if Secure Boot keys change. Then remove the strict slot
#     (it would fail after the update anyway) and update the kernel. This
#     script does NOT reboot; it prints a REBOOT REQUIRED warning. The
#     operator reboots when ready, which is what advances to Phase 2.
#
#   Phase 2 (first boot after the update — new kernel):
#     The loose slot (PCR 7 only) still unseals in the new boot state.
#     Use it to authorize enrollment of a NEW strict Clevis slot with the
#     NEW PCR values, then VERIFY that new slot actually unlocks the volume
#     in place (a real unlock test, not just a TPM unseal). The disk is now
#     fully protected. A reboot is still recommended — again warned, never
#     automatic — so Phase 3 can confirm the slot survives a cold power cycle.
#
#   Phase 3 (second boot after the update — final verification):
#     Confirm the new strict slot still unlocks the volume after a full cold
#     boot. If it does, remove the loose temp slot (now unnecessary) and clean
#     up. No reboot follows.
#
# NO AUTOMATIC REBOOTS:
# ─────────────────────
# This script NEVER reboots the machine. Whenever the next phase needs a fresh
# boot, it prints a large REBOOT REQUIRED banner (see warn_reboot_required) and
# flags /run/reboot-required, then exits. The operator decides when to reboot.
# Because the new binding is verified to unlock IN PLACE at enrollment time, the
# disk is never left depending on an un-tested slot while waiting for that boot.
#
# BOOT-LOOP SAFETY:
# ─────────────────
# The systemd unit that automates Phases 2 and 3 DISABLES ITSELF as its
# very first action before doing any work. This means: if Phase 2 fails,
# the system stays booted and the unit does not run again on the next boot.
# A human can inspect the situation and fix it manually. Without this guard,
# a failure would re-run every boot indefinitely.
#
# USAGE:
#   sudo ./kernel-update-tpm.sh [LUKSDEV] [STRICT_PCR_IDS]
#   sudo ./kernel-update-tpm.sh --hook-mode   (called by APT hook only)
#
#   LUKSDEV        LUKS2 block device path. Auto-detected if not given.
#   STRICT_PCR_IDS Comma-separated PCR IDs for the strict policy.
#                  Auto-detected from current Clevis binding, or defaults
#                  to 0,1,2,4,5,7,8,9 if no existing binding found.
#
# EXAMPLES:
#   sudo ./kernel-update-tpm.sh
#   sudo ./kernel-update-tpm.sh /dev/nvme0n1p3
#   sudo ./kernel-update-tpm.sh /dev/nvme0n1p3 0,1,2,4,5,7,8,9
#
# APT HOOK MODE:
#   When installed with the companion files (kernel-update-tpm-apt-hook
#   and 90-kernel-update-tpm.conf), APT automatically calls this script
#   with --hook-mode before any kernel-related package installation.
#   In hook-mode, Phase 1 prepares the loose slot then exits — APT
#   handles the actual package installation and a Post-Invoke hook prints a
#   REBOOT REQUIRED warning (and flags /run/reboot-required) after apt
#   finishes. It does NOT reboot; the operator reboots to run Phase 2.
# ════════════════════════════════════════════════════════════════════════════

# ════════════════════════════════════════════════════════════════════════════
# AUTO-DETECTION HELPERS
# (These run before any argument parsing; they do not require root)
# ════════════════════════════════════════════════════════════════════════════

# detect_luks_device()
#
# Scans the currently active block devices to find one that is:
#   1. An active dm-crypt (decrypted) mapping — meaning the disk is unlocked
#      and currently in use as the root or home partition.
#   2. Backed by a LUKS container (any version).
#   3. Specifically LUKS version 2 — Clevis uses LUKS2-only token features.
#
# Returns: the device path (e.g., /dev/nvme0n1p3) on stdout.
# Exits non-zero and prints nothing if no suitable device is found.
detect_luks_device() {
  # "local" limits these variables to this function's scope. Without "local",
  # variables set inside a function leak into the global scope and can
  # accidentally overwrite variables in the main script or other functions.
  local crypt_name parent_dev

  # This while loop reads the output of "lsblk" and "awk" line by line.
  #
  # "lsblk -rno NAME,TYPE" lists block devices:
  #   -r  Raw output format (no box-drawing characters, machine-readable)
  #   -n  No headers (don't print column names)
  #   -o NAME,TYPE  Only print the NAME and TYPE columns
  #   Output example:
  #     nvme0n1    disk
  #     nvme0n1p1  part
  #     nvme0n1p2  part
  #     nvme0n1p3  part
  #     dm-0       crypt   ← this is an open LUKS/dm-crypt mapping
  #
  # "awk '$2=="crypt" {print $1}'" filters that output:
  #   $2  Second field (the TYPE column)
  #   $1  First field (the NAME column)
  #   Only prints names where TYPE is "crypt" (active dm-crypt mappings)
  #   Output: "dm-0" (or whatever the mapper name is)
  #
  # "while IFS= read -r crypt_name" reads one name per loop iteration.
  #   IFS=  Clears the Internal Field Separator so leading/trailing whitespace
  #         in the line is preserved exactly as-is (important for filenames
  #         that might have spaces, though unlikely for device names).
  #   -r    Raw mode: backslashes in input are treated as literal characters,
  #         not as escape sequences.
  #
  # "< <(...)" is process substitution. The <(...) part runs the inner
  # commands and makes their output available as if it were a file. The
  # outer "<" redirects that "file" as stdin to the while loop. This is
  # preferred over piping into while (cmd | while ...) because piping
  # would run the while loop in a subshell, making variable assignments
  # inside the loop invisible to the outer script.
  while IFS= read -r crypt_name; do

    # For each active crypt device (e.g., "dm-0"), find its parent device.
    # "lsblk -rno PKNAME /dev/mapper/dm-0" shows the parent kernel device name.
    # For dm-0 that was opened from /dev/nvme0n1p3, this outputs "nvme0n1p3".
    # "| head -1" takes only the first result (prevents issues if there are
    # multiple results, though that's unusual for a single crypt target).
    # "2>/dev/null" redirects stderr to /dev/null, suppressing error messages
    # if the device doesn't exist or lsblk fails.
    parent_dev=$(lsblk -rno PKNAME "/dev/mapper/${crypt_name}" 2>/dev/null | head -1)

    # Fallback: lsblk PKNAME returns empty when the mapper name uses a scheme
    # that includes the partition name (e.g., "nvme0n1p3_crypt"). In that case,
    # resolve the parent by following the /dev/mapper symlink to its dm-N device
    # node and reading /sys/block/dm-N/slaves/, which is always authoritative.
    if [[ -z "$parent_dev" ]]; then
      local dm_path dm_base
      dm_path=$(readlink -f "/dev/mapper/${crypt_name}" 2>/dev/null)
      dm_base="${dm_path##*/}"
      parent_dev=$(ls "/sys/block/${dm_base}/slaves/" 2>/dev/null | head -1)
    fi

    # [[ -z "$parent_dev" ]] tests if the variable is an empty string.
    # If both methods returned nothing, skip to the next crypt device.
    [[ -z "$parent_dev" ]] && continue

    # Build the full device path by prepending "/dev/".
    # lsblk gives us "nvme0n1p3" but we need "/dev/nvme0n1p3" to use it.
    local dev="/dev/${parent_dev}"

    # [[ -b "$dev" ]] tests if the path is a "block device" special file.
    # Block devices represent storage (disks, partitions). If it's not a
    # block device (e.g., the path doesn't exist), skip this iteration.
    # "|| continue" means "if the test fails (non-zero), continue the loop".
    # (We use || continue rather than relying on set -e here because we
    # WANT to continue, not exit the script, when a device isn't suitable.)
    [[ -b "$dev" ]] || continue

    # "cryptsetup isLuks $dev" checks if the device contains a LUKS header.
    # Exit code 0 = yes, it's LUKS. Non-zero = not LUKS.
    # "2>/dev/null" suppresses the "Not a LUKS device" error message from
    # cryptsetup — we handle the failure with "|| continue" (try next device).
    cryptsetup isLuks "$dev" 2>/dev/null || continue

    # Confirm LUKS version 2. Clevis uses features (JSON token metadata)
    # that only exist in LUKS2. LUKS1 uses a different token storage format.
    #
    # "cryptsetup luksDump $dev" prints the full LUKS header metadata.
    # "awk '/^Version:/ {print $2; exit}'" finds the line starting with
    # "Version:" and prints the second field (the version number), then exits.
    # The ^ anchor matches only lines starting with "Version:" to avoid
    # false matches on other lines containing that word.
    local ver
    ver=$(cryptsetup luksDump "$dev" 2>/dev/null | awk '/^Version:/ {print $2; exit}')

    # String comparison: if the version is not "2", skip this device.
    [[ "$ver" == "2" ]] || continue

    # All checks passed. Print the device path and return success (exit code 0).
    echo "$dev"
    return 0

  done < <(lsblk -rno NAME,TYPE | awk '$2=="crypt" {print $1}')

  # If the loop ended without returning, no suitable device was found.
  # Return exit code 1 (failure) so the caller knows to report an error.
  return 1
}

# detect_strict_pcr_ids()
#
# Reads the Clevis policy JSON stored in the LUKS2 token metadata and
# extracts the "pcr_ids" field value (e.g., "0,1,2,4,5,7,8,9").
#
# Why do this? So that the script defaults to "whatever PCR policy is
# already in use on this device" rather than requiring the user to know
# and manually specify it. If you enrolled with 0,1,2,7 originally, this
# function detects that and the script will re-enroll with 0,1,2,7.
#
# Takes: $1 = the LUKS device path
# Returns: the PCR IDs string on stdout, or empty string if none found
detect_strict_pcr_ids() {
  local dev="$1"  # Store first argument in a named variable for clarity

  # "clevis luks list -d $dev" lists all Clevis bindings on the device.
  # Output example:
  #   1: tpm2 '{"hash":"sha256","key":"ecc","pcr_bank":"sha256","pcr_ids":"0,1,2,7"}'
  #   3: tang '{"url":"https://tang.example.com"}'
  #
  # "| grep tpm2" filters to only lines containing "tpm2" (TPM2 bindings).
  # We skip "tang" (network-based) and other pin types because we want the
  # PCR policy, which is a TPM2-specific concept.
  #
  # "| grep -oP '"pcr_ids"\s*:\s*"\K[^"]+'"
  #   -o  Print only the matched portion, not the whole line.
  #   -P  Use Perl-compatible regex (needed for \s, \K, and lookahead/behind).
  #   The pattern:
  #     "pcr_ids"    — literal text to match
  #     \s*:\s*      — a colon with optional whitespace on either side
  #     "            — opening double-quote
  #     \K           — "forget" everything matched so far; don't include it
  #                    in the output. \K is a Perl regex feature that resets
  #                    the start of the reported match.
  #     [^"]+        — one or more characters that are NOT a closing quote
  #                    This captures the PCR IDs, e.g. "0,1,2,4,5,7,8,9"
  #   Result: just the PCR IDs string.
  #
  # "| head -1" returns only the first match, in case there are multiple
  # TPM2 slots with different PCR policies. We take the first (lowest
  # slot number) as the "canonical" strict policy.
  #
  # "2>/dev/null" suppresses errors if the device has no Clevis metadata.
  clevis luks list -d "$dev" 2>/dev/null \
    | grep tpm2 \
    | grep -oP '"pcr_ids"\s*:\s*"\K[^"]+' \
    | head -1
}

# ════════════════════════════════════════════════════════════════════════════
# EARLY HELPER FUNCTIONS
# (These are needed before argument parsing)
# ════════════════════════════════════════════════════════════════════════════

# log() — informational output
# "$*" expands all function arguments as a single space-joined string.
# Using "$*" (not "$@") is intentional: log messages are one string,
# not an array of distinct arguments to be processed separately.
log() { echo "[kernel-update-tpm] $*"; }

# err() — informational output, but written to STDERR instead of STDOUT.
# Use this inside any function whose STDOUT is captured by the caller via
# "$(...)". A normal log() line would be swallowed into that captured value
# and corrupt it (e.g. a slot number would come back as several lines).
# The apt hook and the terminal capture BOTH streams, so err() messages
# still appear in /var/log/kernel-update-tpm-apt-hook.log and on screen —
# they simply stay out of the command-substitution result.
err() { echo "[kernel-update-tpm] $*" >&2; }

# show_slot_status() — display current Clevis bindings (and optionally LUKS keyslots)
#
# Takes:
#   $1 = label prefix (e.g., "Current" or "Final")
#   $2 = LUKS device path
#   $3 = show_luks (optional, default 0): set to 1 to also display LUKS keyslots
#
# Default behavior: shows only Clevis bindings
# With show_luks=1: shows Clevis bindings + LUKS keyslot summary
show_slot_status() {
  local label="$1" dev="$2" show_luks="${3:-0}"
  echo ""
  log "$label Clevis bindings:"
  clevis luks list -d "$dev" 2>/dev/null || echo "[kernel-update-tpm] (no Clevis bindings)"

  if [[ "$show_luks" -eq 1 ]]; then
    echo ""
    log "$label LUKS keyslots:"
    cryptsetup luksDump "$dev" | sed -n '/Keyslots:/,/Tokens:/p' | head -n -1
  fi
  echo ""
}

# ════════════════════════════════════════════════════════════════════════════
# ARGUMENT PARSING AND CONFIGURATION
# ════════════════════════════════════════════════════════════════════════════

# HOOK_MODE: tracks whether we were called by the APT hook.
# 0 = normal user invocation; 1 = APT hook called us.
# When HOOK_MODE=1, Phase 1 exits early (after preparing the loose slot)
# and lets APT continue with package installation itself. APT then registers
# a Post-Invoke hook that prints a REBOOT REQUIRED warning (no automatic
# reboot). In normal mode (0), this script runs apt-get directly.
#
# Why have a hook mode at all?
#   When APT calls a Pre-Install-Pkgs hook, the hook must EXIT before APT
#   can proceed with package installation. We cannot run apt-get upgrade
#   FROM INSIDE the hook — that would be APT trying to call itself, which
#   causes a deadlock. So in hook mode, we do everything that must happen
#   BEFORE packages install (create the loose slot), then return control
#   to APT to do the installation.
# ── Help ───────────────────────────────────────────────────────────────────
if [[ "${1:-}" == "--help" || "${1:-}" == "-h" ]]; then
  cat <<'USAGE'
Usage: kernel-update-tpm.sh [OPTIONS] [LUKSDEV [STRICT_PCR_IDS]]

  LUKSDEV         LUKS2 block device (e.g., /dev/nvme0n1p3).
                  Auto-detected from active dm-crypt mappings if omitted.
  STRICT_PCR_IDS  Comma-separated PCR IDs for the strict policy.
                  If omitted, auto-detected from an existing Clevis binding.
                  In interactive manual mode you will be prompted to confirm
                  or change the detected policy (see Binding policy below).
                  Supplying this argument skips the prompt entirely.

Options:
  --hook-mode     APT hook internal flag — Phase 1 exits early for APT to
                  continue. Suppresses all interactive prompts.
  --help, -h      Show this help and exit.

Phases:
  1  Bind temp loose slot (PCR 7), remove strict slot, update kernel, then WARN
     that a reboot is required (no automatic reboot).
  2  Confirm new kernel booted, re-enroll strict slot against new PCRs, verify it
     unlocks the volume in place, then WARN that a reboot is required.
  3  Verify strict slot unlocks after a full cold boot, remove temp slot, clean up.

This script never reboots on its own. When the next phase needs a fresh boot it
prints a large REBOOT REQUIRED banner and flags /run/reboot-required; you reboot
when ready and the systemd resume unit runs the next phase.

The script resumes the correct phase automatically from the sentinel file:
  /var/lib/kernel-update-tpm/state
If no sentinel exists, Phase 1 runs.

Binding policy (interactive manual mode only):
  At Phase 1 start you are offered four choices for the strict slot PCR set:
  [k]  Keep     — use the PCR set detected from the existing binding
  [s]  Strict   — 0,1,2,4,5,7,8,9 (firmware, GRUB, Secure Boot, kernel)
  [l]  Loose    — 7 only (Secure Boot state; survives kernel updates)
  [c]  Custom   — enter your own comma-separated PCR list
  The chosen policy is written to the sentinel and used by Phases 2 and 3.
  In hook mode or non-interactive invocation the detected/default value is
  used without prompting.

Interactive recovery prompts (suppressed in hook/non-interactive mode):
  Phase 1 — TPM unseal fails (PCRs changed since slot was created):
    [r] re-enroll from LUKS passphrase against current PCR state (no reboot)
    [q] quit and inspect TPM state
  Phase 2 — kernel version unchanged after reboot:
    [r] restore strict slot from temp slot immediately (no reboot)
    [q] quit, fix boot config, then reboot
  Phase 2/3 — sentinel age exceeds 30 minutes:
    [c] reset the age timer and continue
    [q] abort and inspect the sentinel manually
USAGE
  exit 0
fi

HOOK_MODE=0

# Check if the first argument is "--hook-mode".
# "${1:-}" is a safe expansion: if $1 is unset (no arguments), this
# expands to an empty string rather than triggering the -u (nounset) error.
if [[ "${1:-}" == "--hook-mode" ]]; then
  HOOK_MODE=1
  # "shift" removes $1 from the argument list. After this, the original
  # $2 becomes $1, $3 becomes $2, etc. This lets the remaining argument
  # parsing code work normally without needing to know --hook-mode was passed.
  shift
fi

# Store positional arguments with safe defaults.
# "${1:-}" expands to $1 if set, or empty string if not (avoids nounset error).
# "${2:-}" does the same for the second argument.
_ARG_LUKSDEV="${1:-}"
_ARG_PCR_IDS="${2:-}"

# ── Resolve LUKS device ───────────────────────────────────────────────────
if [[ -n "$_ARG_LUKSDEV" ]]; then
  # [[ -n "$var" ]] is true if the variable is non-empty (has a value).
  # The user provided a device path; use it directly.
  LUKSDEV="$_ARG_LUKSDEV"
else
  # No device path given. Try auto-detection.
  #
  # The "$(...)|| { ... }" pattern:
  #   $(...) runs detect_luks_device in a subshell and captures its output.
  #   If detect_luks_device exits non-zero (failure), the "||" triggers
  #   the error block on the right side. We print a helpful message and exit.
  #
  # ">&2" redirects the error message to stderr (file descriptor 2).
  # Error messages go to stderr by convention so that when stdout is
  # redirected to a file or pipe, errors still appear in the terminal.
  LUKSDEV=$(detect_luks_device) \
    || { echo "ERROR: could not auto-detect an active LUKS2 device. Pass it explicitly: sudo $0 /dev/nvme0n1p3" >&2; exit 1; }
  echo "[kernel-update-tpm] Auto-detected LUKS device: $LUKSDEV"
fi

# SENTINEL defined early so the PCR resolution block can peek at it.
# Full explanation of this path is in the sentinel section below.
SENTINEL="/var/lib/kernel-update-tpm/state"

# ── Resolve strict PCR IDs ────────────────────────────────────────────────
#
# Check for an in-progress update before showing the binding policy prompt.
# If Phase 2 or 3 is already pending, the user is resuming — not starting
# Phase 1. Showing the binding prompt in that context misleads them into
# thinking they are choosing a policy for a fresh run. Instead, show the
# sentinel state and offer to continue, abandon, or quit.
#
# _ABANDON_PHASE2=1 is set when the user chooses [a]; the main dispatch at
# the bottom of this script calls reenroll_from_passphrase() directly
# instead of dispatching to phase_2/phase_3.
_PENDING_PHASE=0
_ABANDON_PHASE2=0
_abandon_temp_slot=""
_pcr_has_existing=0
_show_binding_prompt=0

if [[ -f "$SENTINEL" ]]; then
  _PENDING_PHASE=$(grep -E "^phase=" "$SENTINEL" | sed "s/^phase=//" | head -1 || echo 0)
fi

if [[ -n "$_ARG_PCR_IDS" ]]; then
  STRICT_PCR_IDS="$_ARG_PCR_IDS"
elif [[ "$_PENDING_PHASE" -ge 2 ]]; then
  STRICT_PCR_IDS=$(grep -E "^strict_pcr_ids=" "$SENTINEL" | sed "s/^strict_pcr_ids=//" | head -1 || true)
  STRICT_PCR_IDS="${STRICT_PCR_IDS:-0,1,2,4,5,7,8,9}"
  if [[ "$HOOK_MODE" -eq 0 && -t 1 ]]; then
    echo ""
    log "=== CURRENT SYSTEM STATUS ==="
    show_slot_status "Current" "$LUKSDEV"
    echo ""
    echo "[kernel-update-tpm] Phase ${_PENDING_PHASE} update in progress."
    echo "[kernel-update-tpm] Strict PCR policy from Phase 1: ${STRICT_PCR_IDS}"
    echo ""
    echo "  [c]  Continue Phase ${_PENDING_PHASE} (recommended)"
    echo "  [a]  Abandon — choose a new binding and re-enroll from passphrase"
    echo "  [q]  Quit"
    echo ""
    read -r -p "Choice [c/a/q, default=c]: " _phase_choice
    case "${_phase_choice:-c}" in
      c|C) ;;
      a|A)
        _abandon_temp_slot=$(grep -E "^temp_slot=" "$SENTINEL" | sed "s/^temp_slot=//" | head -1 || true)
        _ABANDON_PHASE2=1
        _show_binding_prompt=1
        ;;
      *) echo "[kernel-update-tpm] Quit — no changes made."; exit 0 ;;
    esac
    echo ""
  fi
else
  STRICT_PCR_IDS=$(detect_strict_pcr_ids "$LUKSDEV") || true
  if [[ -n "$STRICT_PCR_IDS" ]]; then
    _pcr_has_existing=1
  else
    #   0,1,2  Firmware code and config      (stable unless firmware updated)
    #   4,5    Bootloader code and config    (stable unless GRUB updated)
    #   7      Secure Boot state             (stable; changes only on MOK/SB changes)
    #   8,9    GRUB commands + kernel files  (change on every kernel update)
    STRICT_PCR_IDS="0,1,2,4,5,7,8,9"
  fi
  if [[ "$HOOK_MODE" -eq 0 && -t 1 ]]; then
    _show_binding_prompt=1
    echo ""
    log "=== CURRENT SYSTEM STATUS ==="
    show_slot_status "Current" "$LUKSDEV"
    echo ""
    if [[ "$_pcr_has_existing" -eq 1 ]]; then
      echo "[kernel-update-tpm] Auto-detected PCRs from existing binding: $STRICT_PCR_IDS"
    else
      echo "[kernel-update-tpm] No existing Clevis binding found."
    fi
  else
    if [[ "$_pcr_has_existing" -eq 1 ]]; then
      echo "[kernel-update-tpm] Auto-detected strict PCRs from existing binding: $STRICT_PCR_IDS"
    else
      echo "[kernel-update-tpm] No existing Clevis policy found — using default strict PCRs: $STRICT_PCR_IDS"
    fi
  fi
fi

if [[ "$_show_binding_prompt" -eq 1 ]]; then
  echo ""
  echo "  Binding policy for the strict slot:"
  if [[ "$_pcr_has_existing" -eq 1 && "$_ABANDON_PHASE2" -eq 0 ]]; then
    printf "  [k]  Keep current  %-26s (same PCR set as existing binding)\n" \
           "($STRICT_PCR_IDS)"
  fi
  printf "  [s]  Strict        %-26s (pins firmware, GRUB, Secure Boot + kernel;\n" \
         "(0,1,2,4,5,7,8,9)"
  echo   "                                              requires re-enrollment on kernel updates)"
  printf "  [l]  Loose         %-26s (Secure Boot state only;\n" "(7)"
  echo   "                                              survives kernel updates without re-enrollment)"
  echo   "  [c]  Custom        enter your own comma-separated PCR list"
  echo   "  [q]  Quit          exit without making any changes"
  echo ""
  if [[ "$_pcr_has_existing" -eq 1 && "$_ABANDON_PHASE2" -eq 0 ]]; then
    read -r -p "Choice [k/s/l/c/q, default=k]: " _pcr_choice
    _pcr_choice="${_pcr_choice:-k}"
  else
    read -r -p "Choice [s/l/c/q, default=s]: " _pcr_choice
    _pcr_choice="${_pcr_choice:-s}"
  fi
  case "$_pcr_choice" in
    k|K) : ;;
    s|S) STRICT_PCR_IDS="0,1,2,4,5,7,8,9" ;;
    l|L) STRICT_PCR_IDS="7" ;;
    c|C)
      read -r -p "Enter comma-separated PCR IDs (e.g. 0,1,7): " STRICT_PCR_IDS
      if [[ ! "$STRICT_PCR_IDS" =~ ^[0-9]+(,[0-9]+)*$ ]]; then
        echo "[kernel-update-tpm] FAIL: Invalid PCR IDs: '$STRICT_PCR_IDS'. Expected comma-separated integers." >&2
        exit 1
      fi
      ;;
    q|Q) echo "[kernel-update-tpm] Quit — no changes made."; exit 0 ;;
    *)
      echo "[kernel-update-tpm] Unrecognised choice '$_pcr_choice' — using default." >&2
      ;;
  esac
  echo ""
  echo "[kernel-update-tpm] Using strict PCR policy: $STRICT_PCR_IDS"
fi

# ── Fallback (loose) PCR policy ───────────────────────────────────────────
#
# FALLBACK_PCR_IDS: the PCR list used for the TEMPORARY slot created in Phase 1.
#
# We use ONLY PCR 7 (Secure Boot state) for the temp slot because:
#   - PCR 7 does NOT change during a kernel update. It only changes if:
#       a) Secure Boot is toggled on/off
#       b) MOK (Machine Owner Keys) certificates are added/removed
#   - PCRs 8 and 9 DO change after a kernel update (new kernel files are
#     measured). If the temp slot used those PCRs, it would ALSO stop
#     working after the update — defeating the purpose of having a temp slot.
#   - The temp slot is intentionally "loose" — it accepts ANY kernel as
#     long as Secure Boot is in the same state. This is acceptable because
#     the temp slot is only present for a short time (two reboots), and
#     the system is protected by Secure Boot itself preventing unsigned kernels.
FALLBACK_PCR_IDS="7"

# Build JSON policy strings for Clevis.
# Clevis expects the policy as a JSON object:
#   { "pcr_bank": "sha256", "pcr_ids": "0,1,2,4,5,7,8,9" }
#
# "pcr_bank" specifies the hash algorithm used for PCR measurements.
# SHA-256 is the modern standard. SHA-1 is deprecated for this purpose.
# (Some older firmware supports only SHA-1 bank; sha256 bank requires
# firmware support for SHA-256 PCR measurements.)
#
# The backslash-escaped quotes (\") embed literal double-quote characters
# inside the double-quoted string. The result is valid JSON.
STRICT_POLICY="{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"${STRICT_PCR_IDS}\"}"
FALLBACK_POLICY="{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"${FALLBACK_PCR_IDS}\"}"

# ── Sentinel file path ────────────────────────────────────────────────────
#
# The sentinel is a key=value text file that tracks the state of an
# in-progress three-phase operation across multiple reboots.
#
# It records: which phase to run next, the LUKS device, PCR policy, and
# the slot numbers created/removed in earlier phases.
#
# Location: /var/lib/ is the standard Linux directory for persistent
# program state that must survive reboots. This file MUST survive
# reboots because Phase 2 and Phase 3 read it to know what to do.
# (Compare: /tmp/ or /run/ would be lost on reboot.)
#
# Permissions: written as 0600 (root read/write only) because it contains
# LUKS device paths and slot numbers. Not sensitive enough to be a secret,
# but no need for other users to read it.
# (SENTINEL is assigned earlier, before the PCR resolution block.)

# ── Systemd unit name and path ────────────────────────────────────────────
#
# The resume unit is a systemd service that runs THIS SCRIPT automatically
# after each reboot during the three-phase process. It checks the sentinel
# to determine which phase to run.
#
# /etc/systemd/system/ is where locally-installed unit files live.
# Files here take precedence over system-provided units in /lib/systemd/system/.
UNIT_NAME="kernel-update-tpm-resume.service"
UNIT_PATH="/etc/systemd/system/${UNIT_NAME}"

# ── Post-Invoke reboot-warning drop-in path ───────────────────────────────
#
# Hook-mode Phase 1 writes a one-shot APT drop-in here that prints the
# "REBOOT REQUIRED" banner (see install of it further below). We keep the
# path in a constant because THREE places need it: the code that writes it,
# and the two completion paths (Phase 3 end and the restore-from-temp path)
# that must DELETE it so a finished transition can never emit a stale banner.
#
# Subtle timing fact that drives the whole design: APT parses everything in
# /etc/apt/apt.conf.d/ ONCE, at process startup. A drop-in that Phase 1 writes
# from inside a Pre-Install-Pkgs hook is therefore NOT part of the running
# apt's config — it first takes effect on the NEXT apt invocation of any kind.
# So this banner always fires one apt-run late, on whatever unrelated command
# (e.g. "apt remove keepass2") happens to run next. That is expected; the two
# fixes below make sure it can only fire while a transition is genuinely
# pending, and that a completed run leaves no drop-in behind at all.
REBOOT_WARNING_DROPIN="/etc/apt/apt.conf.d/99-kernel-update-tpm-reboot"

# SCRIPT_PATH: the absolute path to this script, resolved by "realpath".
# "realpath" resolves symlinks and relative paths to a canonical absolute path.
# "$0" is the path to the script as invoked (could be "./kernel-update-tpm.sh"
# or a relative path). We need the absolute path to write into the systemd
# unit file's ExecStart= line — systemd requires absolute paths.
SCRIPT_PATH="$(realpath "$0")"

# MAX_PHASE_AGE_MINUTES: how many minutes Phase 2 or Phase 3 can wait
# before we consider something wrong and abort.
#
# Normal flow: Phase 1 ends with a reboot, and Phase 2 starts within
# minutes. If Phase 2 has been waiting for more than 30 minutes, something
# unexpected happened (user held the system up, system failed to auto-start
# the unit, etc.). Aborting in that case is safer than blindly proceeding
# with a stale sentinel that might not reflect the system's current state.
MAX_PHASE_AGE_MINUTES=30

# ════════════════════════════════════════════════════════════════════════════
# HELPER FUNCTIONS
# ════════════════════════════════════════════════════════════════════════════

# need_cmd() — verify a required external command is installed
#
# "command -v $1" searches PATH for the named command and prints its path.
# It exits 0 (success) if found, non-zero if not found.
# ">/dev/null 2>&1" discards both stdout and stderr — we only care about
# the exit code, not the output.
#
# The "||" short-circuits: if command -v fails, run the block on the right.
# The block prints an error to stderr and exits with code 1.
#
# Checking dependencies upfront prevents confusing mid-operation failures.
# It is much better to fail with "Missing required command: clevis" before
# touching any LUKS slots than to fail halfway through with a cryptic
# "command not found" error.
need_cmd() {
  command -v "$1" >/dev/null 2>&1 || {
    echo "ERROR: missing required command: $1" >&2
    exit 1
  }
}

# fail() — fatal error: print message to stderr, exit non-zero
# ">&2" redirects this echo's output to stderr (file descriptor 2).
# By convention, error messages go to stderr so they appear in the terminal
# even when the script's stdout is captured or piped.
fail() { echo "[kernel-update-tpm] FAIL: $*" >&2; exit 1; }

# require_root() — ensure the script is running as root
#
# $EUID is a bash built-in variable: the Effective User ID of the running process.
# Root has EUID 0. Regular users typically have UIDs starting at 1000.
# Using $EUID is faster and more reliable than running "id -u" or "whoami".
# -eq is an arithmetic comparison operator (equal).
require_root() {
  [[ "${EUID}" -eq 0 ]] || fail "Run as root: sudo $0"
}

# ════════════════════════════════════════════════════════════════════════════
# SENTINEL MANAGEMENT FUNCTIONS
#
# The sentinel file records state between reboots. These functions create,
# read, update, and delete it.
# ════════════════════════════════════════════════════════════════════════════

# write_sentinel() — create the sentinel file from scratch
#
# Arguments:
#   $1      = phase number to write
#   $2...$N = extra "key=value" pairs to include in the file
#
# The sentinel is a simple line-by-line key=value file:
#   phase=2
#   luksdev=/dev/nvme0n1p3
#   strict_pcr_ids=0,1,2,4,5,7,8,9
#   created_at=2026-06-15T10:30:00+00:00
#   phase_started_at=2026-06-15T10:30:00+00:00
#   kernel_at_phase1=6.1.0-28-amd64
#   temp_slot=3
#   strict_slot_removed=1
#
# This format is easy to parse line-by-line with grep and sed, and is
# human-readable when inspected manually (e.g., "cat /var/lib/kernel-update-tpm/state").
write_sentinel() {
  local phase="$1"; shift
  # "$@" now contains the extra key=value pairs passed after the phase argument.
  # "shift" removed $1 (the phase) so $@ is just the extras.

  # Create the directory if it doesn't exist.
  # "mkdir -p" creates the directory and all parent directories as needed.
  # "-p" also suppresses the error that would occur if the directory already exists.
  # "$(dirname "$SENTINEL")" extracts the directory portion of the path
  # (/var/lib/kernel-update-tpm) from the full file path.
  mkdir -p "$(dirname "$SENTINEL")"

  # Write the sentinel using a heredoc. "cat > "$SENTINEL" <<EOF" redirects
  # the heredoc content into the file, overwriting it if it exists.
  #
  # Variables inside the heredoc are expanded (interpolated) at write time.
  # So $(date --iso-8601=seconds) runs the date command and inserts the result.
  #
  # "date --iso-8601=seconds" outputs an ISO 8601 timestamp like:
  #   2026-06-15T10:30:00+00:00
  # This format is unambiguous, sortable, and parseable by "date --date=...".
  #
  # "uname -r" prints the running kernel version (e.g., "6.1.0-28-amd64").
  # We record this in Phase 1 so Phase 2 can confirm the kernel changed.
  #
  # The last block: "$( IFS=$'\n'; for kv in "$@"; do echo "$kv"; done )"
  # This is a command substitution that prints each extra key=value pair
  # on its own line. IFS=$'\n' makes word splitting use newlines, but the
  # for loop iterates over "$@" which is already one-item-per-argument.
  # The overall effect: each "temp_slot=3", "strict_slot_removed=1", etc.
  # is written on its own line in the file.
  cat > "$SENTINEL" <<EOF
phase=${phase}
luksdev=${LUKSDEV}
strict_pcr_ids=${STRICT_PCR_IDS}
created_at=$(date --iso-8601=seconds)
phase_started_at=$(date --iso-8601=seconds)
kernel_at_phase1=$(uname -r)
$( IFS=$'\n'; for kv in "$@"; do echo "$kv"; done )
EOF
  # Set permissions: 0600 = read/write for owner (root), no access for
  # group or others. Prevents regular users from reading the sentinel.
  chmod 0600 "$SENTINEL"
}

# update_sentinel() — change a single key in the existing sentinel
#
# Used to update one field (e.g., "phase=2" → "phase=3") without
# rewriting the entire file.
#
# Arguments:
#   $1 = key name (e.g., "phase")
#   $2 = new value (e.g., "3")
#
# "sed -i" edits the file in-place (modifies the file directly rather
# than printing to stdout). The -i flag means "in-place edit".
#
# "s|^${key}=.*|${key}=${val}|" is a substitution pattern:
#   s       — substitute command
#   |       — delimiter (we use | instead of / to avoid escaping slashes
#             in values that might be file paths like /dev/nvme0n1p3)
#   ^${key}= — match lines starting (^) with "key="
#   .*      — match anything after the "=" on that line
#   |${key}=${val}| — replace the whole match with "key=newval"
#
# This finds the line for the given key and rewrites just that line.
update_sentinel() {
  local key="$1" val="$2"
  sed -i "s|^${key}=.*|${key}=${val}|" "$SENTINEL"
}

# read_sentinel_value() — extract a single value from the sentinel
#
# Arguments:
#   $1 = key name to look up
#
# Returns: the value on stdout, or empty string if key not found.
#
# "grep -E "^$1=" "$SENTINEL"" finds lines starting with "key=".
#   -E  Extended regex mode (allows + and other extended patterns).
#   ^   Start of line anchor (ensures we match the key, not a value
#       that happens to contain "key=").
#
# "| sed "s/^$1=//"" strips the "key=" prefix, leaving just the value.
#   s/^$1=//  Replace "key=" at the start of the line with nothing.
#
# "| head -1" returns only the first match in case of duplicate keys.
read_sentinel_value() {
  grep -E "^$1=" "$SENTINEL" | sed "s/^$1=//" | head -1
}

# sentinel_age_minutes() — how many minutes old is the current phase?
#
# Used as a guard in Phase 2 and Phase 3: if too much time has passed
# since the phase was recorded, something is wrong and we should abort
# rather than proceed with potentially stale information.
#
# Reads "phase_started_at" from the sentinel (updated at the start of
# each phase), converts it to Unix epoch seconds, and compares to now.
sentinel_age_minutes() {
  local started
  started=$(read_sentinel_value phase_started_at)
  # Parse the ISO 8601 timestamp into Unix epoch seconds.
  # "date --date="$started" +%s" converts the timestamp string to seconds
  # since the Unix epoch (1970-01-01 00:00:00 UTC). This is a large integer
  # that is easy to do arithmetic on.
  # "2>/dev/null" suppresses errors if the timestamp is malformed.
  # "|| { echo 9999; return; }" provides a fallback if date fails:
  # return 9999 (effectively "very old") to trigger the age check guard
  # and abort safely rather than proceeding with a potentially corrupt sentinel.
  local started_epoch now_epoch
  started_epoch=$(date --date="$started" +%s 2>/dev/null) || { echo 9999; return; }
  # "date +%s" prints the current time as Unix epoch seconds.
  now_epoch=$(date +%s)
  # Arithmetic: (now - started) / 60 = elapsed minutes.
  # "$(( ... ))" is bash arithmetic expansion. Integer division — fractional
  # minutes are truncated (e.g., 29.9 minutes = 29).
  echo $(( (now_epoch - started_epoch) / 60 ))
}

# ════════════════════════════════════════════════════════════════════════════
# SLOT MANAGEMENT HELPERS
# ════════════════════════════════════════════════════════════════════════════

# detect_single_clevis_slot() — resolve the device to ONE Clevis TPM2 slot
#
# Phase 1 requires EXACTLY one Clevis TPM2 slot to exist on the device.
# If zero exist: nothing to unseal from to authorize the new binding.
# If two or more exist: normally we cannot determine which is "canonical".
#
# HOWEVER, one multi-slot situation is provably safe to resolve automatically:
# redundant DUPLICATE slots. If every extra Clevis slot has a byte-identical
# policy AND every one of them currently unseals with the TPM AND a human
# passphrase slot exists, then the slots are interchangeable — keeping any one
# and removing the rest cannot reduce our ability to unlock the disk. This is
# exactly the drift that a partially-completed transition can leave behind
# (see collapse_redundant_clevis_slots below), and it is what would otherwise
# abort every subsequent apt run until a human intervened.
#
# So on multiple slots we hand off to collapse_redundant_clevis_slots(), which
# either auto-heals the safe case and returns the surviving slot, or aborts
# with a per-slot diagnostic table when the situation is genuinely ambiguous
# (differing policies, or any slot that does not unseal).
#
# Returns: the single surviving slot number on stdout.
# Fails (exits non-zero) if zero slots exist, or if multiple slots exist and
# the situation is not the provably-safe duplicate case.
#
# IMPORTANT: this function's STDOUT is captured by the caller. All progress
# and diagnostic output MUST use err() (stderr), never log() (stdout).
detect_single_clevis_slot() {
  local dev="$1"
  local slots

  # List all TPM2 slots, extracting just the slot numbers.
  # "clevis luks list -d $dev" prints one line per Clevis binding, like:
  #   1: tpm2 '{"pcr_bank":"sha256","pcr_ids":"0,1,2,7"}'
  #   3: tpm2 '{"pcr_bank":"sha256","pcr_ids":"7"}'
  #
  # "awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}'"
  #   -F:    Use ":" as the field separator. The slot number is $1 (before ":").
  #   /tpm2/ Match only lines containing "tpm2".
  #   gsub(/^[ \t]+|[ \t]+$/, "", $1)  Trim whitespace from $1.
  #     gsub(pattern, replacement, target) replaces all matches of pattern
  #     in target with replacement. ^[ \t]+ matches leading whitespace;
  #     [ \t]+$ matches trailing whitespace. Removing both = trimming.
  #   print $1  Print the slot number.
  #
  # "| sort -n"  Sort numerically so slot 2 comes before slot 10.
  #   -n = numeric sort (as opposed to lexicographic/alphabetical sort,
  #        which would put "10" before "2").
  slots=$(clevis luks list -d "$dev" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n)

  # Count how many slot numbers were found.
  # "echo "$slots" | grep -c '[0-9]'" counts lines containing a digit.
  # grep -c counts matching lines rather than printing them.
  # "|| true" prevents set -e from exiting if grep finds nothing (exits 1).
  local count
  count=$(echo "$slots" | grep -c '[0-9]' || true)

  if [[ "$count" -eq 0 ]]; then
    # -eq is arithmetic "equal to". Zero slots means no existing Clevis
    # binding exists that we can use to authorize new enrollment.
    fail "No Clevis TPM2 slot found on $dev"
  fi

  if [[ "$count" -gt 1 ]]; then
    # -gt is arithmetic "greater than". Multiple slots exist. Try to resolve
    # them: collapse_redundant_clevis_slots() auto-heals the provably-safe
    # duplicate case and prints the surviving slot, or aborts with a detailed
    # per-slot table when the situation is ambiguous. Its stdout (the surviving
    # slot number) becomes our stdout; its diagnostics go to stderr via err().
    collapse_redundant_clevis_slots "$dev" "$slots"
    return $?
  fi

  # Exactly one slot found. Print it and return success.
  echo "$slots"
}

# collapse_redundant_clevis_slots() — safely resolve multiple Clevis TPM2 slots
#
# Called only when detect_single_clevis_slot() has found MORE THAN ONE Clevis
# TPM2 slot. Decides whether the extra slots are provably-redundant duplicates
# (safe to collapse) or a genuinely ambiguous state (must abort).
#
# The "safe to auto-heal" test — ALL of these must hold:
#   1. Every slot carries a byte-identical policy (same pin + same JSON).
#   2. Every slot currently unseals with the TPM (clevis luks pass succeeds).
#   3. A human passphrase slot exists (re-verified here, immediately before
#      any deletion, as defense in depth — the caller already checked it too).
#
# When all three hold, the slots are interchangeable: whichever one we keep
# unseals right now, and the human slot remains as ultimate fallback. We keep
# the lowest-numbered slot and unbind the rest, then continue — so apt is not
# blocked by drift that we can prove is harmless.
#
# When any condition fails (policies differ, or some slot will not unseal), we
# must NOT guess which binding is authoritative. We print a per-slot table and
# fail(), leaving every slot intact for the operator to resolve by hand.
#
# Arguments:
#   $1 = LUKS device
#   $2 = newline-separated, numerically-sorted list of Clevis slot numbers
#        (guaranteed by the caller to contain two or more entries)
#
# Returns: the single surviving slot number on stdout (stderr for everything
# else, because the caller captures our stdout).
collapse_redundant_clevis_slots() {
  local dev="$1"
  local slots="$2"

  # Materialise the slot list into an array for repeated iteration.
  local -a slot_list=()
  local s
  while IFS= read -r s; do
    [[ -n "$s" ]] && slot_list+=("$s")
  done <<< "$slots"
  local total="${#slot_list[@]}"

  # ── Gather per-slot facts: policy string and live unseal status ───────
  # slot_policy[N]  — the "tpm2 '{...}'" descriptor for slot N (policy only,
  #                   with the leading "N:" prefix stripped so two identical
  #                   bindings compare equal as strings).
  # slot_unseals[N] — "yes" or "no" from an actual TPM unseal attempt.
  local -A slot_policy=()
  local -A slot_unseals=()
  local first_policy="" all_identical=1 all_unseal=1
  local keep_slot="${slot_list[0]}"   # lowest slot number (list is sorted)

  for s in "${slot_list[@]}"; do
    # Extract slot N's policy descriptor. We match the line for slot N in
    # "clevis luks list" output and strip the "N:" prefix, leaving e.g.
    #   tpm2 '{"hash":"sha256","key":"ecc","pcr_bank":"sha256","pcr_ids":"7"}'
    local policy
    policy=$(clevis luks list -d "$dev" 2>/dev/null \
      | awk -F: -v want="$s" '
          {
            n = $1; gsub(/^[ \t]+|[ \t]+$/, "", n)
            if (n == want && /tpm2/) {
              sub(/^[ \t]*[0-9]+:[ \t]*/, "")   # drop the "N:" prefix
              print
              exit
            }
          }')
    slot_policy["$s"]="$policy"

    # Test whether slot N unseals with the current TPM state. A single
    # failed attempt here is harmless (the slot normally unseals); we
    # suppress all output because we only care about the exit status.
    if clevis luks pass -d "$dev" -s "$s" >/dev/null 2>&1; then
      slot_unseals["$s"]="yes"
    else
      slot_unseals["$s"]="no"
      all_unseal=0
    fi

    # Track whether every policy string is identical to the first one seen.
    if [[ -z "$first_policy" ]]; then
      first_policy="$policy"
    elif [[ "$policy" != "$first_policy" ]]; then
      all_identical=0
    fi
  done

  # ── Emit the diagnostic table (stderr) regardless of the decision ─────
  err "Multiple Clevis TPM2 slots found on $dev ($total slots):"
  for s in "${slot_list[@]}"; do
    err "    slot ${s}: ${slot_policy[$s]}  [unseals: ${slot_unseals[$s]}]"
  done

  # ── Decide: safe auto-heal, or abort ──────────────────────────────────
  if [[ "$all_identical" -eq 1 && "$all_unseal" -eq 1 ]]; then
    # Defense in depth: independently re-confirm a human passphrase slot
    # exists RIGHT NOW, immediately before deleting anything. confirm_human_slot
    # logs via log() (stdout) and fail()s (and exits) if none is found; we
    # redirect its stdout to stderr so its confirmation line does not pollute
    # the surviving-slot number we print at the end.
    confirm_human_slot "$dev" >&2

    err "All ${total} slots share an identical policy and all unseal with the TPM."
    err "A human passphrase slot is present. These are redundant duplicates."
    err "Auto-healing: keeping slot ${keep_slot}, removing the redundant duplicate(s)."

    for s in "${slot_list[@]}"; do
      [[ "$s" == "$keep_slot" ]] && continue
      err "  Unbinding redundant Clevis slot ${s}..."
      # clevis luks unbind -f removes both the LUKS keyslot AND its token.
      # Redirect its stdout to stderr so nothing leaks into our result.
      clevis luks unbind -f -d "$dev" -s "$s" >&2 \
        || fail "Failed to unbind redundant Clevis slot ${s} on ${dev}. Resolve manually."
      err "  Slot ${s} removed."
    done

    # Re-derive the slot list and insist exactly one Clevis slot now remains.
    local remaining rcount
    remaining=$(clevis luks list -d "$dev" 2>/dev/null \
      | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
      | sort -n)
    rcount=$(echo "$remaining" | grep -c '[0-9]' || true)
    if [[ "$rcount" -ne 1 ]]; then
      fail "Auto-heal ended with ${rcount} Clevis slots on ${dev} (expected 1). Inspect manually."
    fi

    err "Auto-heal complete. Single Clevis slot remaining: ${remaining}"
    echo "$remaining"
    return 0
  fi

  # Not the safe case. Explain precisely why we will not touch anything.
  local reason
  if [[ "$all_identical" -ne 1 ]]; then
    reason="the slots carry DIFFERING policies — the authoritative one is ambiguous"
  else
    reason="at least one slot does NOT unseal with the current TPM state"
  fi
  fail "Multiple Clevis TPM2 slots on $dev and auto-heal is unsafe ($reason). Review the slot table above, then keep the correct slot and remove the rest with 'clevis luks unbind -d $dev -s <slot>'."
}

# detect_new_slot() — identify which slot was just created by comparing
#                     before and after snapshots of the slot list
#
# We cannot predict which slot number Clevis will assign for a new binding
# — LUKS assigns the next available slot. So we take a snapshot before
# binding, bind, take a snapshot after, and diff them.
#
# Arguments:
#   $1 = path to file containing the sorted slot list BEFORE binding
#   $2 = path to file containing the sorted slot list AFTER binding
#
# Returns: the new slot number on stdout.
detect_new_slot() {
  local before_file="$1" after_file="$2"
  local new

  # "comm -13 file1 file2" compares two sorted files line by line.
  # comm has three output columns:
  #   Column 1: lines only in file1 (suppressed by -1)
  #   Column 2: lines in both files (suppressed by -3)
  #   Column 3: lines only in file2 (shown)
  # So "-13" shows only lines UNIQUE TO FILE 2 (the "after" list).
  # These are the new slot numbers added by the bind operation.
  #
  # IMPORTANT: Both files must be sorted for comm to work correctly.
  # The calls to sort -n when building these files ensures this.
  #
  # "| head -1" takes the first result in case multiple slots were added
  # unexpectedly (shouldn't happen, but defensive).
  new=$(comm -13 "$before_file" "$after_file" | head -1)

  # [[ -n "$new" ]] is true if the variable is non-empty.
  # If comm found nothing new, the before and after lists are identical —
  # the bind command didn't create a new slot. Fail immediately.
  [[ -n "$new" ]] || fail "Could not identify newly created Clevis slot"

  echo "$new"
}

# verify_slot_unlocks() — prove a Clevis slot ACTUALLY UNLOCKS the LUKS volume
#
# Every place this script creates a new Clevis binding, it must confirm the
# binding really works — not merely that the TPM hands back some bytes. This is
# what lets us finish safely WITHOUT relying on a reboot to prove the slot: we
# verify the new binding in place, in the currently-running boot, right after
# enrolling it.
#
# The check is two steps, mirroring Clevis's OWN unlock path
# ("clevis luks unlock" internally does: clevis decrypt | cryptsetup open
# --key-file -"), so the passphrase bytes are consumed exactly the way Clevis
# produced them — no newline/keyfile-size ambiguity:
#
#   1. clevis luks pass -d DEV -s SLOT
#        Unseals the TPM for SLOT and prints the recovered LUKS passphrase.
#        This step DEPENDS on the current running PCR state: it only succeeds
#        if the slot's sealed policy matches the PCRs as measured in THIS boot.
#        Because this script always seals new slots against the state that
#        exists at bind time, this step succeeds immediately after enrollment —
#        whether or not a later reboot is required.
#
#   2. cryptsetup open --test-passphrase --key-file - -S SLOT DEV
#        Feeds that recovered passphrase to LUKS and tests whether it opens
#        keyslot SLOT. --test-passphrase performs the unlock check WITHOUT
#        activating any device mapping (no side effects). This step is
#        PCR-independent: it is pure LUKS crypto ("does this key decrypt the
#        master key?"), so a pending reboot cannot make it falsely fail.
#        -S SLOT restricts the test to the keyslot the token is attached to,
#        so success means THAT specific binding unlocks — not just some slot.
#
# Returns 0 if the slot genuinely unlocks the volume, non-zero otherwise.
# All output is suppressed; callers decide how to react to the exit status.
#
# pipefail (set at the top of the script) makes the whole pipeline fail if
# clevis luks pass fails, so a TPM unseal failure is not masked by cryptsetup.
verify_slot_unlocks() {
  local dev="$1" slot="$2"
  clevis luks pass -d "$dev" -s "$slot" \
    | cryptsetup open --test-passphrase --key-file - -S "$slot" "$dev" >/dev/null 2>&1
}

# confirm_human_slot() — verify a non-Clevis keyslot exists before removal
#
# CRITICAL SAFETY CHECK. Before removing any Clevis slots, we must confirm
# that at least one human-entered-passphrase keyslot still exists on the device.
#
# If all remaining keyslots are Clevis/TPM slots, and we remove them (or the
# TPM fails permanently), there would be NO WAY to unlock the encrypted disk.
# All data would be permanently inaccessible without a lengthy recovery effort.
#
# This function parses the LUKS2 header dump to identify:
#   all_slots[]    — every slot that exists (from the Keyslots section)
#   clevis_slots[] — every slot associated with a Clevis token (from the Tokens section)
# Then checks if any slot in all_slots is NOT in clevis_slots.
#
# A human passphrase slot is one that EXISTS but is NOT backed by Clevis.
confirm_human_slot() {
  local dev="$1"
  local found

  # "cryptsetup luksDump $dev" prints the complete LUKS2 header.
  # Relevant sections look like:
  #
  #   Keyslots:
  #     0: luks2                ← keyslot 0 exists
  #        Key:      512 bits
  #        Cipher:   aes-xts-plain64
  #     1: luks2                ← keyslot 1 exists
  #        Key:      512 bits
  #   Tokens:
  #     0: clevis               ← Clevis token 0
  #        Keyslot:  1          ← it's associated with keyslot 1
  #   Digests:
  #
  # The awk program:
  #   Tracks which section we're in (section variable).
  #   In "Keyslots:", records every keyslot number in all_slots[].
  #   In "Tokens:", records keyslot numbers associated with clevis in clevis_slots[].
  #   At END, prints any slot in all_slots that is NOT in clevis_slots.
  #
  # Line-by-line awk explanation:
  #   /^Keyslots:/          { section="ks"; next }
  #     When we see a line matching "Keyslots:" at the start, set section to "ks"
  #     and skip to the next input line (next).
  #
  #   section=="ks" && /^  [0-9]+: / {
  #     When in the Keyslots section AND the line matches "  digit(s): ",
  #     extract the slot number from $1, strip the colon, store in all_slots.
  #     n = $1; gsub(/:/, "", n) — $1 is "0:" or "1:", gsub removes the ":".
  #   }
  #
  #   /^Tokens:/            { section="tok"; next }
  #     Switch section tracking when we see the Tokens section header.
  #
  #   /^Digests:/           { section="dig"; next }
  #     Switch section tracking when we see the Digests section header.
  #     (This ends the Tokens section; we stop looking for clevis entries.)
  #
  #   section=="tok" && /^  [0-9]+: clevis/ { in_clevis=1; next }
  #     In the Tokens section, when we see a clevis token entry, set a flag.
  #
  #   section=="tok" && in_clevis && /Keyslot:/ {
  #     While inside a clevis token block (in_clevis=1), when we see "Keyslot:",
  #     extract the slot number from the last field ($NF) and add to clevis_slots[].
  #     Then clear the in_clevis flag.
  #   }
  #
  #   END { ... }
  #     After processing all lines, iterate over all_slots.
  #     If a slot is NOT in clevis_slots, it's a human passphrase slot.
  #     Print the first one found and exit 0 (success).
  #     If none found, exit 1 (failure) — no human slot exists.
  #
  # "|| fail ..." — if awk exits 1 (no human slot found), call fail() to
  # abort the script with an explanation.
  found=$(cryptsetup luksDump "$dev" | awk '
    /^Keyslots:/          { section="ks"; next }
    section=="ks" && /^  [0-9]+: / {
      n = $1; gsub(/:/, "", n); all_slots[n] = 1
    }
    /^Tokens:/            { section="tok"; next }
    /^Digests:/           { section="dig"; next }
    section=="tok" && /^  [0-9]+: clevis/ { in_clevis=1; next }
    section=="tok" && in_clevis && /Keyslot:/ {
      n = $NF; gsub(/[^0-9]/, "", n); clevis_slots[n] = 1; in_clevis=0
    }
    END {
      for (s in all_slots)
        if (!(s in clevis_slots)) { print s; exit 0 }
      exit 1
    }
  ') || fail "No human passphrase slot found on $dev — enroll one before proceeding"

  log "Human passphrase slot confirmed: slot $found"
  # Note: we do not return $found. The caller only needs to know a slot EXISTS.
  # The slot number itself is informational (logged) but not used programmatically.
}

# ════════════════════════════════════════════════════════════════════════════
# SYSTEMD UNIT MANAGEMENT
# ════════════════════════════════════════════════════════════════════════════

# install_unit() — write the systemd service unit file and enable it
#
# The unit file tells systemd to run this script automatically on the
# next boot (and only the next boot, via the sentinel condition check).
#
# Unit file sections explained:
#   [Unit]     — metadata and dependencies
#   [Service]  — how to run the service
#   [Install]  — how to enable/disable it (where it plugs into the boot graph)
install_unit() {
  # Write the unit file using a heredoc. Variables are expanded at write time.
  # Single-quotes around 'EOF' would suppress expansion, but we WANT expansion
  # here so that SENTINEL, SCRIPT_PATH, LUKSDEV, STRICT_PCR_IDS, and UNIT_NAME
  # are written as their actual values into the unit file.
  cat > "$UNIT_PATH" <<EOF
[Unit]
Description=Kernel update TPM re-enrollment resume
# ConditionPathExists: systemd will SKIP starting this service if this
# file does not exist. The sentinel is removed after Phase 3, so the unit
# becomes permanently dormant once the process is complete. This prevents
# the unit from running unexpectedly on future reboots if it is somehow
# not disabled.
ConditionPathExists=${SENTINEL}
# After=: run this service only AFTER these other units have started.
# multi-user.target = all normal system services are running.
# clevis-luks-askpass.service = Clevis boot-time LUKS unlock has finished.
# We need to be after both so that:
#   1. The system is fully up (network, TPM services, etc.)
#   2. The LUKS volume is already unlocked (so clevis commands work)
After=multi-user.target clevis-luks-askpass.service

[Service]
# Type=oneshot: run the ExecStart command once and then consider the
# service done. The service does NOT keep running in the background.
# This is appropriate for scripts that perform a task and exit.
Type=oneshot
# ExecStart: the command to run. This script, with the LUKS device
# and PCR IDs as arguments (so Phase 2 and 3 have them even if the
# user did not set defaults on the system).
ExecStart=${SCRIPT_PATH} ${LUKSDEV} ${STRICT_PCR_IDS}
# Route the service's stdout and stderr to the systemd journal.
# "journalctl -u kernel-update-tpm-resume" shows the logs.
StandardOutput=journal
StandardError=journal
# Restart=no: NEVER automatically restart this service if it fails.
# This is the critical boot-loop prevention setting.
# Each phase disables the unit as its FIRST action, then does work.
# If work fails, the unit is already disabled — no restart on next boot.
Restart=no
# RemainAfterExit=no: once ExecStart exits, consider the service done
# and don't keep it in "active" state. Clean up the service status.
RemainAfterExit=no

[Install]
# WantedBy=multi-user.target: when enabled, add this service to the
# set of services that are wanted (started alongside) multi-user.target.
# multi-user.target is the standard Linux "normal operation" target —
# all normal services running, network up, but not graphical desktop.
# This causes the service to run automatically when the system boots
# to normal operation.
WantedBy=multi-user.target
EOF

  # Tell systemd to reload its unit file cache. Required after writing
  # a new unit file, or systemd won't see the new file.
  systemctl daemon-reload

  # Enable the unit: create the symlink in multi-user.target.wants/ that
  # causes it to start automatically on next boot.
  systemctl enable "$UNIT_NAME"

  log "Systemd unit installed and enabled: $UNIT_NAME"
}

# remove_unit() — disable and delete the systemd unit file
#
# Called at the start of Phase 2 and Phase 3 (boot-loop guard), and
# again at the end of Phase 3 (final cleanup).
remove_unit() {
  # "2>/dev/null || true" — suppress errors if the unit is already disabled
  # (calling disable on a non-enabled unit is an error, but we don't care).
  # "|| true" prevents set -e from exiting on that error.
  systemctl disable "$UNIT_NAME" 2>/dev/null || true

  # DO NOT "systemctl stop $UNIT_NAME" here. Phase 2 and Phase 3 — and their
  # restore_strict_from_temp / reenroll_from_passphrase helpers — call
  # remove_unit while running AS this very unit. "systemctl stop" would ask
  # systemd to terminate the running service, i.e. send SIGTERM to ourselves,
  # killing the script mid-cleanup BEFORE the sentinel is removed. That is
  # exactly what stranded a phase=2 sentinel after an otherwise-successful
  # auto-restore (SIGTERM, status=15/TERM, no "Sentinel removed" line).
  # Disabling is sufficient to keep the unit from running on future boots;
  # the current invocation exits on its own when the script returns.

  # Remove the unit file itself. "-f" = force: don't error if it doesn't exist.
  rm -f "$UNIT_PATH"

  # Reload the daemon so systemd forgets the now-deleted unit. "|| true" keeps
  # a non-zero daemon-reload from aborting cleanup under "set -e".
  systemctl daemon-reload || true

  log "Systemd unit removed: $UNIT_NAME"
}

# remove_sentinel() — delete the sentinel file
#
# Called at the end of Phase 3 after all operations are complete.
# Without this, the ConditionPathExists in the unit file would still
# evaluate to true (if the unit somehow got re-enabled), and future
# boots might try to run Phase 4 (which doesn't exist).
remove_sentinel() {
  # "rm -f" removes the file. "-f" = force: don't error if the file
  # doesn't exist (idempotent — safe to call multiple times).
  rm -f "$SENTINEL"
  log "Sentinel removed"
}

# remove_reboot_warning() — delete the one-shot Post-Invoke reboot-warning drop-in
#
# Called at the end of Phase 3 and the restore-from-temp path (both are
# "the transition is now complete" points), alongside remove_sentinel and
# remove_unit.
#
# WHY THIS IS NEEDED (Fix 1)
# ───────────────────────────
# Hook-mode Phase 1 writes REBOOT_WARNING_DROPIN so that the next apt run
# reminds the operator to reboot. But nothing used to delete that file on
# COMPLETION — Phase 3 removed the sentinel and the unit, yet left the drop-in
# sitting in apt.conf.d. Because the drop-in only takes effect on the NEXT apt
# run (APT reads its config once at startup — see REBOOT_WARNING_DROPIN above),
# it would fire its "reboot to run Phase 2" banner on some later, unrelated
# transaction LONG AFTER re-enrollment already finished — a false alarm that
# looks like the automation is stuck when it is not. Proactively removing the
# drop-in at every completion point guarantees a finished run leaves nothing
# behind to misfire.
#
# The drop-in's own Post-Invoke command also self-deletes (belt-and-suspenders),
# and additionally self-gates on the sentinel (Fix 2) so it stays silent if it
# somehow lingers — but deleting it here is the clean, primary fix.
remove_reboot_warning() {
  # "-f" = force: idempotent, no error if it was already removed (e.g. the
  # drop-in already self-deleted on an intervening apt run).
  rm -f "$REBOOT_WARNING_DROPIN"
  log "Reboot-warning drop-in removed: $REBOOT_WARNING_DROPIN"
}

# warn_reboot_required() — print a large, unmissable REBOOT REQUIRED banner
#
# This script NEVER reboots the machine on its own. When a phase finishes and
# the next phase can only run after a fresh boot, we WARN loudly instead of
# rebooting. The operator reboots when they are ready; the systemd resume unit
# then fires the next phase on that boot. This is a deliberate safety choice:
# an unattended reboot of a server is disruptive and must be a human decision.
#
# Besides the banner, we flag the system through the standard Debian
# "reboot-required" mechanism (/run/reboot-required and its .pkgs list), so the
# login MOTD, needrestart, and update-notifier also surface the pending reboot
# even if this banner has since scrolled off the screen.
#
# Argument $1 — urgency context, because the two call sites differ in stakes:
#   degraded   After Phase 1: the strict slot has been REMOVED, so the disk is
#              auto-unlocking on the looser PCR-7 temp slot until Phase 2 runs.
#              Protection is temporarily reduced — reboot promptly.
#   protected  After Phase 2: a new strict slot is already enrolled AND has
#              passed the real in-place unlock test, so the disk is already
#              fully protected. The reboot only runs Phase 3 to confirm the
#              binding survives a cold power cycle and to remove the temp slot.
warn_reboot_required() {
  local mode="${1:-protected}"

  # Flag via the standard Debian mechanism (best-effort; never fatal).
  # The append is wrapped in a group so that a redirection failure (e.g. an
  # unwritable /run in some odd invocation) is swallowed too — a bare
  # "cmd >> file 2>/dev/null" does NOT suppress the shell's own redirection
  # error, only the command's stderr.
  touch /run/reboot-required 2>/dev/null || true
  if ! grep -qxF "kernel-update-tpm" /run/reboot-required.pkgs 2>/dev/null; then
    { echo "kernel-update-tpm" >> /run/reboot-required.pkgs; } 2>/dev/null || true
  fi

  # Two status lines tailored to the urgency. ASCII only, so the box aligns
  # regardless of terminal locale (multibyte glyphs break printf field widths).
  local line1 line2
  if [[ "$mode" == "degraded" ]]; then
    line1="Auto-unlock is TEMPORARILY on the looser PCR-7 slot only."
    line2="Reboot to run Phase 2 and restore the strict TPM binding."
  else
    line1="Strict slot is enrolled and VERIFIED to unlock right now."
    line2="Reboot to run Phase 3: confirm cold-boot stability + cleanup."
  fi

  # Banner to stderr so it appears on the terminal, in the systemd journal,
  # and in the apt hook log regardless of whether stdout is being captured.
  {
    echo ""
    printf '╔%s╗\n' "$(printf '═%.0s' {1..68})"
    printf '║ %-66s ║\n' "***  REBOOT REQUIRED  -  TPM re-enrollment is NOT finished  ***"
    printf '╠%s╣\n' "$(printf '═%.0s' {1..68})"
    printf '║ %-66s ║\n' "$line1"
    printf '║ %-66s ║\n' "$line2"
    printf '║ %-66s ║\n' ""
    printf '║ %-66s ║\n' "Reboot when you are ready:   sudo systemctl reboot"
    printf '║ %-66s ║\n' "This script will NOT reboot the system for you."
    printf '╚%s╝\n' "$(printf '═%.0s' {1..68})"
    echo ""
  } >&2
}

# wait_for_dpkg_lock() — block until the dpkg frontend lock is free
#
# unattended-upgrades runs on a systemd timer and can hold the dpkg frontend
# lock when the user manually invokes this script. Rather than failing
# immediately, we wait and show progress.
#
# For unattended-upgrades specifically we tail its log file so the user can
# see exactly what it's doing. "tail --pid=PID" auto-exits when the watched
# process terminates, so the tail stops as soon as unattended-upgrades exits.
#
# For any other holder we emit a "still waiting" line every 30 seconds.
#
# After the lock is free we keep a short timeout on the apt calls themselves
# to cover the race window between our check clearing and apt's lock attempt.
wait_for_dpkg_lock() {
  local timeout_secs=600
  local elapsed=0
  local interval=5
  local status_every=30

  fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 || return 0

  local lock_pid lock_proc
  lock_pid=$(fuser /var/lib/dpkg/lock-frontend 2>/dev/null \
    | tr -s ' ' '\n' | grep -v '^$' | head -1 || true)
  lock_proc=$(ps -p "$lock_pid" -o comm= 2>/dev/null || echo "unknown")
  log "dpkg lock held by PID $lock_pid ($lock_proc) — waiting up to $((timeout_secs / 60)) minutes..."

  local log_tail_pid=""
  local uu_log="/var/log/unattended-upgrades/unattended-upgrades.log"
  if [[ "$lock_proc" == "unattended-upgr" && -f "$uu_log" ]]; then
    log "Showing unattended-upgrades activity (last 5 lines + live follow):"
    tail -n 5 -f "$uu_log" --pid="$lock_pid" &
    log_tail_pid=$!
  fi

  while fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1; do
    if [[ $elapsed -ge $timeout_secs ]]; then
      [[ -n "$log_tail_pid" ]] && kill "$log_tail_pid" 2>/dev/null || true
      fail "dpkg lock still held by '$lock_proc' after ${elapsed}s. Check: sudo fuser /var/lib/dpkg/lock-frontend"
    fi
    sleep "$interval"
    elapsed=$((elapsed + interval))
    if [[ -z "$log_tail_pid" && $((elapsed % status_every)) -eq 0 ]]; then
      log "Still waiting for dpkg lock (held by $lock_proc, ${elapsed}s elapsed)..."
    fi
  done

  [[ -n "$log_tail_pid" ]] && kill "$log_tail_pid" 2>/dev/null || true
  [[ $elapsed -gt 0 ]] && log "dpkg lock released after ${elapsed}s — proceeding."
}

# ════════════════════════════════════════════════════════════════════════════
# PHASE 1 — PREFLIGHT, BIND LOOSE SLOT, UPDATE KERNEL, REBOOT
# ════════════════════════════════════════════════════════════════════════════
#
# Phase 1 is triggered by the user directly (or by the APT hook).
# It performs the PREPARATION work: creating a temporary loose Clevis slot
# that will survive the kernel update, removing the strict slot that will
# NOT survive, and then (in non-hook mode) running the kernel update itself.
#
# By the time Phase 1 ends, the system has:
#   - A temp Clevis slot (PCR 7 only) that will auto-unlock after the update
#   - No strict Clevis slot (removed because it would fail post-update)
#   - A sentinel file recording this state
#   - A systemd unit that will trigger Phase 2 after the next reboot
# ════════════════════════════════════════════════════════════════════════════

phase_1() {
  log "=== Phase 1: preflight and kernel update ==="

  # ── Dependency check ───────────────────────────────────────────────────
  # Verify all required commands before touching anything.
  # Phase 1 needs tools for: Clevis (clevis), LUKS (cryptsetup), kernel
  # rebuild (update-initramfs, update-grub), package management (apt),
  # text processing (awk, sort, comm), TPM inspection (tpm2_pcrread),
  # and service management (systemctl).
  for cmd in clevis cryptsetup update-initramfs update-grub apt awk sort comm tpm2_pcrread systemctl; do
    need_cmd "$cmd"
  done

  # ── Basic device validation ────────────────────────────────────────────
  # [[ -b "$LUKSDEV" ]] — confirm it's a block device. Catches typos.
  [[ -b "$LUKSDEV" ]]       || fail "Not a block device: $LUKSDEV"
  # Confirm it's actually a LUKS container (has a valid LUKS header).
  # "2>/dev/null" suppresses cryptsetup's error message for non-LUKS devices.
  cryptsetup isLuks "$LUKSDEV" || fail "Not a LUKS device: $LUKSDEV"

  log "LUKS device: $LUKSDEV"
  log "Strict PCR policy: $STRICT_POLICY"
  log "Fallback PCR policy: $FALLBACK_POLICY"
  echo  # Blank line for readability in the output

  # ── Human passphrase safety check ────────────────────────────────────
  # This must happen BEFORE any slot operations. If no human slot exists,
  # we cannot safely remove any Clevis slots.
  confirm_human_slot "$LUKSDEV"

  # ── Detect and verify the existing strict slot ────────────────────────
  # Phase 1 requires exactly one Clevis TPM2 slot to exist.
  log "Detecting existing Clevis slot..."
  STRICT_SLOT=$(detect_single_clevis_slot "$LUKSDEV")
  log "Found Clevis slot: $STRICT_SLOT"

  # Verify the existing slot unseals with the current TPM state before
  # proceeding. If it can't unseal, PCR values have changed since the slot
  # was created (hardware change, chassis intrusion detection, firmware
  # update, or a stale temp slot from an interrupted prior run).
  #
  # In interactive mode we offer passphrase-based re-enrollment as a
  # recovery path rather than dying with no options.
  log "Verifying TPM unseal on slot $STRICT_SLOT..."
  if ! clevis luks pass -d "$LUKSDEV" -s "$STRICT_SLOT" >/dev/null; then
    log "TPM unseal failed on slot $STRICT_SLOT — PCR values may have changed."
    if [[ -t 1 ]]; then
      echo ""
      echo "  [r]  Re-enroll from LUKS passphrase — bind a new slot to the current"
      echo "       PCR state, then remove the broken slot (no reboot required)"
      echo "  [q]  Quit — inspect TPM and PCR state, then re-run"
      echo ""
      read -r -p "Choice [r/q]: " _choice
      case "$_choice" in
        r|R) reenroll_from_passphrase; return ;;
        *) fail "Aborted. Existing Clevis slot cannot unseal. Inspect TPM and PCR state." ;;
      esac
    else
      fail "TPM unseal failed on slot $STRICT_SLOT — cannot proceed"
    fi
  fi
  log "TPM unseal OK"
  echo

  # ── Record current PCR values ─────────────────────────────────────────
  # "tpm2_pcrread sha256:0,1,2,4,5,7" reads and prints the current SHA-256
  # PCR values for PCRs 0,1,2,4,5,7. These are the "stable" PCRs — PCRs
  # that don't change during a kernel update.
  #
  # We print them here as a reference record. This is informational — it
  # helps with debugging if something goes wrong later. If Phase 2 fails
  # because PCR 7 changed unexpectedly (e.g., a Secure Boot key enrollment
  # happened during the update), these values help diagnose what happened.
  log "Stable PCR values before update (0,1,2,4,5,7):"
  tpm2_pcrread sha256:0,1,2,4,5,7
  echo

  # ── Confirm before first destructive action ───────────────────────────
  # All preflight checks above are read-only. Nothing has been changed yet.
  # Give the user a final summary and an exit path before slot work begins.
  if [[ "$HOOK_MODE" -eq 0 && -t 1 ]]; then
    echo ""
    log "=== CURRENT SLOT STATUS ==="
    show_slot_status "Current" "$LUKSDEV"
    echo ""
    echo "  Preflight complete. Review the plan before proceeding:"
    printf "    LUKS device   : %s\n"  "$LUKSDEV"
    printf "    Strict policy : PCR %s\n" "$STRICT_PCR_IDS"
    printf "    Temp slot     : PCR %s (survives kernel update)\n" "$FALLBACK_PCR_IDS"
    printf "    Existing slot : %s (will be removed)\n" "$STRICT_SLOT"
    echo   "    Steps         : bind temp slot → remove strict slot"
    echo   "                    → apt upgrade → update-initramfs → update-grub → reboot"
    echo ""
    echo "  [y]  Proceed"
    echo "  [q]  Quit — no changes made"
    echo ""
    read -r -p "Proceed? [y/q]: " _confirm
    case "${_confirm:-}" in
      y|Y) echo "" ;;
      *) log "Quit — no changes made."; exit 0 ;;
    esac
  fi

  # ── Bind the temporary loose slot ────────────────────────────────────
  # The loose slot is bound to PCR 7 only. It will work after the kernel
  # update because PCR 7 does not change when a kernel or initramfs changes.
  log "Binding temporary loose slot (PCR $FALLBACK_PCR_IDS)..."

  # Create two temp files to capture before/after slot lists for comparison.
  # "mktemp" creates a uniquely-named temporary file in /tmp and prints its path.
  before_slots=$(mktemp)
  after_slots=$(mktemp)

  # Register cleanup on RETURN (when this function exits, not when the script exits).
  # "trap ... RETURN" runs the cleanup command when the function returns,
  # whether normally or due to an error. This ensures the temp files are
  # deleted even if the bind command fails.
  # "rm -f" removes the files silently (no error if they don't exist).
  trap 'rm -f "$before_slots" "$after_slots"' RETURN

  # Capture the current list of TPM2 slots BEFORE binding the new one.
  # This is the "before" snapshot for detect_new_slot().
  clevis luks list -d "$LUKSDEV" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n > "$before_slots"

  # Bind the new loose slot, authorizing via the existing strict slot.
  #
  # Pipeline:
  # "clevis luks pass -d $LUKSDEV -s $STRICT_SLOT"
  #   → Unseals the existing strict slot using the TPM.
  #   → Outputs the raw LUKS passphrase to stdout.
  #   → This passphrase is the key that unlocks the LUKS master key.
  #
  # "| clevis luks bind -y -k - -d $LUKSDEV tpm2 "$FALLBACK_POLICY""
  #   -y    Answer "yes" automatically to any prompts (non-interactive mode).
  #   -k -  Read the LUKS authorization key from stdin (the "-" means stdin).
  #         This receives the passphrase from "clevis luks pass" via the pipe.
  #   -d $LUKSDEV  The device to add the new slot to.
  #   tpm2  The Clevis pin type (TPM2-backed sealing).
  #   "$FALLBACK_POLICY"  The JSON policy for the new slot (PCR 7 only).
  #
  # Why pipe the passphrase rather than typing it?
  #   The LUKS passphrase for slot $STRICT_SLOT is a long random key —
  #   not something a human can type. Clevis manages it inside the TPM.
  #   By piping through clevis luks pass, the TPM acts as the authorization
  #   credential. This is also more secure than exposing the passphrase
  #   on the command line (where it would appear in process listings).
  clevis luks pass -d "$LUKSDEV" -s "$STRICT_SLOT" \
    | clevis luks bind -y -k - -d "$LUKSDEV" tpm2 "$FALLBACK_POLICY"

  # Capture the slot list AFTER binding.
  clevis luks list -d "$LUKSDEV" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n > "$after_slots"

  # Find the new slot number by comparing before and after lists.
  TEMP_SLOT=$(detect_new_slot "$before_slots" "$after_slots")
  log "Temporary slot created: $TEMP_SLOT"

  # ── Test the temp slot BEFORE removing the strict slot ────────────────
  # This is the critical safety gate of Phase 1.
  #
  # If the temp slot does not unseal NOW, we should NOT remove the strict slot.
  # Leaving both slots in place and aborting is the safest outcome:
  # the user is no worse off than before this script ran.
  #
  # Reasons the temp slot might fail here:
  #   - The TPM dictionary attack lockout was triggered (too many failed attempts).
  #   - The TPM2 tools version has a compatibility issue with the policy.
  #   - PCR 7 value could not be read at seal time.
  log "Testing temporary slot $TEMP_SLOT (real unlock test)..."
  verify_slot_unlocks "$LUKSDEV" "$TEMP_SLOT" \
    || fail "Temporary slot $TEMP_SLOT did not unlock the volume — strict slot left in place; aborting"
  log "Temporary slot unlocks OK"

  # ── Remove the strict slot ────────────────────────────────────────────
  # Now that the temp slot is confirmed working, remove the strict slot.
  # The strict slot WILL fail after the kernel update (PCRs 8/9 will change).
  # Removing it now avoids confusion: the system will only have one Clevis
  # slot, and that slot (PCR 7 only) will work correctly post-update.
  #
  # If we left the strict slot in place, after the update the system would
  # have TWO Clevis slots: one that works (temp, PCR 7) and one that fails
  # (strict, PCRs 0,1,2,4,5,7,8,9). Phase 2 expects exactly ONE slot.
  log "Removing strict slot $STRICT_SLOT..."
  # "clevis luks unbind -f -d $dev -s $slot"
  #   -f  Force: don't prompt for confirmation.
  #   -d  Device
  #   -s  Slot number
  # This removes both the Clevis token metadata AND the LUKS keyslot.
  clevis luks unbind -f -d "$LUKSDEV" -s "$STRICT_SLOT"
  log "Strict slot $STRICT_SLOT removed"

  log "=== SLOT STATUS AFTER PHASE 1 ==="
  show_slot_status "After Phase 1" "$LUKSDEV"

  # ── Write sentinel and install systemd unit ───────────────────────────
  # Do this BEFORE any package changes. In hook mode, APT is about to
  # install packages as the next step. We want the sentinel and unit in
  # place before those changes happen, so if something goes wrong during
  # installation, the sentinel already describes the current state.
  #
  # write_sentinel 2: record that Phase 2 is what should run next.
  # Extra key=value pairs:
  #   temp_slot=$TEMP_SLOT            — Phase 2 needs to know the temp slot number
  #   strict_slot_removed=$STRICT_SLOT — informational record of what was removed
  write_sentinel 2 \
    "temp_slot=${TEMP_SLOT}" \
    "strict_slot_removed=${STRICT_SLOT}"

  # Install the systemd unit so Phase 2 runs automatically after the reboot.
  install_unit

  # ── Hook-mode branch ──────────────────────────────────────────────────
  # If we were called by the APT hook, exit here.
  # APT is still running and will proceed with package installation.
  # After installation, the Post-Invoke hook we register below WARNS that a
  # reboot is required — it does NOT reboot the system.
  if [[ "$HOOK_MODE" -eq 1 ]]; then
    log "Phase 1 (hook-mode) complete — slot transition prepared."
    log "apt will now install packages and rebuild initramfs/grub."
    log "A REBOOT REQUIRED warning will be shown after apt finishes — this"
    log "script does NOT reboot automatically. Reboot when ready to run Phase 2."

    # Register a one-time Post-Invoke APT hook that WARNS about the pending
    # reboot after apt finishes. It intentionally does NOT reboot: an
    # unattended reboot of a server must be a human decision.
    #
    # WHEN DOES THIS DROP-IN ACTUALLY FIRE?  (important — a common misconception)
    # ─────────────────────────────────────────────────────────────────────────
    # NOT during this transaction. APT parses /etc/apt/apt.conf.d/ exactly ONCE,
    # at process startup, and this hook runs LATER (Pre-Install-Pkgs) — so the
    # file we write here is not part of the already-parsed config of the apt run
    # that is executing us. It first takes effect on the NEXT apt invocation of
    # ANY kind. That is why the banner shows up attached to some later, unrelated
    # command (the classic symptom: "apt remove keepass2" suddenly printing a
    # kernel reboot warning). This is inherent to how apt hooks work; we cannot
    # make it fire "now," so instead we make it HARMLESS when it fires late.
    #
    # The command the drop-in runs, in order:
    #   1. Removes itself ("rm -f $REBOOT_WARNING_DROPIN") — truly one-shot, so it
    #      fires on at most one future apt run and never accumulates.
    #   2. SELF-GATE (Fix 2): if the sentinel is gone, the three-phase transition
    #      already completed — there is nothing to warn about — so exit 0 quietly.
    #      This is what makes a late firing truthful: the banner appears ONLY
    #      while a transition is genuinely pending, never as a stale false alarm.
    #      (Belt-and-suspenders with Fix 1, which deletes this file outright at
    #      every completion point so it usually never survives to fire at all.)
    #   3. Flags the system via the standard Debian reboot-required mechanism
    #      (touch /run/reboot-required and add to its .pkgs list) so the login
    #      MOTD, needrestart, and update-notifier also surface the pending
    #      reboot even after the banner scrolls away.
    #   4. Prints a plain-text REBOOT REQUIRED banner to stderr (apt's log/
    #      terminal). The message is deliberately ASCII and self-contained.
    #
    # 'APTEOF' in single quotes: the heredoc is in single-quote mode, so no
    # variable expansion happens inside it. The content is written literally as
    # APT configuration syntax. The Post-Invoke value is a double-quoted string
    # that APT passes to /bin/sh -c; the single-quoted echo arguments inside it
    # are consumed by that shell, so no backslash escaping is needed. Because the
    # body cannot expand shell variables, the drop-in path and the sentinel path
    # are written LITERALLY below — they must stay in sync with
    # $REBOOT_WARNING_DROPIN and $SENTINEL (/var/lib/kernel-update-tpm/state).
    cat > "$REBOOT_WARNING_DROPIN" <<'APTEOF'
// Auto-generated by kernel-update-tpm.sh — removed after first use
DPkg::Post-Invoke {
  "rm -f /etc/apt/apt.conf.d/99-kernel-update-tpm-reboot; test -f /var/lib/kernel-update-tpm/state || exit 0; touch /run/reboot-required; grep -qxF kernel-update-tpm /run/reboot-required.pkgs 2>/dev/null || echo kernel-update-tpm >> /run/reboot-required.pkgs; echo >&2; echo '*** REBOOT REQUIRED - kernel-update-tpm: TPM re-enrollment is NOT finished ***' >&2; echo 'The strict slot was removed; auto-unlock is on the looser PCR-7 slot only.' >&2; echo 'Reboot to run Phase 2 and restore the strict TPM binding.' >&2; echo 'This will NOT reboot for you. Reboot when ready:  sudo systemctl reboot' >&2; echo >&2";
};
APTEOF
    log "Post-Invoke REBOOT REQUIRED warning hook written — system will NOT reboot on its own."
    # Exit 0 (success): APT sees a successful hook and continues with installation.
    exit 0
  fi

  # ── Normal mode: run apt ourselves ───────────────────────────────────
  # Only reached in non-hook mode (user invoked this script directly).
  # Here we run the full kernel update sequence ourselves.

  wait_for_dpkg_lock

  log "Running apt update && apt upgrade..."
  # "apt-get update -q" refreshes package lists.
  #   -q  Quiet: reduce output verbosity (but not fully silent; use -qq for that).
  # DPkg::Lock::Timeout=60 is a short safety net for the race window between
  # wait_for_dpkg_lock() clearing and apt acquiring the lock itself.
  apt-get -o DPkg::Lock::Timeout=60 update -q

  # "apt-get upgrade -y" upgrades all installed packages, including the kernel.
  #   -y  Yes to all prompts (non-interactive mode).
  # Note: we use apt-get (the scripting-friendly interface) rather than apt
  # (the user-friendly interface). apt warns when used non-interactively;
  # apt-get does not.
  apt-get -o DPkg::Lock::Timeout=60 upgrade -y
  echo

  # "update-initramfs -u -k all" rebuilds the initramfs for all installed kernels.
  #   -u  Update existing initramfs (as opposed to creating one from scratch).
  #   -k all  Process ALL installed kernel versions.
  # The initramfs is a small filesystem image loaded into RAM at boot time.
  # It contains the tools needed to unlock the encrypted disk before the
  # main filesystem can be mounted. It MUST be rebuilt after a kernel update
  # to include the new kernel's modules and the updated Clevis/cryptsetup tools.
  log "Rebuilding initramfs..."
  update-initramfs -u -k all
  echo

  # "update-grub" regenerates the GRUB bootloader configuration.
  # After a kernel update, new entries must be added to grub.cfg so the
  # bootloader knows about the new kernel and its initramfs path.
  # This is what changes PCR 8 and 9 — the new grub.cfg references
  # different files, and those references are measured on the next boot.
  log "Updating GRUB..."
  update-grub
  echo

  log "Phase 1 complete."
  log "Kernel update staged — the new kernel boots on the next reboot."
  # Do NOT reboot automatically. The strict slot is gone and the disk is on the
  # looser PCR-7 temp slot until Phase 2 runs, so warn with the "degraded"
  # urgency. The operator reboots when ready; the resume unit runs Phase 2 then.
  warn_reboot_required degraded
}

# ════════════════════════════════════════════════════════════════════════════
# RESTORE HELPER — re-enroll strict slot from temp slot without a reboot
#
# Called by phase_2 when it detects the kernel did not change. The system is
# still in the same boot state as Phase 1, so the current PCR values are the
# same ones the strict policy should be sealed against. We can re-enroll now
# without rebooting.
#
# Preconditions (guaranteed by the caller):
#   - LUKSDEV, STRICT_PCR_IDS, STRICT_POLICY are set from the sentinel
#   - TEMP_SLOT is the loose PCR-7 slot created in Phase 1 (still present)
#   - No strict slot exists (was removed in Phase 1)
# ════════════════════════════════════════════════════════════════════════════

restore_strict_from_temp() {
  log "Restoring strict slot from temporary slot $TEMP_SLOT..."

  local before_slots after_slots new_strict
  before_slots=$(mktemp)
  after_slots=$(mktemp)
  # No RETURN trap here. bash RETURN traps persist into the caller after the
  # function returns, which causes "unbound variable" when the caller (phase_2)
  # later returns and the trap fires in phase_2's scope. Cleanup is explicit.

  clevis luks list -d "$LUKSDEV" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n > "$before_slots"

  log "Re-enrolling strict slot (PCR $STRICT_PCR_IDS) against current boot state..."
  if ! clevis luks pass -d "$LUKSDEV" -s "$TEMP_SLOT" \
      | clevis luks bind -y -k - -d "$LUKSDEV" tpm2 "$STRICT_POLICY"; then
    rm -f "$before_slots" "$after_slots"
    log "Temp slot $TEMP_SLOT cannot unseal — PCR 7 may have changed since Phase 1."
    if [[ -t 1 ]]; then
      echo ""
      echo "  [p]  Re-enroll from LUKS passphrase instead (no reboot required)"
      echo "  [q]  Quit — temp slot $TEMP_SLOT still present for manual recovery"
      echo ""
      read -r -p "Choice [p/q]: " _choice
      case "$_choice" in
        p|P) reenroll_from_passphrase "$TEMP_SLOT"; return ;;
        *) fail "Aborted. Temp slot $TEMP_SLOT still present." ;;
      esac
    else
      fail "Temp slot $TEMP_SLOT cannot unseal. Run interactively to use passphrase fallback."
    fi
  fi

  clevis luks list -d "$LUKSDEV" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n > "$after_slots"

  new_strict=$(detect_new_slot "$before_slots" "$after_slots")
  rm -f "$before_slots" "$after_slots"
  log "New strict slot created: $new_strict"

  log "Testing new strict slot $new_strict (real unlock test)..."
  verify_slot_unlocks "$LUKSDEV" "$new_strict" \
    || fail "New strict slot $new_strict did not unlock the volume. Temp slot $TEMP_SLOT still present for manual recovery."
  log "Strict slot unlocks OK"

  log "Removing temporary loose slot $TEMP_SLOT..."
  clevis luks unbind -f -d "$LUKSDEV" -s "$TEMP_SLOT"
  log "Temp slot removed"

  # Clear the sentinel BEFORE the unit. The sentinel is what gates the apt hook
  # (which skips while one exists) and the phase dispatcher, so removing it
  # first guarantees the system is fully un-gated even if unit teardown hiccups.
  remove_sentinel
  remove_unit
  # Drop the one-shot reboot-warning drop-in too: this restore path finishes the
  # transition WITHOUT a further reboot, so any pending banner is now false. If
  # we left it, it would misfire on the next unrelated apt run. (Fix 1.)
  remove_reboot_warning

  log "=== FINAL SLOT STATUS ==="
  show_slot_status "Final" "$LUKSDEV"
  log "Strict slot restored. No reboot required."
}

# ════════════════════════════════════════════════════════════════════════════
# REENROLL FROM PASSPHRASE — rebuild a broken or stale Clevis binding
# ════════════════════════════════════════════════════════════════════════════
#
# Called from two contexts:
#   - phase_1() TPM unseal fails   → slot to remove is STRICT_SLOT
#   - restore_strict_from_temp()
#     temp slot can't unseal       → slot to remove is TEMP_SLOT
#
# Optional arg $1: the slot number to remove after re-enrolling.
#   If omitted, falls back to $STRICT_SLOT.
#
# Steps:
#   1. Bind a new slot to the CURRENT PCR state using the LUKS passphrase
#   2. Test the new slot unseals
#   3. Remove the broken slot ($1 or $STRICT_SLOT)
#   4. Clean up any leftover sentinel/unit
# No reboot is required.
# ════════════════════════════════════════════════════════════════════════════

reenroll_from_passphrase() {
  local _slot_to_remove="${1:-${STRICT_SLOT:-}}"
  [[ -n "$_slot_to_remove" ]] \
    || fail "reenroll_from_passphrase: no slot specified and STRICT_SLOT is unset"

  log "Re-enrolling Clevis binding from LUKS passphrase..."
  log "PCR policy: $STRICT_POLICY"
  log "Slot to remove after re-enroll: $_slot_to_remove"
  echo ""

  local before_slots after_slots new_slot
  before_slots=$(mktemp)
  after_slots=$(mktemp)

  clevis luks list -d "$LUKSDEV" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n > "$before_slots"

  log "Binding new slot sealed to current TPM state..."
  log "Clevis will prompt for your LUKS passphrase:"
  if ! clevis luks bind -y -d "$LUKSDEV" tpm2 "$STRICT_POLICY"; then
    rm -f "$before_slots" "$after_slots"
    fail "Clevis bind failed. Verify the LUKS passphrase and TPM state, then re-run."
  fi

  clevis luks list -d "$LUKSDEV" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n > "$after_slots"

  new_slot=$(detect_new_slot "$before_slots" "$after_slots")
  rm -f "$before_slots" "$after_slots"

  log "New slot: $new_slot — real unlock test..."
  verify_slot_unlocks "$LUKSDEV" "$new_slot" \
    || fail "New slot $new_slot did not unlock the volume. Old slot $_slot_to_remove still present for manual recovery."
  log "New slot unlocks OK."

  log "Removing broken slot $_slot_to_remove..."
  clevis luks unbind -f -d "$LUKSDEV" -s "$_slot_to_remove"

  remove_unit     2>/dev/null || true
  remove_sentinel 2>/dev/null || true
  # Remove the one-shot reboot-warning drop-in here too. This function is a
  # TERMINAL completion path reached by several recovery/abandon routes — the
  # interactive [p] "re-enroll from passphrase" choice, the [a] "abandon phase"
  # dispatch, and a broken-temp-slot recovery. Each of those bypasses the
  # cleanup at the end of restore_strict_from_temp() / phase_3(), so without
  # this line a completed recovery would leave the drop-in behind to misfire
  # its "reboot to run Phase 2" banner on a later, unrelated apt transaction.
  # The invariant: wherever the sentinel is removed as a terminal action, the
  # reboot-warning drop-in is removed alongside it.
  remove_reboot_warning 2>/dev/null || true

  log "=== FINAL SLOT STATUS ==="
  show_slot_status "Final" "$LUKSDEV"
  log "Clevis binding re-established. No reboot required."
}

# ════════════════════════════════════════════════════════════════════════════
# PHASE 2 — RE-ENROLL STRICT SLOT, REBOOT
# ════════════════════════════════════════════════════════════════════════════
#
# Phase 2 runs automatically after the first reboot post-update.
# The new kernel is now running. PCRs 8/9 have new values reflecting
# the new kernel. PCR 7 is unchanged, so the temp slot (PCR 7 only)
# still unseals.
#
# Phase 2 uses the temp slot to authorize enrollment of a NEW strict slot.
# The new strict slot is sealed to the CURRENT PCR values — which include
# the new kernel's measurements. After Phase 2, both the temp slot and
# the new strict slot are present. Phase 2 ends with a reboot to verify.
# ════════════════════════════════════════════════════════════════════════════

phase_2() {
  log "=== Phase 2: re-enroll strict PCR policy ==="

  # ── BOOT-LOOP GUARD — disable the unit FIRST ──────────────────────────
  # This is the most important line in Phase 2. By disabling the unit
  # BEFORE doing any work, we ensure that if anything fails below, the
  # system will NOT automatically restart the unit on the next boot.
  # Without this guard, a failure would cause Phase 2 to run again on
  # every subsequent boot, potentially looping indefinitely.
  #
  # The unit will be re-enabled at the end of Phase 2 (to trigger Phase 3).
  # If Phase 2 fails, the unit stays disabled and a human must intervene.
  log "Disabling resume unit (boot-loop guard)..."
  # "2>/dev/null || true" — ignore errors if already disabled.
  systemctl disable "$UNIT_NAME" 2>/dev/null || true
  # Reload systemd's state after disable.
  systemctl daemon-reload

  # ── Read state from sentinel ─────────────────────────────────────────
  # Phase 2 runs in a fresh boot, in a new process invocation. The script's
  # variables are not carried over from Phase 1 — only the command-line
  # arguments and the sentinel file persist across reboots. We re-read the
  # authoritative values FIRST, because the recovery path in the age check
  # below (restore_strict_from_temp) depends on them being set.
  LUKSDEV=$(read_sentinel_value luksdev)
  STRICT_PCR_IDS=$(read_sentinel_value strict_pcr_ids)
  # Rebuild the policy JSON from the retrieved PCR IDs.
  STRICT_POLICY="{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"${STRICT_PCR_IDS}\"}"
  TEMP_SLOT=$(read_sentinel_value temp_slot)
  local kernel_at_phase1
  kernel_at_phase1=$(read_sentinel_value kernel_at_phase1)

  # ── Age check (automated runs only) ─────────────────────────────────
  # If Phase 2 fires more than MAX_PHASE_AGE_MINUTES after Phase 1, the reboot
  # was not prompt (e.g. a server whose reboot was deferred for hours). Phase 1
  # has ALREADY removed the strict slot, so the disk is running on the looser
  # PCR-7 temp slot until we act.
  #
  # This used to fail() here, leaving the disk degraded until a human ran the
  # script by hand. Instead we recover safely with restore_strict_from_temp():
  # it re-seals a strict slot to the CURRENT boot state, but only AFTER
  # unsealing the temp slot — so it refuses to enroll into a state it cannot
  # validate, which is exactly what the old "stale PCR state" guard was there
  # to prevent. It restores protection now and skips the Phase 3 verify-reboot
  # (moot after this long a gap). Guard applies to automated runs only;
  # interactive runs are human-confirmed and handled by the normal flow.
  if [[ "$HOOK_MODE" -eq 1 || ! -t 1 ]]; then
    local age
    age=$(sentinel_age_minutes)
    if [[ "$age" -gt "$MAX_PHASE_AGE_MINUTES" ]]; then
      err "Sentinel is ${age} minutes old (max ${MAX_PHASE_AGE_MINUTES}) — reboot was not prompt."
      err "Strict slot was already removed in Phase 1; auto-restoring it from the temp slot now."
      restore_strict_from_temp
      return
    fi
  fi

  # ── Kernel version check ──────────────────────────────────────────────
  # Confirm the kernel actually changed from Phase 1 to now.
  # "uname -r" prints the running kernel version.
  local current_kernel
  current_kernel=$(uname -r)
  if [[ "$current_kernel" == "$kernel_at_phase1" ]]; then
    # Same kernel string means the new kernel did not boot. This could happen if:
    #   - The grub.cfg still points to the old kernel (update-grub failed in Phase 1).
    #   - The system booted from a non-default grub menu entry.
    #   - The kernel "update" installed the same version already running.
    # The PCR values are unchanged from Phase 1, so we can re-enroll the strict
    # slot right now against the current (unchanged) boot state. No reboot needed.
    log "Kernel is still $current_kernel — same as Phase 1."
    log "The strict slot is gone; temp slot (PCR 7, slot $TEMP_SLOT) is still active."
    echo ""
    if [[ -t 1 ]]; then
      # Running interactively — offer to restore or quit.
      echo "  [r]  Restore strict slot from temp slot and clean up (recommended)"
      echo "  [q]  Quit — leave temp slot in place, fix /boot manually, then reboot"
      echo ""
      read -r -p "Choice [r/q]: " _choice
      case "$_choice" in
        r|R) restore_strict_from_temp; return ;;
        *) log "Exiting. Fix the boot configuration, then reboot — Phase 2 will retry."
           log "Or run 'sudo /usr/local/sbin/kernel-update-tpm.sh' manually after fixing."
           exit 1 ;;
      esac
    else
      # Non-interactive (systemd unit / apt hook resume). Previously this
      # dead-ended with fail(), leaving the disk protected only by the looser
      # PCR-7 temp slot until a human ran the script by hand — the root cause
      # of the recurring degraded state and the duplicate-slot drift.
      #
      # A non-kernel boot-sensitive upgrade (fwupd, grub, shim, microcode, ...)
      # trips the apt hook but does NOT change "uname -r", so reaching this
      # branch is a NORMAL, expected event — not an error. Recover automatically:
      # restore_strict_from_temp() re-seals a strict slot to the current boot
      # state, authorized by unsealing the temp slot (so it only proceeds if
      # PCR 7 still validates), then removes the temp slot and clears the
      # sentinel. No reboot, no manual step, no half-transitioned state left.
      log "Non-kernel transition (uname -r unchanged) — auto-restoring strict slot from temp slot $TEMP_SLOT."
      restore_strict_from_temp
      return
    fi
  fi
  log "Kernel updated: $kernel_at_phase1 → $current_kernel"

  # ── Verify the temp slot still unseals ───────────────────────────────
  # The temp slot is sealed to PCR 7 (Secure Boot state). If the system
  # is in the same Secure Boot state as Phase 1, the temp slot should unseal.
  # If it fails, something changed unexpectedly (e.g., a MOK key was modified
  # during the apt transaction, or Secure Boot was toggled). The user must
  # investigate and unlock manually.
  log "Verifying temporary slot $TEMP_SLOT in new boot state..."
  clevis luks pass -d "$LUKSDEV" -s "$TEMP_SLOT" >/dev/null \
    || fail "Temporary slot $TEMP_SLOT failed to unseal after kernel update. " \
            "Boot state may have shifted unexpectedly (check PCR 7 / MOK state). " \
            "Unlock manually with LUKS passphrase and re-enroll."
  log "Temporary slot OK in new boot state"
  echo

  # ── Read new PCR values for the record ───────────────────────────────
  # Print current PCR values. These are the values the new strict slot
  # will be sealed against. Saving this output to the journal (via systemd)
  # provides a record of what the PCR state looked like at enrollment time,
  # useful for future debugging.
  log "PCR values in new boot state:"
  tpm2_pcrread sha256:0,1,2,4,5,7,8,9
  echo

  # ── Re-enroll the strict slot ─────────────────────────────────────────
  # Bind a NEW strict slot using the CURRENT PCR values (which include the
  # new kernel's measurements in PCRs 8 and 9).
  log "Binding new strict slot (PCR $STRICT_PCR_IDS)..."

  before_slots=$(mktemp)
  after_slots=$(mktemp)
  # "trap ... RETURN" ensures temp files are deleted when this function exits.
  trap 'rm -f "$before_slots" "$after_slots"' RETURN

  # Snapshot current TPM2 slots (before binding the new one).
  clevis luks list -d "$LUKSDEV" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n > "$before_slots"

  # Bind the new strict slot, authorizing via the temp slot.
  # Same pipeline pattern as Phase 1, but this time:
  #   - We unseal TEMP_SLOT (not the old strict slot)
  #   - We bind with STRICT_POLICY (not FALLBACK_POLICY)
  # The new slot will be sealed to the NEW PCR values (measured at this moment).
  clevis luks pass -d "$LUKSDEV" -s "$TEMP_SLOT" \
    | clevis luks bind -y -k - -d "$LUKSDEV" tpm2 "$STRICT_POLICY"

  # Snapshot after binding and detect the new slot number.
  clevis luks list -d "$LUKSDEV" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n > "$after_slots"

  NEW_STRICT_SLOT=$(detect_new_slot "$before_slots" "$after_slots")
  log "New strict slot created: $NEW_STRICT_SLOT"

  # ── Test the new strict slot ──────────────────────────────────────────
  # Verify the new slot unseals RIGHT NOW. This is an in-process test —
  # it does NOT prove the slot will survive a full power cycle (that is
  # what Phase 3's reboot is for). But if it fails right now, we know
  # something is fundamentally wrong with the enrollment.
  #
  # IMPORTANT: If this fails, DO NOT reboot. The temp slot is still
  # present and working. The user can investigate (check tpm2_pcrread,
  # check PCR values match what was expected) and re-run Phase 2 manually.
  log "Testing new strict slot $NEW_STRICT_SLOT (real unlock test)..."
  verify_slot_unlocks "$LUKSDEV" "$NEW_STRICT_SLOT" \
    || fail "New strict slot $NEW_STRICT_SLOT did not unlock the volume. " \
            "Temp slot $TEMP_SLOT is still present for recovery. " \
            "Do not reboot — investigate manually."
  log "New strict slot unlocks OK"

  log "=== SLOT STATUS AFTER PHASE 2 ==="
  show_slot_status "After Phase 2" "$LUKSDEV"

  # ── Update sentinel for Phase 3 ───────────────────────────────────────
  # Record the new state:
  #   phase=3          — Phase 3 will run on the next boot
  #   new_strict_slot  — Phase 3 needs this to verify and clean up
  #   phase_started_at — reset the age timer for Phase 3's age check
  update_sentinel phase 3
  update_sentinel new_strict_slot "$NEW_STRICT_SLOT"
  update_sentinel phase_started_at "$(date --iso-8601=seconds)"

  # Re-enable the unit so Phase 3 runs automatically after the next reboot.
  systemctl enable "$UNIT_NAME"
  systemctl daemon-reload

  log "Phase 2 complete. The new strict slot is enrolled and verified to unlock now."
  # Do NOT reboot automatically. The disk is already fully protected by the new
  # strict slot (it just passed the real unlock test above), so warn with the
  # "protected" urgency rather than the degraded one.
  #
  # Why a reboot is still worthwhile: the new strict slot was created in THIS
  # boot. The TPM's PCR values were measured at the START of this boot and do
  # not change while the system runs. To confirm the strict slot will keep
  # working at FUTURE boots, the operator boots again from scratch — the
  # firmware measures everything fresh and Phase 3 confirms the sealed policy
  # still matches, then removes the temp slot.
  warn_reboot_required protected
}

# ════════════════════════════════════════════════════════════════════════════
# PHASE 3 — VERIFY STRICT SLOT, REMOVE TEMP SLOT, CLEANUP
# ════════════════════════════════════════════════════════════════════════════
#
# Phase 3 runs automatically after the second reboot post-update.
# The new strict slot (enrolled in Phase 2) should have unsealed correctly
# at boot time — the LUKS disk auto-unlocked without a passphrase prompt.
# Phase 3 confirms this by testing the strict slot again in-process, then
# removes the temp slot (which is no longer needed), and cleans up the
# sentinel file and systemd unit.
# ════════════════════════════════════════════════════════════════════════════

phase_3() {
  log "=== Phase 3: verification and cleanup ==="

  # ── BOOT-LOOP GUARD ───────────────────────────────────────────────────
  # Same pattern as Phase 2: disable the unit FIRST. If Phase 3 fails,
  # we do NOT want to loop. The temp slot is still present; the user has
  # fallback access and can manually clean up.
  log "Disabling resume unit (boot-loop guard)..."
  systemctl disable "$UNIT_NAME" 2>/dev/null || true
  systemctl daemon-reload

  # ── Age check (automated runs only) ─────────────────────────────────
  # Same rationale as Phase 2: only block automated runs; interactive
  # invocations are human-confirmed and PCR values don't drift at runtime.
  if [[ "$HOOK_MODE" -eq 1 || ! -t 1 ]]; then
    local age
    age=$(sentinel_age_minutes)
    if [[ "$age" -gt "$MAX_PHASE_AGE_MINUTES" ]]; then
      fail "Sentinel is ${age} minutes old (max ${MAX_PHASE_AGE_MINUTES}). Phase 3 did not run promptly — aborting. Temp slot is still present. Inspect $SENTINEL and clean up manually."
    fi
  fi

  # ── Read sentinel values ──────────────────────────────────────────────
  LUKSDEV=$(read_sentinel_value luksdev)
  TEMP_SLOT=$(read_sentinel_value temp_slot)
  NEW_STRICT_SLOT=$(read_sentinel_value new_strict_slot)

  # ── Verify the new strict slot unseals ───────────────────────────────
  # This is the definitive test: the new strict slot was enrolled in Phase 2.
  # The system has now rebooted — the TPM has re-measured the full boot chain
  # from scratch (firmware → GRUB → kernel → initramfs). If the strict slot
  # unseals NOW, it will continue to unseal on all future boots (as long as
  # the boot chain remains unchanged).
  #
  # If this fails: the temp slot is still present! Do NOT remove it.
  # Possible causes:
  #   - A firmware update happened between Phase 2 and Phase 3 (changed PCR 0/1).
  #   - GRUB configuration changed between reboots.
  #   - A Secure Boot key was added/removed (changed PCR 7).
  # The user must investigate, determine why PCRs changed, and re-enroll manually.
  log "Verifying new strict slot $NEW_STRICT_SLOT unlocks after full TPM cycle (real unlock test)..."
  verify_slot_unlocks "$LUKSDEV" "$NEW_STRICT_SLOT" \
    || fail "New strict slot $NEW_STRICT_SLOT did not unlock the volume on the verification boot. " \
            "Temp slot $TEMP_SLOT is still present. " \
            "Investigate PCR state manually — do not remove the temp slot."
  log "New strict slot $NEW_STRICT_SLOT verified OK"
  echo

  # ── Remove the temporary loose slot ──────────────────────────────────
  # The temp slot's job is done. It has served as:
  #   1. The auto-unlock mechanism for the first post-update boot (PCR 7 only).
  #   2. The authorization credential for enrolling the new strict slot in Phase 2.
  #
  # Leaving it in place would be a security concern:
  #   - The temp slot (PCR 7 only) is "looser" than the strict policy.
  #   - It would unseal for ANY kernel, as long as Secure Boot is unchanged.
  #   - An attacker who compromises the bootloader but keeps Secure Boot on
  #     could potentially use the temp slot to unseal the LUKS key.
  #   - The strict slot (many PCRs) provides the full security guarantee.
  log "Removing temporary loose slot $TEMP_SLOT..."
  clevis luks unbind -f -d "$LUKSDEV" -s "$TEMP_SLOT"
  log "Temporary slot $TEMP_SLOT removed"

  # ── Final state report ────────────────────────────────────────────────
  log "=== SLOT STATUS AFTER PHASE 3 (FINAL) ==="
  show_slot_status "Final" "$LUKSDEV" 1

  # ── Cleanup ───────────────────────────────────────────────────────────
  # Remove the systemd unit file (it has no further purpose).
  remove_unit
  # Remove the sentinel file (the process is complete).
  remove_sentinel
  # Remove the one-shot reboot-warning drop-in. The three-phase process is
  # fully finished here, so no banner should ever fire again. Without this,
  # the drop-in lingers and misfires "reboot to run Phase 2" on a later,
  # unrelated apt transaction — the stale false alarm this fix eliminates.
  remove_reboot_warning

  log "=== Kernel update and TPM re-enrollment complete ==="
  log "Running kernel: $(uname -r)"
  log "Strict PCR policy: $STRICT_POLICY"
  log "No reboot required."
  # Phase 3 exits normally (exit code 0). The system is now in a clean state:
  # one strict Clevis slot, no temp slot, no sentinel, no unit.
}

# ════════════════════════════════════════════════════════════════════════════
# ENTRY POINT
# ════════════════════════════════════════════════════════════════════════════
#
# This is where execution begins. The argument parsing and configuration
# sections above have already run by this point. We now:
#   1. Confirm we are root.
#   2. Determine which phase to run.
#   3. Call the appropriate phase function.
# ════════════════════════════════════════════════════════════════════════════

require_root

# Display current slot status at script startup (all phases/invocations)
log "=== CURRENT SYSTEM STATUS ==="
show_slot_status "Current" "$LUKSDEV"

# If the user chose [a] (abandon) at the Phase 2/3 resume prompt, skip phase
# dispatch entirely and re-enroll from passphrase with the newly chosen policy.
# The sentinel and unit are removed here; reenroll_from_passphrase() then
# removes the temp slot after successfully binding a new strict slot.
if [[ "$_ABANDON_PHASE2" -eq 1 ]]; then
  log "Abandoning Phase ${_PENDING_PHASE} — removing sentinel and unit..."
  remove_unit     2>/dev/null || true
  remove_sentinel 2>/dev/null || true
  log "Re-enrolling from LUKS passphrase with new policy: $STRICT_PCR_IDS"
  reenroll_from_passphrase "${_abandon_temp_slot}"
else
  # Determine the current phase.
  # If the sentinel file exists, read the phase from it (we are mid-process).
  # If no sentinel file exists, start at Phase 1.
  if [[ -f "$SENTINEL" ]]; then
    PHASE=$(read_sentinel_value phase)
  else
    PHASE=1
  fi

  case "$PHASE" in
    1) phase_1 ;;
    2) phase_2 ;;
    3) phase_3 ;;
    *) fail "Unknown phase '$PHASE' in sentinel $SENTINEL — inspect and remove manually" ;;
  esac
fi
