#!/usr/bin/env bash
# ^^^^^^^^^^^^^^^^^^^^
# Shebang line. Tells the OS to run this script using bash, found by searching
# PATH. Preferred over #!/bin/bash because bash may not always live at /bin/bash
# (e.g., on NixOS or some containers it lives elsewhere). Using /usr/bin/env
# makes the script more portable across different Linux installations.

set -euo pipefail
# Three critical safety options bundled into one line.
#
#   -e  Exit immediately if any simple command fails (exits non-zero).
#       Without this, bash would continue past errors silently, potentially
#       causing later commands to act on bad data or a broken system state.
#       Example without -e: if "clevis luks pass" fails, the next command
#       might try to use the empty result and cause unexpected damage.
#
#   -u  Treat any reference to an undefined (unset) variable as an error.
#       Without this, a typo in a variable name expands to an empty string.
#       Example: "clevis luks unbind -d $LUKSDEV" where LUKSDEV was misspelled
#       as LUKSDEV_ would silently pass an empty -d argument instead of failing.
#
#   -o pipefail  Make a pipeline fail if ANY command in it fails.
#       In bash, "cmd1 | cmd2 | cmd3" normally only reports the exit code of
#       cmd3. With pipefail, if cmd1 or cmd2 fails, the whole pipeline fails.
#       This matters for chains like "clevis luks list | grep | awk" — if the
#       clevis command fails, we want to know, not silently process empty output.

# ════════════════════════════════════════════════════════════════════════════
# update-clevis-pcrs.sh
#
# Lower-level helper: re-enroll a single Clevis TPM2 slot and/or remove old
# ones. Operates on a single slot without sentinel tracking or reboot
# management. For kernel updates use kernel-update-tpm.sh instead.
#
# BACKGROUND — WHAT IS CLEVIS AND WHY DO PCRS MATTER?
# ─────────────────────────────────────────────────────
# Clevis is a tool for automating LUKS (disk encryption) decryption using a
# TPM (Trusted Platform Module). The TPM is a hardware chip on the motherboard
# that can store cryptographic secrets securely.
#
# The TPM seals secrets against "PCR values". PCRs (Platform Configuration
# Registers) are measurements of boot components:
#   PCR 0:  Firmware code (BIOS/UEFI)
#   PCR 1:  Firmware configuration
#   PCR 2:  Option ROM code
#   PCR 4:  Bootloader (GRUB stage 1)
#   PCR 5:  Bootloader configuration
#   PCR 7:  Secure Boot state (MOK keys, Secure Boot enabled/disabled)
#   PCR 8:  GRUB commands
#   PCR 9:  GRUB files loaded (kernel path, initramfs path)
#
# When Clevis seals a LUKS key, it records the current PCR values. The TPM
# will only release the key if those values match at boot time. If PCRs
# change (e.g., new kernel, new initramfs), the TPM refuses.
#
# This script lets you change which PCRs are used ("re-enroll") without
# going through the full three-phase automated process. Use it when:
#   - You want to change the PCR policy on a system not yet using automation
#   - You are recovering from a failed kernel-update-tpm.sh run
#   - You want to test slot operations interactively
#
# WHAT IS A "SLOT"?
# ──────────────────
# LUKS supports multiple key slots (0–31). Each slot holds an independent
# way to decrypt the LUKS volume. You can have:
#   - Slot 0: your passphrase
#   - Slot 1: a Clevis/TPM binding for auto-unlock
#   - Slot 2: a backup Clevis binding with looser PCR policy
#   ...etc.
# Removing a slot does NOT affect data — it just removes one way of
# accessing the encryption key. As long as another slot remains, data
# is safe.
#
# WARNING: Do NOT run while kernel-update-tpm.sh is mid-run (sentinel present).
#          This script does not read or update the sentinel file. Running both
#          concurrently can create slots the automation cannot account for, or
#          remove slots it expects to use for authorization.
#
# Usage:
#   sudo ./update-clevis-pcrs.sh [OPTIONS] [LUKSDEV]
#
# Options:
#   --pcrs <ids>          PCR IDs for new enrollment (default: detect from existing)
#   --auth-slot <n>       Authorize new binding from this slot (default: detect)
#   --remove-slot <n>     Remove this slot after enrollment (repeatable)
#   --remove-all-tpm      Remove all TPM2 slots other than the one just created
#   --enroll-only         Enroll only; do not remove any existing slots
#   --remove-only         Remove slot(s) only; do not enroll a new slot
#   --list                List current Clevis bindings and exit
#   --dry-run             Show what would be done; make no changes
#   --help                Show this help
#
# Examples:
#   # Re-enroll with auto-detected PCR policy, remove all old TPM slots:
#   sudo ./update-clevis-pcrs.sh --remove-all-tpm
#
#   # Re-enroll with specific PCRs, keep old slots:
#   sudo ./update-clevis-pcrs.sh --pcrs 0,1,2,7 --enroll-only
#
#   # Remove a stuck temp slot from a failed automation run:
#   sudo ./update-clevis-pcrs.sh --remove-only --remove-slot 5
#
#   # Full manual recovery: re-enroll strict policy, remove stuck temp slot:
#   sudo ./update-clevis-pcrs.sh --pcrs 0,1,2,4,5,7,8,9 --remove-slot 3
# ════════════════════════════════════════════════════════════════════════════

# ── Sentinel path ────────────────────────────────────────────────────────────
#
# The sentinel is the state file written by kernel-update-tpm.sh when it
# starts a multi-phase operation. Its presence means that script is mid-run.
# We warn the user if it exists because running this script concurrently with
# the automation can corrupt the slot tracking.
#
# /var/lib/ is the standard Linux location for persistent program state that
# must survive reboots. The sentinel must survive reboots because the
# automation spans multiple boot cycles.
SENTINEL="/var/lib/kernel-update-tpm/state"

# ── Default PCR policy ───────────────────────────────────────────────────────
#
# If no --pcrs argument is given and no existing Clevis binding is found to
# detect PCRs from, we fall back to this list of PCR IDs.
#
# This set of PCRs covers:
#   0,1,2  — Firmware code and configuration. Changes if firmware is updated.
#   4,5    — Bootloader code and config. Changes if GRUB is updated.
#   7      — Secure Boot state. Changes if MOK keys change or SB is toggled.
#   8,9    — GRUB commands and loaded files. Changes if kernel path changes.
#
# Trade-off — more PCRs vs. fewer:
#   More PCRs: Tighter binding. The TPM only unseals in a very specific boot
#     state. Higher security — harder for an attacker to manipulate the boot
#     environment to get the TPM to release the key. But higher risk of
#     accidental lockout: any firmware or bootloader update can break auto-unlock.
#   Fewer PCRs (e.g., PCR 7 only): Looser binding. Survives firmware and
#     kernel updates without re-enrollment. Lower security but more resilient.
#     This is why the automation uses a loose PCR-7-only slot as a TEMPORARY
#     measure while a kernel update is in progress.
#   This default (0,1,2,4,5,7,8,9): A reasonable "strict" policy that protects
#     against most boot-path tampering while covering the full boot chain.
DEFAULT_PCR_IDS="0,1,2,4,5,7,8,9"

# ════════════════════════════════════════════════════════════════════════════
# LOGGING HELPERS
# ════════════════════════════════════════════════════════════════════════════
#
# ANSI escape codes for terminal color. These work in most Linux terminals.
#   \033[  — escape sequence start (ESC character)
#   0;31m  — color code: 0=normal weight, 31=red
#   1;33m  — color code: 1=bold, 33=yellow
#   0;32m  — color code: 0=normal weight, 32=green
#   0;36m  — color code: 0=normal weight, 36=cyan
#   0m     — reset all formatting back to default
#
# We define these as variables so we can embed them in echo -e strings.
# echo -e enables interpretation of backslash escape sequences like \033.
RED='\033[0;31m'; YELLOW='\033[1;33m'; GREEN='\033[0;32m'; CYAN='\033[0;36m'; NC='\033[0m'

# log()  — informational message (cyan prefix)
# ok()   — success message (green prefix)
# warn() — warning, written to stderr (>&2) so it appears even if stdout is redirected
# fail() — fatal error message to stderr, then exit 1 to stop the script
#
# "$*" expands to all function arguments as a single string.
# Using "$*" rather than "$@" here is intentional: log messages are single
# conceptual strings, not arrays of separate arguments.
log()  { echo -e "${CYAN}[update-clevis-pcrs]${NC}       $*"; }
ok()   { echo -e "${GREEN}[update-clevis-pcrs] OK:${NC}   $*"; }
warn() { echo -e "${YELLOW}[update-clevis-pcrs] WARN:${NC} $*" >&2; }
fail() { echo -e "${RED}[update-clevis-pcrs] FAIL:${NC} $*" >&2; exit 1; }

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

# need_cmd — verify a command exists before using it
#
# "command -v $1" searches for the command in PATH and prints its path if found.
# It exits 0 (success) if found, non-zero if not.
# We suppress output with >/dev/null 2>&1 because we only care about the exit code.
# If the command is missing, we call fail() which exits the script immediately.
#
# Why check dependencies explicitly?
#   If a required tool is missing and we don't check, the script will fail
#   mid-operation with a confusing "command not found" error — possibly after
#   already making changes. Checking all dependencies upfront gives a clear
#   error message before anything is modified.
need_cmd() { command -v "$1" >/dev/null 2>&1 || fail "Missing required command: $1"; }

# detect_luks_device — auto-detect the active LUKS2 block device
#
# This function finds a block device that is:
#   1. Currently unlocked (has an active dm-crypt mapping)
#   2. Is a LUKS container (not just any encrypted device)
#   3. Is specifically LUKS version 2 (not LUKS1)
#
# Why version 2? Clevis uses features specific to LUKS2 (JSON token metadata).
# LUKS1 stores token data differently and Clevis will not work correctly with it.
detect_luks_device() {
  # Declare local variables. "local" limits their scope to this function.
  # Without "local", these variables would be global and could accidentally
  # conflict with variables of the same name elsewhere in the script.
  local crypt_name parent_dev

  # "lsblk -rno NAME,TYPE" lists all block devices in raw (-r) format,
  # with no headers (-n), showing only the NAME and TYPE columns (-o).
  # Output looks like:
  #   sda         disk
  #   sda1        part
  #   sda2        part
  #   dm-0        crypt    ← this is an active dm-crypt (LUKS) mapping
  #   dm-1        lvm
  #
  # "awk '$2=="crypt" {print $1}'" prints only the NAME field ($1) for
  # rows where the TYPE field ($2) is "crypt". This gives us the names
  # of active decrypted volumes (e.g., "dm-0").
  #
  # "while IFS= read -r crypt_name" reads one crypt device name per iteration.
  #   IFS=    — clear the field separator so leading/trailing whitespace is preserved
  #   -r      — raw mode: don't interpret backslashes in the input
  #
  # "<(...)" is process substitution. It runs the command inside and presents
  # its output as if it were a file. This lets us pipe into a while loop
  # without the loop running in a subshell (which would lose variable assignments).
  while IFS= read -r crypt_name; do
    # For each active crypt device (e.g., "dm-0"), find its parent block device.
    # "lsblk -rno PKNAME /dev/mapper/${crypt_name}" shows the parent kernel name.
    # For /dev/mapper/dm-0 (which might be opened from /dev/nvme0n1p3), this
    # outputs "nvme0n1p3".
    # "| head -1" takes just the first result in case there are multiple.
    # "2>/dev/null" suppresses any errors (e.g., if the device doesn't exist).
    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

    # If lsblk returned nothing (empty string), skip this device and continue
    # to the next one. [[ -z "$parent_dev" ]] is true if the string is empty.
    [[ -z "$parent_dev" ]] && continue

    # Construct the full device path. lsblk gives us just "nvme0n1p3" or "sda2",
    # so we prepend "/dev/" to get a usable device path like "/dev/nvme0n1p3".
    local dev="/dev/${parent_dev}"

    # [[ -b "$dev" ]] — test if the path is a block device (-b flag).
    # Block devices are special files that represent physical/virtual storage.
    # If it's not a block device (e.g., the path doesn't exist), skip it.
    [[ -b "$dev" ]] || continue

    # Confirm this block device is a LUKS container (any version).
    # "cryptsetup isLuks $dev" exits 0 if it is LUKS, non-zero if not.
    # "2>/dev/null" suppresses "Not a LUKS device" error messages.
    # "|| continue" skips non-LUKS devices without exiting the script
    # (we use || continue rather than relying on set -e here because we
    # WANT to continue the loop on failure, not exit the whole script).
    cryptsetup isLuks "$dev" 2>/dev/null || continue

    # Confirm specifically LUKS version 2 (not LUKS1).
    # "cryptsetup luksDump $dev" prints the LUKS header details.
    # "awk '/^Version:/ {print $2; exit}'" finds the line starting with
    # "Version:" and prints the second field (the version number), then exits.
    local ver
    ver=$(cryptsetup luksDump "$dev" 2>/dev/null | awk '/^Version:/ {print $2; exit}')

    # [[ "$ver" == "2" ]] — string comparison. If it's not "2", skip this device.
    [[ "$ver" == "2" ]] || continue

    # All checks passed. Print the device path and return 0 (success).
    # "return 0" is not strictly necessary (the function returns the exit code
    # of the last command) but makes the intent explicit.
    echo "$dev"
    return 0
  done < <(lsblk -rno NAME,TYPE | awk '$2=="crypt" {print $1}')

  # If we get here, no suitable device was found. Return 1 (failure).
  # The caller will handle this with an error message.
  return 1
}

# detect_strict_pcr_ids — read PCR IDs from an existing Clevis binding
#
# Clevis stores the PCR policy as JSON inside the LUKS token metadata.
# This function reads that JSON and extracts the pcr_ids field.
#
# This lets the script default to "the same PCR policy already in use"
# rather than always requiring the user to specify PCRs explicitly.
# If the system was originally enrolled with 0,1,2,4,5,7, this function
# returns "0,1,2,4,5,7" and the re-enrollment will use the same policy.
detect_strict_pcr_ids() {
  # "$1" is the first argument passed to this function — the LUKS device path.
  local dev="$1"

  # "clevis luks list -d $dev" shows all Clevis bindings on the device.
  # Output looks like:
  #   1: tpm2 '{"hash":"sha256","key":"ecc","pcr_bank":"sha256","pcr_ids":"0,1,2,7"}'
  #
  # "| grep tpm2" keeps only TPM2 bindings (not tang, not sss).
  #
  # "| grep -oP '"pcr_ids"\s*:\s*"\K[^"]+'
  #   -o  Print only the matching part, not the whole line
  #   -P  Use Perl-compatible regular expressions (enables \K and \s)
  #   The pattern:
  #     "pcr_ids"   — literal text to find
  #     \s*:\s*     — colon with optional whitespace on either side
  #     "           — opening quote
  #     \K          — "forget" everything matched so far (don't include it in output)
  #     [^"]+       — one or more characters that are NOT a closing quote
  #   This extracts just the PCR IDs string, e.g., "0,1,2,4,5,7,8,9"
  #
  # "| head -1" returns only the first match, in case multiple TPM2 bindings
  # exist with different PCR policies.
  #
  # "2>/dev/null" suppresses errors if the device has no Clevis bindings.
  clevis luks list -d "$dev" 2>/dev/null \
    | grep tpm2 \
    | grep -oP '"pcr_ids"\s*:\s*"\K[^"]+' \
    | head -1
}

# detect_new_slot — identify a newly created LUKS slot by diffing before/after
#
# When we bind a new Clevis slot, LUKS assigns it the next available slot number.
# We cannot predict the number in advance. Instead, we:
#   1. List all TPM2 slot numbers BEFORE binding → save to a temp file
#   2. Bind the new slot
#   3. List all TPM2 slot numbers AFTER binding → save to a temp file
#   4. Compare the two lists — the new entry is the new slot
#
# This function takes the two temp file paths and returns the new slot number.
detect_new_slot() {
  local before_file="$1" after_file="$2"
  local new

  # "comm -13 $before $after" compares two SORTED files line by line.
  # comm outputs three columns:
  #   Column 1: lines only in the first file (suppressed by -1)
  #   Column 2: lines in both files (suppressed by -2... wait, -13 suppresses 1 and 3)
  #   Actually:
  #   -1: suppress lines unique to file 1 (before)
  #   -3: suppress lines common to both files
  # So "-13" prints only lines that are UNIQUE TO FILE 2 (after) — i.e., the new slot.
  #
  # Requirements: both files must be sorted. list_tpm_slots() sorts its output,
  # so this works correctly.
  #
  # "| head -1" takes the first result in case somehow multiple new slots appeared.
  new=$(comm -13 "$before_file" "$after_file" | head -1)

  # [[ -n "$new" ]] — true if the variable is non-empty.
  # If comm found nothing new (before and after are identical), something went
  # wrong with the bind command. Fail with an explanation.
  [[ -n "$new" ]] || fail "Could not identify newly created Clevis slot"

  echo "$new"
}

# confirm_human_slot — verify at least one non-Clevis keyslot exists
#
# This is a critical safety check. Before removing any Clevis slots, we must
# confirm that a human-entered passphrase slot still exists on the device.
# If all slots were Clevis/TPM slots and we removed them all (or the TPM
# failed), there would be NO WAY to unlock the disk. Data would be permanently
# inaccessible.
#
# This function uses an awk program to parse the output of "cryptsetup luksDump"
# and find any keyslot that is NOT associated with a Clevis token.
confirm_human_slot() {
  local dev="$1"
  local found

  # "cryptsetup luksDump $dev" outputs the full LUKS2 header. Relevant sections:
  #
  #   Keyslots:
  #     0: luks2                ← keyslot 0 exists
  #        Key:        ...
  #        Cipher:     ...
  #     1: luks2                ← keyslot 1 exists
  #        Key:        ...
  #   Tokens:
  #     0: clevis               ← token 0 is a Clevis token
  #        Keyslot:    1        ← it is associated with keyslot 1
  #   Digests: ...
  #
  # The awk program tracks which section we're in and builds two sets:
  #   all_slots[]   — all keyslot numbers that exist
  #   clevis_slots[] — keyslot numbers that have Clevis tokens
  # At the END, it prints any slot in all_slots that is NOT in clevis_slots.
  # If it finds one, that is our human passphrase slot.
  #
  # Why awk instead of grep?
  # The output is structured across multiple lines. Grep can only look at one
  # line at a time. We need to understand the section structure (Keyslots vs
  # Tokens) and correlate data across sections. Awk handles multi-line
  # structured parsing well.
  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"

  # If we get here, $found contains the slot number. Log it and return normally.
  # We do not return the slot number — the caller just needs to know it EXISTS.
  log "Human passphrase slot confirmed: slot $found"
}

# list_tpm_slots — list all TPM2 slot numbers on a device, sorted numerically
#
# Used to build the before/after snapshots for detect_new_slot(), and to
# enumerate slots for --remove-all-tpm.
#
# Output: one slot number per line, sorted smallest first.
#   Example:
#     1
#     3
list_tpm_slots() {
  local dev="$1"

  # "clevis luks list -d $dev" shows all bindings. Each line looks like:
  #   1: tpm2 '{"pcr_bank":"sha256","pcr_ids":"0,1,2,7"}'
  #   3: tpm2 '{"pcr_bank":"sha256","pcr_ids":"7"}'
  #
  # "awk -F: '/tpm2/ {gsub(...); print $1}'"
  #   -F:    — use ":" as the field separator
  #   /tpm2/ — only process lines containing "tpm2"
  #   gsub(/^[ \t]+|[ \t]+$/, "", $1)  — trim leading/trailing whitespace from $1
  #   print $1 — print the slot number (first field before the ":")
  #
  # "| sort -n" — sort numerically (so "2" comes before "10")
  #
  # "2>/dev/null" — suppress errors if the device has no Clevis bindings at all
  clevis luks list -d "$dev" 2>/dev/null \
    | awk -F: '/tpm2/ {gsub(/^[ \t]+|[ \t]+$/, "", $1); print $1}' \
    | sort -n
}

# ════════════════════════════════════════════════════════════════════════════
# ARGUMENT PARSING
# ════════════════════════════════════════════════════════════════════════════
#
# Initialize all option variables to safe defaults BEFORE parsing arguments.
# This is important with set -u: if we reference a variable that was never
# set, bash will error. By setting defaults first, we ensure every variable
# has a known value even if the user does not pass that option.

LUKSDEV=""          # Path to LUKS device (empty = auto-detect)
PCR_IDS=""          # PCR IDs string (empty = auto-detect from existing binding)
AUTH_SLOT=""        # Slot number to authorize from (empty = auto-detect)
REMOVE_SLOTS=()     # Array of slot numbers to remove explicitly (starts empty)
REMOVE_ALL_TPM=false  # Whether to remove all TPM slots after enrollment
ENROLL_ONLY=false   # If true, skip the removal phase
REMOVE_ONLY=false   # If true, skip the enrollment phase
DO_LIST=false       # If true, just list bindings and exit
DRY_RUN=false       # If true, show actions without performing them

# "while [[ $# -gt 0 ]]" — loop while there are still arguments remaining.
# $# is the count of positional parameters (command-line arguments).
# $# -gt 0 means "greater than zero" — there's at least one argument left.
while [[ $# -gt 0 ]]; do
  case "$1" in
    # "--pcrs" expects the next argument ($2) to be a comma-separated PCR list.
    # "shift 2" removes both $1 (--pcrs) and $2 (the value) from the argument list.
    --pcrs)           PCR_IDS="$2";         shift 2 ;;

    # "--auth-slot" takes the slot number to authorize the new enrollment from.
    # Must be an existing, working TPM2 slot.
    --auth-slot)      AUTH_SLOT="$2";       shift 2 ;;

    # "--remove-slot" can be specified multiple times.
    # "+=" appends to the array rather than replacing it.
    # Example: --remove-slot 3 --remove-slot 5 → REMOVE_SLOTS=(3 5)
    --remove-slot)    REMOVE_SLOTS+=("$2"); shift 2 ;;

    # Boolean flags: set to "true" and consume one argument with "shift".
    --remove-all-tpm) REMOVE_ALL_TPM=true;  shift ;;
    --enroll-only)    ENROLL_ONLY=true;     shift ;;
    --remove-only)    REMOVE_ONLY=true;     shift ;;
    --list)           DO_LIST=true;         shift ;;
    --dry-run)        DRY_RUN=true;         shift ;;

    # "--help" or "-h": print the usage from the script's own header comments.
    # "sed -n '3,48p'" prints lines 3 through 48 (the usage block at the top).
    # "sed 's/^# \?//'" strips the leading "# " or "#" from each comment line,
    # turning the bash comment block into plain readable text.
    --help|-h)
      sed -n '3,48p' "$0" | sed 's/^# \?//'
      exit 0
      ;;

    # Unknown options starting with "-" are an error.
    # We fail rather than silently ignoring unrecognized flags, which could
    # cause the user to think an option worked when it was actually ignored.
    -*) fail "Unknown option: $1  (use --help)" ;;

    # Anything not starting with "-" is treated as the LUKS device path.
    # "shift" removes it from the argument list.
    *)  LUKSDEV="$1"; shift ;;
  esac
done

# ════════════════════════════════════════════════════════════════════════════
# VALIDATE OPTION COMBINATIONS
# ════════════════════════════════════════════════════════════════════════════
#
# Some combinations of options are contradictory. Catch them early with clear
# error messages rather than letting the script proceed in an undefined state.

# --enroll-only says "enroll but don't remove". --remove-only says "remove but
# don't enroll". Both together is self-contradictory.
$ENROLL_ONLY && $REMOVE_ONLY  && fail "--enroll-only and --remove-only are mutually exclusive"

# --enroll-only says "don't remove anything". --remove-all-tpm says "remove
# everything". These are logically contradictory.
$ENROLL_ONLY && $REMOVE_ALL_TPM && fail "--enroll-only and --remove-all-tpm are mutually exclusive"

# --remove-only without specifying what to remove is a no-op. The user
# probably forgot to add --remove-slot or --remove-all-tpm.
# "${#REMOVE_SLOTS[@]}" is the number of elements in the REMOVE_SLOTS array.
if $REMOVE_ONLY && [[ ${#REMOVE_SLOTS[@]} -eq 0 ]] && ! $REMOVE_ALL_TPM; then
  fail "--remove-only requires --remove-slot <n> or --remove-all-tpm"
fi

# ════════════════════════════════════════════════════════════════════════════
# ROOT CHECK
# ════════════════════════════════════════════════════════════════════════════
#
# "$EUID" is the Effective User ID. Root has EUID 0. Non-root users have
# EUIDs starting at 1000 on most Linux systems.
#
# We check $EUID rather than running "id -u" or "whoami" because $EUID is a
# built-in bash variable — it's faster and cannot be spoofed by a PATH attack.
#
# All LUKS operations (clevis luks bind, clevis luks unbind, cryptsetup)
# require root. Failing here with a clear message is better than failing
# midway through an operation with a cryptic "Permission denied" error.
[[ "${EUID}" -eq 0 ]] || fail "Run as root: sudo $0"

# ════════════════════════════════════════════════════════════════════════════
# DEPENDENCY CHECK
# ════════════════════════════════════════════════════════════════════════════
#
# Verify all required external commands are available before doing any work.
# Failing here is much better than failing in the middle of a slot operation.
#
# Tools and why we need them:
#   clevis      — the Clevis tool for managing TPM-backed LUKS slots
#   cryptsetup  — the LUKS management tool (isLuks check, luksDump, unbind)
#   awk         — text processing for parsing cryptsetup and clevis output
#   sort        — sorting slot lists for comm comparison
#   comm        — comparing before/after sorted slot lists
#   lsblk       — listing block devices for LUKS auto-detection
for cmd in clevis cryptsetup awk sort comm lsblk; do
  need_cmd "$cmd"
done

# ════════════════════════════════════════════════════════════════════════════
# SENTINEL WARNING
# ════════════════════════════════════════════════════════════════════════════
#
# If the sentinel file exists, kernel-update-tpm.sh has already started its
# multi-phase process and is waiting for a reboot. Running this script now
# could:
#   - Create a slot the automation's sentinel does not know about. When Phase 2
#     runs, it will read the sentinel's "temp_slot" value and operate on THAT
#     slot — not whatever we create here. This can leave orphaned slots.
#   - Remove a slot the automation is relying on. Phase 2 authorizes the new
#     strict enrollment through the temp slot. If we remove the temp slot here,
#     Phase 2 will fail and the system may not auto-unlock after the next boot.
#
# We warn the user and require them to type "yes" explicitly to confirm they
# understand the risk and intend to proceed (e.g., for manual recovery).
#
# The warning is only shown — the script does NOT read or modify the sentinel.
if [[ -f "$SENTINEL" ]]; then
  echo ""
  warn "kernel-update-tpm.sh sentinel found at: $SENTINEL"
  warn "The automation is mid-run. Running this script now can desync slot"
  warn "tracking and leave the automation in an unrecoverable state."
  warn "Only continue if you are intentionally recovering from a failed run."
  echo ""
  # "read -rp" reads input from the user.
  #   -r  Raw mode: don't interpret backslashes in the user's input
  #   -p  Prompt string to display before reading
  # We embed a color-formatted prompt using $(...) command substitution.
  # The answer is stored in SENTINEL_CONFIRM.
  read -rp "$(echo -e "${YELLOW}[update-clevis-pcrs] WARN:${NC}  Continue anyway? (yes/N): ")" SENTINEL_CONFIRM

  # Require exactly "yes" (all lowercase) to proceed. A single "y" is not
  # accepted because this is a high-risk operation. Requiring the full word
  # ensures the user read the warning and is making a deliberate choice.
  [[ "$SENTINEL_CONFIRM" == "yes" ]] || { log "Aborted."; exit 0; }
  echo ""
fi

# ════════════════════════════════════════════════════════════════════════════
# RESOLVE LUKS DEVICE
# ════════════════════════════════════════════════════════════════════════════

if [[ -z "$LUKSDEV" ]]; then
  # No device was specified on the command line. Try to auto-detect one.
  # detect_luks_device() returns a device path or exits 1 if none found.
  # The "\" at the end of the first line is a line continuation — the two
  # lines form a single logical statement.
  LUKSDEV=$(detect_luks_device) \
    || fail "Could not auto-detect an active LUKS2 device. Pass it as an argument."
  log "Auto-detected LUKS device: $LUKSDEV"
fi

# [[ -b "$LUKSDEV" ]] — verify it's a block device. This catches typos like
# "/dev/sdb" when they meant "/dev/sda2", or a path that doesn't exist.
[[ -b "$LUKSDEV" ]] || fail "Not a block device: $LUKSDEV"

# Verify it's actually a LUKS container. Without this check, someone could
# accidentally pass a non-LUKS device and we'd attempt slot operations on it.
# "2>/dev/null" suppresses cryptsetup's "Not a LUKS device" error message
# since we print our own more informative error via fail().
cryptsetup isLuks "$LUKSDEV" 2>/dev/null || fail "Not a LUKS device: $LUKSDEV"

# ── --list shortcut ──────────────────────────────────────────────────────────
#
# If the user just wants to see current bindings, show them and exit.
# We exit 0 after "list" — this is a read-only operation that always succeeds
# (unless clevis itself errors, which would exit non-zero via set -e).
if $DO_LIST; then
  log "Clevis bindings on $LUKSDEV:"
  # "|| echo" handles the case where there are no bindings at all: clevis
  # may exit non-zero if the device has no token metadata.
  clevis luks list -d "$LUKSDEV" || echo "  (none)"
  exit 0
fi

log "LUKS device: $LUKSDEV"
echo ""

# ════════════════════════════════════════════════════════════════════════════
# HUMAN PASSPHRASE SAFETY CHECK
# ════════════════════════════════════════════════════════════════════════════
#
# This check runs EVERY time, before enrollment or removal, because:
#   - We might remove the only Clevis slot after enrollment fails
#   - We might be doing --remove-only and delete all TPM slots
# Either way, if no human slot exists, we need to know NOW before touching anything.
confirm_human_slot "$LUKSDEV"

# ── Show current state ───────────────────────────────────────────────────────
log "Current Clevis bindings:"
clevis luks list -d "$LUKSDEV" 2>/dev/null || echo "  (none)"
echo ""

# NEW_SLOT will hold the slot number of the slot we create (if any).
# Initialize to empty. The removal section uses this to avoid removing the
# slot we just created.
NEW_SLOT=""

# ════════════════════════════════════════════════════════════════════════════
# ENROLLMENT PHASE
# ════════════════════════════════════════════════════════════════════════════
#
# Skip if --remove-only was specified.
if ! $REMOVE_ONLY; then

  # ── Resolve PCR IDs ────────────────────────────────────────────────────────
  if [[ -z "$PCR_IDS" ]]; then
    # No --pcrs given. Try to detect from the existing Clevis binding.
    # "|| true" prevents set -e from exiting if detect_strict_pcr_ids returns
    # empty (no existing binding). We handle the empty case below.
    PCR_IDS=$(detect_strict_pcr_ids "$LUKSDEV") || true
    if [[ -z "$PCR_IDS" ]]; then
      # No existing binding found. Use the default PCR set.
      PCR_IDS="$DEFAULT_PCR_IDS"
      log "No existing Clevis policy found — using default PCRs: $PCR_IDS"
    else
      log "Auto-detected PCRs from existing binding: $PCR_IDS"
    fi
  fi

  # Build the Clevis TPM2 policy JSON string.
  # Clevis expects the policy as a JSON object with exactly these fields:
  #   pcr_bank: which PCR hash algorithm to use. "sha256" is the modern
  #             standard. SHA-1 is deprecated for PCR measurements.
  #   pcr_ids:  comma-separated PCR register numbers to bind against.
  #
  # The double-quotes inside the string are escaped with backslash-double-quote.
  POLICY="{\"pcr_bank\":\"sha256\",\"pcr_ids\":\"${PCR_IDS}\"}"
  log "Enrollment policy: $POLICY"
  echo ""

  # ── Resolve authorization slot ─────────────────────────────────────────────
  #
  # To create a new Clevis slot, we need to provide the LUKS passphrase that
  # unlocks the LUKS master key. We get this passphrase FROM AN EXISTING
  # CLEVIS SLOT by running "clevis luks pass", which uses the TPM to unseal
  # the slot and outputs the raw LUKS passphrase. We then pipe that to
  # "clevis luks bind" as the authorization.
  #
  # This means you can re-enroll without knowing the human passphrase —
  # the existing TPM slot acts as the authorization credential.
  #
  # This is also a security boundary: if the TPM refuses to unseal
  # (because PCRs don't match, or because the TPM is in a bad state),
  # the re-enrollment will fail, which is the correct behavior.
  if [[ -z "$AUTH_SLOT" ]]; then
    # No --auth-slot given. Try to auto-detect.
    # mapfile -t fills an array from command output, one element per line.
    #   -t strips the trailing newline from each element.
    mapfile -t EXISTING_TPM_SLOTS < <(list_tpm_slots "$LUKSDEV")

    # Use a case statement to handle 0, 1, or multiple existing slots.
    case "${#EXISTING_TPM_SLOTS[@]}" in
      0)
        # No TPM slots at all. We cannot authorize via TPM. The user must
        # either specify --auth-slot with a known slot, or enroll from scratch
        # using a passphrase (which this script does not support — use clevis
        # luks bind directly for the initial enrollment).
        fail "No existing TPM2 slot to authorize from. Use --auth-slot or enroll manually with passphrase first."
        ;;
      1)
        # Exactly one TPM slot. Use it automatically.
        AUTH_SLOT="${EXISTING_TPM_SLOTS[0]}"
        log "Auto-detected auth slot: $AUTH_SLOT"
        ;;
      *)
        # Multiple TPM slots. We cannot guess which one to use for authorization
        # — different slots may have different PCR policies and one may currently
        # be failing to unseal (which is WHY you are running this script).
        # Require the user to be explicit.
        fail "Multiple TPM2 slots found — specify which to authorize from with --auth-slot <n>:
$(clevis luks list -d "$LUKSDEV")"
        ;;
    esac
  fi

  # ── Test auth slot BEFORE binding ─────────────────────────────────────────
  #
  # Verify the authorization slot actually unseals with the current TPM state.
  # If it doesn't, we cannot authorize the new enrollment and there is no
  # point trying. Failing here is safe — no changes have been made yet.
  #
  # ">/dev/null" discards the output (the raw passphrase). We only care
  # whether the command succeeds (exit 0) or fails (exit non-zero).
  log "Verifying auth slot $AUTH_SLOT unseals..."
  if ! $DRY_RUN; then
    clevis luks pass -d "$LUKSDEV" -s "$AUTH_SLOT" >/dev/null \
      || fail "Auth slot $AUTH_SLOT failed to unseal — cannot enroll"
  fi
  ok "Auth slot $AUTH_SLOT OK"
  echo ""

  # ── Snapshot slot list before binding ─────────────────────────────────────
  #
  # mktemp creates a temporary file with a unique name in /tmp and prints
  # its path. We use two temp files to capture the before/after slot lists.
  #
  # "trap 'rm -f ...' EXIT" registers a cleanup command to run when the
  # script exits for ANY reason (normal exit, error, Ctrl-C, kill signal).
  # This ensures temp files are always deleted, even if the script crashes.
  # Without this, temp files accumulate in /tmp, wasting space.
  before_slots=$(mktemp)
  after_slots=$(mktemp)
  trap 'rm -f "$before_slots" "$after_slots"' EXIT

  # Save the current TPM slot list to the before-file.
  list_tpm_slots "$LUKSDEV" > "$before_slots"

  # ── Bind the new slot ──────────────────────────────────────────────────────
  #
  # This is the core enrollment operation. The pipeline:
  #
  # clevis luks pass -d $LUKSDEV -s $AUTH_SLOT
  #   → Unseals auth slot via TPM, outputs the raw LUKS passphrase to stdout.
  #
  # | clevis luks bind -y -k - -d $LUKSDEV tpm2 "$POLICY"
  #   -y  Answer "yes" automatically to the "Are you sure?" prompt.
  #       Without this, the command would wait for interactive input, which
  #       would not work in automated or piped contexts.
  #   -k -  Read the authorization key from stdin (the "-" means stdin).
  #         This is where the passphrase from "clevis luks pass" is consumed.
  #   -d $LUKSDEV  The LUKS device to add the new slot to.
  #   tpm2  The Clevis pin type (TPM2, as opposed to "tang" for network-based).
  #   "$POLICY"  The JSON policy for the new slot (which PCRs to bind against).
  #
  # Why pipe the passphrase rather than prompting for it?
  #   Piping uses the existing TPM slot as proof of authorization. This means:
  #   1. The user does not need to know or type the LUKS passphrase (which
  #      is a long random key, not something humans can remember).
  #   2. The operation is automatable — no interactive passphrase prompt.
  #   3. Authorization is proven through the TPM, so the new enrollment is
  #      only possible if the TPM currently recognizes this boot state.
  log "Binding new TPM2 slot (PCR $PCR_IDS)..."
  if $DRY_RUN; then
    log "[dry-run] Would run: clevis luks pass -d $LUKSDEV -s $AUTH_SLOT | clevis luks bind -y -k - -d $LUKSDEV tpm2 '$POLICY'"
  else
    clevis luks pass -d "$LUKSDEV" -s "$AUTH_SLOT" \
      | clevis luks bind -y -k - -d "$LUKSDEV" tpm2 "$POLICY"

    # Save the post-bind slot list and find the new slot number.
    list_tpm_slots "$LUKSDEV" > "$after_slots"
    NEW_SLOT=$(detect_new_slot "$before_slots" "$after_slots")
    log "New slot created: $NEW_SLOT"

    # ── Test the new slot immediately ────────────────────────────────────────
    #
    # Verify the new slot actually works before we remove any old slots.
    # If this test fails, old slots are still in place — the user can
    # investigate without being locked out.
    #
    # What could cause this to fail?
    #   - The PCRs specified don't match current TPM state. For example,
    #     if you specify PCRs that include the kernel measurement (PCR 9)
    #     and then boot a different kernel, the new slot won't unseal.
    #     The fix: re-enroll with PCRs that match the CURRENT boot state.
    #   - The TPM is in a bad state (e.g., dictionary attack lockout).
    #   - A bug in Clevis or the TPM2 tools.
    log "Testing new slot $NEW_SLOT..."
    clevis luks pass -d "$LUKSDEV" -s "$NEW_SLOT" >/dev/null \
      || fail "New slot $NEW_SLOT failed to unseal — old slots left intact"
    ok "New slot $NEW_SLOT unseals OK"
    echo ""
  fi
fi

# ════════════════════════════════════════════════════════════════════════════
# REMOVAL PHASE
# ════════════════════════════════════════════════════════════════════════════
#
# Skip if --enroll-only was specified.
if ! $ENROLL_ONLY; then

  # Build the final list of slots to remove.
  SLOTS_TO_REMOVE=()

  # If --remove-all-tpm: add every existing TPM slot except the new one.
  if $REMOVE_ALL_TPM; then
    while IFS= read -r s; do
      [[ -z "$s" ]] && continue

      # Skip the slot we just created. Removing it immediately after creating
      # it would be self-defeating. This guard is also important in --remove-only
      # mode where NEW_SLOT is empty — the empty-string comparison always
      # evaluates to false, so all slots are included when NEW_SLOT is "".
      [[ -n "$NEW_SLOT" && "$s" == "$NEW_SLOT" ]] && continue

      SLOTS_TO_REMOVE+=("$s")
    done < <(list_tpm_slots "$LUKSDEV")
  fi

  # Add any slots specified with --remove-slot, but skip the new slot.
  for s in "${REMOVE_SLOTS[@]}"; do
    if [[ -n "$NEW_SLOT" && "$s" == "$NEW_SLOT" ]]; then
      warn "Skipping --remove-slot $s — that is the slot just created"
      continue
    fi
    SLOTS_TO_REMOVE+=("$s")
  done

  # Deduplicate the removal list (in case --remove-slot and --remove-all-tpm
  # both included the same slot). "sort -un" sorts numerically (-n) and
  # removes duplicates (-u). "grep -v '^$'" removes empty lines.
  # "|| true" prevents exit if the array is empty (grep would exit 1 on no output).
  mapfile -t SLOTS_TO_REMOVE < <(printf '%s\n' "${SLOTS_TO_REMOVE[@]:-}" | sort -un | grep -v '^$' || true)

  if [[ ${#SLOTS_TO_REMOVE[@]} -eq 0 ]]; then
    # Nothing to remove. If we also didn't enroll, warn the user.
    [[ -z "$NEW_SLOT" ]] && log "No slots to remove and no enrollment performed — nothing done."
  else
    log "Removing slot(s): ${SLOTS_TO_REMOVE[*]}"
    for s in "${SLOTS_TO_REMOVE[@]}"; do
      if $DRY_RUN; then
        log "[dry-run] Would run: clevis luks unbind -f -d $LUKSDEV -s $s"
      else
        log "Removing slot $s..."
        # "clevis luks unbind -f -d $dev -s $slot"
        #   -f  Force: don't prompt for confirmation. We already confirmed
        #       with the user via the argument they passed.
        #   -d  Device path
        #   -s  Slot number
        # Note: unbind removes BOTH the Clevis token metadata AND the LUKS
        # keyslot, in one command. After this, the slot number is free to
        # be reassigned to a future enrollment.
        clevis luks unbind -f -d "$LUKSDEV" -s "$s"
        ok "Slot $s removed"
      fi
    done
    echo ""
  fi
fi

# ════════════════════════════════════════════════════════════════════════════
# FINAL STATE REPORT
# ════════════════════════════════════════════════════════════════════════════
#
# Always show the final state so the user can verify what the device looks
# like after the script ran. This is especially important for verifying that
# the right slots remain and that no unexpected bindings are present.
log "Final Clevis bindings on $LUKSDEV:"
clevis luks list -d "$LUKSDEV" 2>/dev/null || echo "  (none)"
echo ""

# Summary line: dry-run notes no changes; otherwise confirm success.
if $DRY_RUN; then
  log "[dry-run] No changes were made."
else
  ok "Done."
fi
