From 03492c73294aadf4f56f50edbf9b2363a8c36859 Mon Sep 17 00:00:00 2001 From: William MacKinnon Date: Mon, 21 Sep 2026 03:22:26 -0400 Subject: [PATCH 1/4] Five bugs from the 2026-09-21 audit: diag execute bit, dead fstab branch, chroot on stdin, a summary line that lied, and a pin for every prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. bin/linuxlocker-diag.sh shipped as 100644. linuxlocker.sh exec's it and every document says `sudo ./bin/linuxlocker-diag.sh`; both failed with permission denied. The bug-report tool could not be run to report a bug. 2. The fstab device-path branch (stage 6b) looked for the partition name AFTER the mountpoint. On a real line the device comes first, so `/dev/nvme0n1p3 / ext4 ...` never matched and fell through to the "neither UUID nor PARTUUID found" failure. The sed on the next line was right all along; only the gate was wrong. 3. The chroot phase was a 27 KB here-document on bash's stdin. Under the 64 KB pipe buffer, bash 5.1+ delivers it through a pipe and reads it unbuffered so children can share stdin — so any child that reads stdin (a hook that asks, a debconf prompt) swallows the rest of the script and the phase ends early with a clean exit code, skipping the UKI rebuild, signing and relabel. Reproduced: a script whose tail exits 5 returned 0 after one `cat`. The phase is now written to /tmp/.luks-chroot.sh on the target and run with stdin from /dev/null. 4. The final summary printed "GRUB_ENABLE_CRYPTODISK=y", which stage 6c deliberately never writes. It now states what was actually done. 5. The README said every prompt has a LUKS_* variable behind it; 27 prompts existed and only a handful were pinnable, and without a terminal a bare `read` died under set -e with no message. Every prompt now goes through ask(): a pinned value answers it and is logged; no pin and no terminal stops the run and names the variable. New pins: LUKS_CONFIRM (ENCRYPT|CONFIGURE|CONVERT, mismatch fatal), LUKS_STALE_MAPPER, LUKS_LIVE_OVERRIDE, LUKS_BATTERY_OVERRIDE, LUKS_UNMOUNT, LUKS_RESUME, LUKS_EXISTING, LUKS_DATA_PARTITION, LUKS_MISMATCH_OVERRIDE, LUKS_CROSS_DISK, LUKS_SUBVOL_MISMATCH, LUKS_FSCK, LUKS_FSCK_FORCE, LUKS_ALREADY_SHRUNK; LUKS_PROFILE (plus 'skip') also answers the re-costing menu after a LUKS1 conversion. The inner-UUID-changed override keeps no pin on purpose. README documents all of it, and the Audit section records this pass. All three suites pass: cmdline 71/0, uki 41/0, loopback 44/0 (as root). Co-Authored-By: Claude Fable 5.1 --- README.md | 45 ++++++++++- bin/linuxlocker-diag.sh | 0 bin/luks-deploy.sh | 168 ++++++++++++++++++++++++++++++++-------- 3 files changed, 178 insertions(+), 35 deletions(-) mode change 100644 => 100755 bin/linuxlocker-diag.sh diff --git a/README.md b/README.md index 281881a..4e05993 100644 --- a/README.md +++ b/README.md @@ -424,8 +424,32 @@ LUKS_GRUB_KDF_FACTOR= unlock-time multiplier for a volume G itself unlocks (default 8.5, measured) LUKS_GRUB_ARGON2_MAX_KIB= argon2id ceiling for such a volume (default 1 GiB — the x86 UEFI heap) + +LUKS_CONFIRM=ENCRYPT|CONFIGURE|CONVERT the typed point-of-no-return gate of the + mode the run reaches; a mismatch is fatal +LUKS_STALE_MAPPER=keep|close a mapper left open by an earlier run +LUKS_LIVE_OVERRIDE=LIVE run from a root that is not a live environment +LUKS_BATTERY_OVERRIDE=BATTERY run on battery below 50% +LUKS_UNMOUNT=yes|no unmount a target a live desktop mounted +LUKS_RESUME=yes|no finish an interrupted encryption +LUKS_EXISTING=tune|config|quit what to do with a finished LUKS2 volume +LUKS_PROFILE=...|skip also answers the re-costing menu after a + LUKS1 conversion ('skip' keeps pbkdf2) +LUKS_DATA_PARTITION=yes|no encrypt a volume with no fstab as data +LUKS_MISMATCH_OVERRIDE=MISMATCH keep a pinned /boot or EFI the target's + fstab disagrees with +LUKS_CROSS_DISK=yes|no proceed with /boot or EFI on another disk +LUKS_SUBVOL_MISMATCH=yes|no proceed when BLS and fstab disagree on subvol +LUKS_FSCK=yes|no run the read-only integrity check first +LUKS_FSCK_FORCE=FORCE continue past filesystem errors +LUKS_ALREADY_SHRUNK=yes|no f2fs/vfat: shrunk by an interrupted run? ``` +Without a terminal, a prompt that has no pin stops the run and names the +variable it wanted; nothing hangs, and nothing is answered by default. The one +prompt with no pin at all is the inner-UUID-changed override: that state means +the wrong device is open, and no fleet should answer it blind. + The UKI and Secure Boot knobs are documented in full, with detection order and key-discovery paths, in [BOOTLOADERS.md](docs/BOOTLOADERS.md). @@ -682,8 +706,12 @@ costs you nothing. ### Can I run it unattended across several machines? -Yes. Every prompt has an `LUKS_*` environment variable behind it — see -[Environment knobs](#environment-knobs-fleet--non-interactive-use). Pin +Yes. Every prompt has an `LUKS_*` environment variable behind it, except the +inner-UUID-changed override, which means the wrong device is open and is +never answered blind — see +[Environment knobs](#environment-knobs-fleet--non-interactive-use). Run +without a terminal, a prompt that has no pin stops the run and names its +variable rather than hanging or dying on a failed read. Pin `LUKS_PBKDF_MEMORY` / `_ITER` / `_PARALLEL` for reproducible KDF cost across a fleet rather than per-machine benchmark drift; pinned values below the floor are fatal rather than silently raised, precisely so the numbers you pinned are the @@ -817,6 +845,19 @@ recognition, the harden-only guarantee — and v1.5.0 marks the audited state. Nothing in this tool has changed hands: the design decisions are the author's, the audit checked that the code keeps them. +Audited again on 2026-09-21 by **Claude Fable 5.1**, against v1.6.0: every +script read in full, the three test suites run, and each finding reproduced +before it was reported. Five bugs came out of it and were fixed in the same +pass — the diagnostic script shipped without its execute bit, the fstab +device-path branch could never match a real line, the chroot phase was fed +to bash on stdin where any child that reads it would have swallowed the rest, +the summary claimed a `GRUB_ENABLE_CRYPTODISK=y` the tool never writes, and +the unattended-use claim was not true until every prompt was given a pin. +The design gaps it named that are still open — an end-to-end boot of the +Fedora, Ubuntu and Arch GRUB paths, a console-keymap check for the initramfs, +`sgdisk` in the dependency map — are recognized here so that nobody mistakes +the passing suites for a boot. + ## License and contact MIT — see [LICENSE](LICENSE). diff --git a/bin/linuxlocker-diag.sh b/bin/linuxlocker-diag.sh old mode 100644 new mode 100755 diff --git a/bin/luks-deploy.sh b/bin/luks-deploy.sh index 657d23a..64a9d0e 100755 --- a/bin/luks-deploy.sh +++ b/bin/luks-deploy.sh @@ -124,6 +124,25 @@ warn() { echo -e "[$(date '+%H:%M:%S')] ${YELLOW}[WARN]${NC} $*"; } err() { echo -e "[$(date '+%H:%M:%S')] ${RED}[ERROR]${NC} $*" >&2; } fatal() { err "$@"; exit 1; } +# ask — read an answer into VAR. $ENV, when set, answers +# the prompt without a terminal and is logged as having done so. With neither +# a value nor a terminal the run stops and names the variable, instead of +# dying on a failed read with no message at all. +ask() { + local __var="$1" __env="$2" __prompt="$3" __shown + if [ -n "${!__env:-}" ]; then + printf -v "$__var" '%s' "${!__env}" + __shown="${__prompt#"${__prompt%%[![:space:]]*}"}" + log " $__env=${!__env} answers: ${__shown% }" + return 0 + fi + if [ ! -t 0 ]; then + fatal "No terminal to answer '${__prompt#"${__prompt%%[![:space:]]*}"}' — set $__env (see the header of this script)." + fi + # shellcheck disable=SC2229 # read into the caller's variable, by name + read -r -p "$__prompt" "$__var" +} + # ─── Shared dependency helpers (OS / package manager / installer) ──────────── # Optional: if lib-deps.sh sits next to this script, missing filesystem tools # are auto-installed with the live distro's package manager; without it they @@ -278,6 +297,37 @@ harden_path() { # harden_path # LUKS_GRUB_ARGON2_MAX_KIB= argon2id memory ceiling for such a volume. # Default 1 GiB — the x86 UEFI contiguous heap # +# Every remaining prompt has a pin as well, so a run with no terminal never +# hangs on a read or dies on one without a message: a prompt with neither a +# pin nor a terminal stops the run and names the variable it wanted. The +# typed-word gates take the same word a person would type, never a bare 1, so +# a stray 'yes' cannot open them. +# LUKS_CONFIRM=ENCRYPT|CONFIGURE|CONVERT +# the point-of-no-return gate of the mode the run +# actually reaches; a mismatch is fatal, so a +# pinned ENCRYPT can never reconfigure or convert +# LUKS_STALE_MAPPER=keep|close a /dev/mapper/ left open by an earlier run +# LUKS_LIVE_OVERRIDE=LIVE run from a root that is not a live environment +# LUKS_BATTERY_OVERRIDE=BATTERY run on battery below 50% +# LUKS_UNMOUNT=yes|no unmount the target if a live desktop mounted it +# LUKS_RESUME=yes|no finish an interrupted encryption +# LUKS_EXISTING=tune|config|quit what to do with a finished LUKS2 volume +# LUKS_PROFILE=...|skip also answers the re-costing menu after a LUKS1 +# conversion; 'skip' leaves the keyslots on pbkdf2 +# and is only meaningful there +# LUKS_DATA_PARTITION=yes|no encrypt a volume with no /etc/fstab as data +# LUKS_MISMATCH_OVERRIDE=MISMATCH +# keep a pinned /boot or EFI partition that the +# target's fstab disagrees with +# LUKS_CROSS_DISK=yes|no proceed with /boot or EFI on another disk +# LUKS_SUBVOL_MISMATCH=yes|no proceed when BLS and fstab name different subvolumes +# LUKS_FSCK=yes|no run the read-only integrity check first +# LUKS_FSCK_FORCE=FORCE continue past filesystem errors +# LUKS_ALREADY_SHRUNK=yes|no f2fs/vfat only: was the filesystem shrunk by an +# earlier, interrupted run? +# The one prompt without a pin is the inner-UUID-changed override: that state +# means the wrong device is open, and no fleet should answer it blind. +# # Encrypted /boot is RECOGNISED, never set up. A target whose /boot lives on # the root filesystem under GRUB or extlinux is refused before the shrink: the # bootloader would have to unlock the volume itself, and LinuxLocker does not @@ -688,7 +738,7 @@ cleanup() { umount /mnt 2>/dev/null || true cryptsetup close ${LUKS_NAME} 2>/dev/null || true cryptsetup close "luks-probe-$$" 2>/dev/null || true - rm -f /mnt/tmp/.luks-deploy-env /mnt/tmp/.luks-lib-uki.sh /mnt/tmp/.luks-lib-boot.sh 2>/dev/null || true + rm -f /mnt/tmp/.luks-deploy-env /mnt/tmp/.luks-chroot.sh /mnt/tmp/.luks-lib-uki.sh /mnt/tmp/.luks-lib-boot.sh 2>/dev/null || true if [ $exit_code -ne 0 ]; then echo "" echo "Recovery options:" @@ -722,7 +772,7 @@ if [ -b /dev/mapper/${LUKS_NAME} ]; then warn "Found /dev/mapper/${LUKS_NAME} already open from a previous run!" echo " keep = leave it open and reuse it (saves a passphrase prompt in config-only mode)" echo " close = close it and start fresh" - read -p "Keep or close? (keep/close): " STALE_CHOICE + ask STALE_CHOICE LUKS_STALE_MAPPER "Keep or close? (keep/close): " umount -R /mnt 2>/dev/null || true case "$STALE_CHOICE" in keep) @@ -798,7 +848,7 @@ case "$CURRENT_ROOT_FSTYPE" in err "This script MUST be run from a LIVE USB / rescue environment." err "Running on the installed system WILL destroy your data." echo "" - read -p "Are you certain you are in a live/rescue environment? (Type 'LIVE' to override): " LIVE_OVERRIDE + ask LIVE_OVERRIDE LUKS_LIVE_OVERRIDE "Are you certain you are in a live/rescue environment? (Type 'LIVE' to override): " [ "$LIVE_OVERRIDE" = "LIVE" ] || fatal "Aborted for safety." ;; esac @@ -822,7 +872,7 @@ for ps_dir in /sys/class/power_supply/*/; do err "║ Connect AC power before proceeding. ║" err "╚══════════════════════════════════════════════════════╝" echo "" - read -p "Continue on battery? (Type 'BATTERY' to override): " BAT_OVERRIDE + ask BAT_OVERRIDE LUKS_BATTERY_OVERRIDE "Continue on battery? (Type 'BATTERY' to override): " [ "$BAT_OVERRIDE" = "BATTERY" ] || fatal "Connect AC power and try again." fi break @@ -851,6 +901,8 @@ pick_partition() { # All display/prompts go to stderr so stdout is clean for capture. local role="$1" fstype="$2" label_hint="$3" local -a devs=() disp_labels=() sizes=() disks=() scores=() + # A menu needs a terminal; a fleet run pins the answer instead. + [ -t 0 ] || fatal "No terminal to pick the $role partition — pin it with LUKS_TARGET_ROOT / LUKS_TARGET_BOOT / LUKS_TARGET_EFI." local idx=0 best_idx=0 best_score=-999 # Collect all partitions matching fstype (skip loop devices). @@ -1021,7 +1073,7 @@ ensure_unmounted() { warn " [dry-run] a real run would require unmounting these first." return 0 fi - read -p " Unmount it now? (yes/no): " UNMOUNT_OK + ask UNMOUNT_OK LUKS_UNMOUNT " Unmount it now? (yes/no): " [ "$UNMOUNT_OK" = "yes" ] || fatal "Cannot operate on a mounted device." while IFS= read -r mp; do [ -n "$mp" ] || continue @@ -1177,7 +1229,7 @@ convert_luks1() { log "[dry-run] Nothing was changed." exit 0 fi - read -p " Type 'CONVERT' to convert this LUKS1 header to LUKS2: " CONFIRM_CONV + ask CONFIRM_CONV LUKS_CONFIRM " Type 'CONVERT' to convert this LUKS1 header to LUKS2: " [ "$CONFIRM_CONV" = "CONVERT" ] || fatal "Aborted — header unchanged." # Close any mapper currently backed by this device; convert needs it inactive. @@ -1206,6 +1258,24 @@ convert_luks1() { log " Header converted to LUKS2." # ── Re-cost the keyslots to argon2id ──────────────────────────────────── + # LUKS_PROFILE answers this menu too (aggressive|moderate|fast|skip); the + # menu for a GRUB-unlocked volume takes fast or skip. A pinned value the + # menu does not offer is fatal, never mapped to a default nobody chose. + convert_menu_choice() { # $1 = variable, $2 = prompt + if [ -n "${LUKS_PROFILE:-}" ]; then + case "$LUKS_PROFILE" in + aggressive) printf -v "$1" 1 ;; + moderate) printf -v "$1" 2 ;; + fast) printf -v "$1" 3 ;; + skip) printf -v "$1" 4 ;; + *) fatal "Unknown LUKS_PROFILE '$LUKS_PROFILE' (expected: aggressive, moderate, fast, or skip)" ;; + esac + log " LUKS_PROFILE=$LUKS_PROFILE answers the re-costing menu." + return 0 + fi + [ -t 0 ] || fatal "No terminal to answer the re-costing menu — set LUKS_PROFILE (aggressive|moderate|fast|skip)." + read -r -p "$2" "$1" + } profile=skip if [ "$GRUB_RECOST" = "none" ]; then echo "" @@ -1236,11 +1306,14 @@ convert_luks1() { fi echo "" while true; do - read -p " Select [3-4, default 4=skip]: " KDF_CHOICE + convert_menu_choice KDF_CHOICE " Select [3-4, default 4=skip]: " case "${KDF_CHOICE:-4}" in 3) profile=fast; apply_kdf_profile fast; break ;; 4) profile=skip; break ;; - *) echo " Invalid selection '$KDF_CHOICE' — enter 3 or 4." ;; + *) + [ -z "${LUKS_PROFILE:-}" ] \ + || fatal "LUKS_PROFILE=$LUKS_PROFILE is not offered on a volume GRUB unlocks (fast or skip). Keyslots unchanged." + echo " Invalid selection '$KDF_CHOICE' — enter 3 or 4." ;; esac done else @@ -1255,7 +1328,7 @@ convert_luks1() { fi echo "" while true; do - read -p " Select [1-4, default 2=moderate]: " KDF_CHOICE + convert_menu_choice KDF_CHOICE " Select [1-4, default 2=moderate]: " case "${KDF_CHOICE:-2}" in 1) profile=aggressive; apply_kdf_profile aggressive ;; 2) profile=moderate; apply_kdf_profile moderate ;; @@ -1266,6 +1339,8 @@ convert_luks1() { # The KDF re-runs at every boot on THIS machine; it must fit. if [ "$MEM_TOTAL_KIB" -gt 0 ] && [ "$LUKS_PBKDF_MEMORY" -ge "$MEM_TOTAL_KIB" ]; then warn " $profile needs $((LUKS_PBKDF_MEMORY / 1024)) MiB; this machine has $((MEM_TOTAL_KIB / 1024)) MiB. Pick a smaller profile." + [ -z "${LUKS_PROFILE:-}" ] \ + || fatal "LUKS_PROFILE=$LUKS_PROFILE does not fit this machine's RAM. Keyslots unchanged." continue fi break @@ -1344,7 +1419,7 @@ if blkid "$TARGET_ROOT" | grep -q 'TYPE="crypto_LUKS"'; then echo " (reencrypt --resume-only) and then redo the configuration" echo " phase. This script can do both now." echo "" - read -p " Resume the interrupted encryption now? (yes/no): " RESUME_OK + ask RESUME_OK LUKS_RESUME " Resume the interrupted encryption now? (yes/no): " [ "$RESUME_OK" = "yes" ] \ || fatal "Aborted. Resume manually with: cryptsetup reencrypt --resume-only $TARGET_ROOT" DEPLOY_MODE="resume" @@ -1377,9 +1452,9 @@ if blkid "$TARGET_ROOT" | grep -q 'TYPE="crypto_LUKS"'; then echo " 3) quit — leave everything exactly as it is" echo "" while true; do - read -p " Select [1-3, default 3=quit]: " LUKS2_CHOICE + ask LUKS2_CHOICE LUKS_EXISTING " Select [1-3, default 3=quit]: " case "${LUKS2_CHOICE:-3}" in - 1) + 1|tune) log "Launching the KDF tuning UI..." [ -x "$SCRIPT_DIR/luks-tune.sh" ] \ || fatal "luks-tune.sh not found next to this script." @@ -1388,15 +1463,18 @@ if blkid "$TARGET_ROOT" | grep -q 'TYPE="crypto_LUKS"'; then fi exec "$SCRIPT_DIR/luks-tune.sh" ;; - 2) + 2|config) DEPLOY_MODE="config-only" break ;; - 3) + 3|quit) log "Nothing changed. Bye." exit 0 ;; - *) echo " Invalid selection '$LUKS2_CHOICE' — enter 1, 2 or 3." ;; + *) + [ -z "${LUKS_EXISTING:-}" ] \ + || fatal "LUKS_EXISTING must be tune, config or quit (got '$LUKS_EXISTING'). Nothing changed." + echo " Invalid selection '$LUKS2_CHOICE' — enter 1, 2 or 3." ;; esac done fi @@ -1649,7 +1727,7 @@ if [ ! -f "/mnt_temp/${SUBPATH}etc/fstab" ]; then log "by hand if you want it auto-unlocked; see docs/FILESYSTEMS.md." exit 0 fi - read -p " Encrypt $TARGET_ROOT as a data partition? (yes/no): " DATA_OK + ask DATA_OK LUKS_DATA_PARTITION " Encrypt $TARGET_ROOT as a data partition? (yes/no): " [ "$DATA_OK" = "yes" ] || fatal "Aborted — no system found and data mode declined." SYSTEM_MODE=0 fi @@ -1752,7 +1830,7 @@ resolve_boot_partition() { err " Target fstab wants : $spec → ${fstab_dev}" err " The pinned $role partition likely belongs to a DIFFERENT install." err " Writing boot config there would break BOTH systems." - read -p " Use $dev anyway? (Type 'MISMATCH' to override): " XCHK + ask XCHK LUKS_MISMATCH_OVERRIDE " Use $dev anyway? (Type 'MISMATCH' to override): " [ "$XCHK" = "MISMATCH" ] || fatal "Fix the partition selection and re-run." fi elif [ -n "$fstab_dev" ]; then @@ -1870,7 +1948,8 @@ for i in "${!MNT_MPS[@]}"; do d=$(lsblk -dno PKNAME "${MNT_DEVS[$i]}" 2>/dev/null | head -n1) if [ -n "$d" ] && [ "$d" != "$DISK_ROOT" ]; then warn "${MNT_MPS[$i]} (${MNT_DEVS[$i]}) is on disk '$d' but ROOT is on '$DISK_ROOT'!" - read -p "Proceed with partitions on different disks? (yes/no): " cross_disk + cross_disk="" + ask cross_disk LUKS_CROSS_DISK "Proceed with partitions on different disks? (yes/no): " [ "$cross_disk" = "yes" ] || fatal "Aborted." break fi @@ -2035,7 +2114,8 @@ if [ "$IS_BTRFS" -eq 1 ] && [ -n "$BLS_ROOT_SUBVOL" ] && [ "$BLS_ROOT_SUBVOL" != warn " The script will mount and modify the fstab subvolume." warn " If the system boots from a DIFFERENT subvolume, config changes" warn " may not take effect. Consider fixing this inconsistency first." - read -p " Continue anyway? (yes/no): " subvol_override + subvol_override="" + ask subvol_override LUKS_SUBVOL_MISMATCH " Continue anyway? (yes/no): " [ "$subvol_override" = "yes" ] || fatal "Fix subvolume inconsistency first." fi @@ -2061,15 +2141,17 @@ if [ "$DRY_RUN" = "1" ]; then DO_CHECK="n" log "[dry-run] Skipping the $ORIG_FSTYPE integrity check (a real run offers it here)." else - read -p "Run a read-only $ORIG_FSTYPE integrity check first? (Recommended, takes 5-30 min) [Y/n]: " DO_CHECK + ask DO_CHECK LUKS_FSCK "Run a read-only $ORIG_FSTYPE integrity check first? (Recommended, takes 5-30 min) [Y/n]: " fi -if [ "$DO_CHECK" != "n" ] && [ "$DO_CHECK" != "N" ]; then +case "$DO_CHECK" in [Nn]|[Nn][Oo]) DO_CHECK=n ;; esac +if [ "$DO_CHECK" != "n" ]; then log " Running read-only $ORIG_FSTYPE check (this may take a while)..." if fs_check_ro "$CHECK_DEV" "$ORIG_FSTYPE"; then log " Filesystem check: PASSED" else err " Filesystem check found errors!" - read -p " Continue despite filesystem errors? (Type 'FORCE' to override): " fsck_override + fsck_override="" + ask fsck_override LUKS_FSCK_FORCE " Continue despite filesystem errors? (Type 'FORCE' to override): " [ "$fsck_override" = "FORCE" ] || fatal "Fix filesystem errors before encrypting." fi else @@ -2275,10 +2357,10 @@ if [ "$DRY_RUN" = "1" ]; then fi if [ "$DEPLOY_MODE" = "config-only" ]; then - read -p "Type 'CONFIGURE' to redo the configuration phase: " CONFIRM + ask CONFIRM LUKS_CONFIRM "Type 'CONFIGURE' to redo the configuration phase: " [ "$CONFIRM" = "CONFIGURE" ] || fatal "Aborted." else - read -p "Type 'ENCRYPT' to begin — there is no going back: " CONFIRM + ask CONFIRM LUKS_CONFIRM "Type 'ENCRYPT' to begin — there is no going back: " [ "$CONFIRM" = "ENCRYPT" ] || fatal "Aborted." fi @@ -2311,7 +2393,7 @@ else warn " Cannot probe the $ORIG_FSTYPE size to verify whether a previous run" warn " already shrank it. If this is a RE-RUN after an interruption and the" warn " shrink step had completed, answer yes." - read -p " Was this filesystem already shrunk by a previous run? (yes/NO): " PREV_SHRUNK + ask PREV_SHRUNK LUKS_ALREADY_SHRUNK " Was this filesystem already shrunk by a previous run? (yes/NO): " [ "$PREV_SHRUNK" = "yes" ] && SKIP_SHRINK=1 fi @@ -2448,7 +2530,10 @@ if [ "$INNER_UUID" != "$ORIG_FS_UUID" ]; then err " filesystem. It usually means the open mapper is backed by a DIFFERENT" err " device than expected, or the filesystem was damaged. Continuing" err " would write boot configuration for the wrong system." - read -p " Continue with UUID $INNER_UUID anyway? (Type 'UUID-CHANGED' to override): " UUID_OVERRIDE + # Deliberately no environment pin: this state means the wrong device is + # open, and no unattended run should answer it. Without a terminal the + # read fails and the run stops here. + read -r -p " Continue with UUID $INNER_UUID anyway? (Type 'UUID-CHANGED' to override): " UUID_OVERRIDE [ "$UUID_OVERRIDE" = "UUID-CHANGED" ] || fatal "Aborted — investigate before configuring anything." warn " Override accepted — updating ORIG_FS_UUID to $INNER_UUID." ORIG_FS_UUID="$INNER_UUID" @@ -2690,9 +2775,9 @@ if [ -z "$RK_CHOICE" ]; then if [ "$RK_DEFAULT" = "no" ]; then warn " This is a configuration-only re-run: saying yes adds ANOTHER" warn " keyslot on top of what is already there. Defaulting to no." - read -p " Generate and enroll a recovery key now? [y/N]: " RK_CHOICE + ask RK_CHOICE LUKS_RECOVERY_KEY " Generate and enroll a recovery key now? [y/N]: " else - read -p " Generate and enroll a recovery key now? [Y/n]: " RK_CHOICE + ask RK_CHOICE LUKS_RECOVERY_KEY " Generate and enroll a recovery key now? [Y/n]: " fi # A bare Enter takes the default rather than falling through to "enrol". RK_CHOICE="${RK_CHOICE:-$RK_DEFAULT}" @@ -2863,7 +2948,10 @@ if grep -q "UUID=$ORIG_FS_UUID" /mnt/etc/fstab; then elif [ -n "$ORIG_PARTUUID" ] && grep -q "PARTUUID=$ORIG_PARTUUID" /mnt/etc/fstab; then sed -i "s|PARTUUID=$ORIG_PARTUUID|/dev/mapper/$LUKS_NAME|g" /mnt/etc/fstab log " fstab: PARTUUID=$ORIG_PARTUUID → /dev/mapper/$LUKS_NAME" -elif grep -Eq "^[^#]*[[:space:]]/[[:space:]].*$(basename "$TARGET_ROOT")" /mnt/etc/fstab; then +elif grep -Eq "^[^#]*$(basename "$TARGET_ROOT")[[:space:]]+/[[:space:]]" /mnt/etc/fstab; then + # The device comes BEFORE the mountpoint on an fstab line, so the name is + # matched ahead of the lone '/', the way the sed below already expected. + # (An earlier version looked for it after the mountpoint and never matched.) sed -i "s|^[^#]*$(basename "$TARGET_ROOT")\([[:space:]]\+/[[:space:]]\)|/dev/mapper/$LUKS_NAME\1|" /mnt/etc/fstab log " fstab: $TARGET_ROOT → /dev/mapper/$LUKS_NAME (device-path entry)" elif grep -q "/dev/mapper/$LUKS_NAME" /mnt/etc/fstab; then @@ -3261,8 +3349,13 @@ fi cp "$SCRIPT_DIR/lib-boot.sh" /mnt/tmp/.luks-lib-boot.sh 2>/dev/null \ || warn " Could not copy lib-boot.sh into the chroot — the splash will not be stripped." -CHROOT_RC=0 -chroot /mnt /bin/bash <<'CHROOT_SCRIPT' || CHROOT_RC=$? +# The chroot phase is written to a file and run with stdin closed, never fed +# to bash on its stdin. A here-document of this size travels through a pipe, +# bash reads a pipe unbuffered so that its children can share it, and any +# child that reads stdin — a hook that asks a question, a debconf prompt — +# would swallow the rest of the script and end the phase early with a clean +# exit code, skipping the UKI rebuild, the signing and the relabel. +cat > /mnt/tmp/.luks-chroot.sh <<'CHROOT_SCRIPT' # ── Inside chroot ────────────────────────────────────────────────────────── source /tmp/.luks-deploy-env rm -f /tmp/.luks-deploy-env @@ -3810,7 +3903,9 @@ echo "[CHROOT] ═════════════════════ exit $ERRORS CHROOT_SCRIPT -rm -f /mnt/tmp/.luks-deploy-env 2>/dev/null || true +CHROOT_RC=0 +chroot /mnt /bin/bash /tmp/.luks-chroot.sh /dev/null || true if [ "$CHROOT_RC" -ne 0 ]; then err "Chroot reported $CHROOT_RC error(s)!" @@ -4246,7 +4341,14 @@ fi echo "Summary of changes:" echo " crypttab : $LUKS_NAME UUID=$LUKS_UUID none $CRYPTTAB_OPTS" echo " fstab : /dev/mapper/$LUKS_NAME (was UUID=$ORIG_FS_UUID)" -[ -f /mnt/etc/default/grub ] && echo " grub default: GRUB_ENABLE_CRYPTODISK=y${LUKS_BOOT_ARGS:+ + LUKS kernel args}" +if [ -f /mnt/etc/default/grub ]; then + # GRUB_ENABLE_CRYPTODISK is never written (stage 6c): /boot stays clear. + if [ -n "$LUKS_BOOT_ARGS" ]; then + echo " grub default: GRUB_CMDLINE_LINUX carries the LUKS kernel args (GRUB_ENABLE_CRYPTODISK untouched)" + else + echo " grub default: root=/resume= point at the mapper; unlock is crypttab-driven (GRUB_ENABLE_CRYPTODISK untouched)" + fi +fi [ -f /mnt/etc/kernel/cmdline ] && echo " kernel cmd : ${LUKS_BOOT_ARGS:-'(crypttab-driven)'}" [ -d /mnt/boot/loader/entries ] && [ -n "$LUKS_BOOT_ARGS" ] && echo " BLS entries : ALL updated with LUKS parameters" [ -n "$RPI_CMDLINE" ] && echo " cmdline.txt : root=/dev/mapper/$LUKS_NAME" From ff2303dc294cb76b2af63a9ca7d7d9408277c604 Mon Sep 17 00:00:00 2001 From: William MacKinnon Date: Mon, 21 Sep 2026 03:25:56 -0400 Subject: [PATCH 2/4] pbkdf2 is never an option: say so wherever the conversion menu, its pin, and the README could be read otherwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 'skip' choice after a LUKS1 conversion was worded as "keep pbkdf2", and the new LUKS_PROFILE=skip pin repeated it. pbkdf2 is a plain for loop over a hash — no memory cost, every GPU core runs its own copy — and LinuxLocker never writes it, never offers it, and does not treat a keyslot left on it as hardened. The menu now says "defer the re-costing" with that explanation under it, a deferred re-costing ends with a warning that the volume is not hardened yet, the dry-run line for a GRUB without argon2 says the same, and the README FAQ states it in bold with the for-loop reason. Co-Authored-By: Claude Fable 5.1 --- README.md | 14 ++++++++++---- bin/luks-deploy.sh | 30 ++++++++++++++++++++++++------ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 4e05993..e1e289a 100644 --- a/README.md +++ b/README.md @@ -434,7 +434,8 @@ LUKS_UNMOUNT=yes|no unmount a target a live desktop mount LUKS_RESUME=yes|no finish an interrupted encryption LUKS_EXISTING=tune|config|quit what to do with a finished LUKS2 volume LUKS_PROFILE=...|skip also answers the re-costing menu after a - LUKS1 conversion ('skip' keeps pbkdf2) + LUKS1 conversion ('skip' defers it — + pbkdf2 is never an option) LUKS_DATA_PARTITION=yes|no encrypt a volume with no fstab as data LUKS_MISMATCH_OVERRIDE=MISMATCH keep a pinned /boot or EFI the target's fstab disagrees with @@ -558,9 +559,14 @@ match the target. ### Why argon2id only, and never pbkdf2? -pbkdf2 is CPU-only, which is exactly what a GPU cracking fleet is good at. -argon2id is memory-hard, so an attacker has to buy RAM per guess, not just -cores. All three profiles are argon2id, and the cheapest of them is a hard +pbkdf2 is a simple `for` loop: hash the passphrase, feed the result back in, +repeat. It holds no state larger than one hash, so it needs no memory, and +that is exactly what a GPU cracking fleet is good at — every core runs its +own copy of the loop. argon2id is memory-hard, so an attacker has to buy RAM +per guess, not just cores. **pbkdf2 is never an option here.** LinuxLocker +never writes it, never offers it, and treats a keyslot still on it as not +yet hardened: the `skip` choice after a LUKS1 conversion only defers the +re-costing, and the script says so before it lets you leave. All three profiles are argon2id, and the cheapest of them is a hard floor with no override flag — the tool exists to beat a bare `luksFormat`, not to undercut it. `luks-tune.sh` also converts leftover pbkdf2 keyslots on volumes you encrypted earlier. For what the same loop looks like in BitLocker diff --git a/bin/luks-deploy.sh b/bin/luks-deploy.sh index 64a9d0e..9688b23 100755 --- a/bin/luks-deploy.sh +++ b/bin/luks-deploy.sh @@ -313,8 +313,11 @@ harden_path() { # harden_path # LUKS_RESUME=yes|no finish an interrupted encryption # LUKS_EXISTING=tune|config|quit what to do with a finished LUKS2 volume # LUKS_PROFILE=...|skip also answers the re-costing menu after a LUKS1 -# conversion; 'skip' leaves the keyslots on pbkdf2 -# and is only meaningful there +# conversion; 'skip' defers the re-costing to a +# later run or to luks-tune.sh. pbkdf2 is NEVER +# an option: it is a plain for loop over a hash, +# LinuxLocker never writes it, and a keyslot +# still on it is not hardened # LUKS_DATA_PARTITION=yes|no encrypt a volume with no /etc/fstab as data # LUKS_MISMATCH_OVERRIDE=MISMATCH # keep a pinned /boot or EFI partition that the @@ -1224,7 +1227,8 @@ convert_luks1() { case "$GRUB_RECOST" in any) log "[dry-run] then offer per-keyslot argon2id re-costing (all three profiles)." ;; fast-only) log "[dry-run] then offer per-keyslot argon2id re-costing (1 GiB only — GRUB unlocks it)." ;; - none) log "[dry-run] and leave the keyslots on pbkdf2 (GRUB unlocks it and has no argon2)." ;; + none) log "[dry-run] and cannot re-cost the keyslots: GRUB unlocks this volume and has no argon2 code." + log "[dry-run] They stay as they are. pbkdf2 is never written and never an option here either — install a GRUB with argon2 and re-run." ;; esac log "[dry-run] Nothing was changed." exit 0 @@ -1294,7 +1298,11 @@ convert_luks1() { EST_FAST_GRUB=$(bt_grub_ms "${EST_FAST:-}" || echo "") printf " 3) fast 1 GiB, 9 iterations unlock ~%s in GRUB (~%s kernel-side)\n" \ "$(kdf_fmt_ms "${EST_FAST_GRUB:-}")" "$(kdf_fmt_ms "${EST_FAST:-}")" - echo " 4) skip — keep pbkdf2 (re-run later, or use luks-tune.sh)" + echo " 4) skip — defer the re-costing (re-run later, or use luks-tune.sh)" + echo "" + echo " pbkdf2 is NOT an option to keep. It is a plain for loop over a hash —" + echo " no memory cost, so every GPU core runs its own copy — and a keyslot" + echo " left on it is not hardened. 'skip' only postpones the fix." echo "" echo " aggressive (4 GiB) and moderate (2 GiB) are not offered: GRUB takes" echo " argon2id's memory as ONE contiguous block from the firmware heap," @@ -1302,7 +1310,7 @@ convert_luks1() { if [ -n "${EST_FAST_GRUB:-}" ] && [ "$EST_FAST_GRUB" -ge $((BT_GRUB_RESET_WALL_S * 1000)) ]; then warn " ~$(kdf_fmt_ms "$EST_FAST_GRUB") of uninterrupted compute inside GRUB: firmware" warn " watchdogs have been seen to reset a machine past ~${BT_GRUB_RESET_WALL_S} s. Consider" - warn " keeping pbkdf2, or a custom cost via luks-tune.sh." + warn " deferring the re-costing, or a custom cost via luks-tune.sh." fi echo "" while true; do @@ -1320,7 +1328,11 @@ convert_luks1() { printf " 1) aggressive 4 GiB, 10 iterations unlock ~%s\n" "$(kdf_fmt_ms "${EST_AGG:-}")" printf " 2) moderate 2 GiB, 8 iterations unlock ~%s [default]\n" "$(kdf_fmt_ms "${EST_MOD:-}")" printf " 3) fast 1 GiB, 9 iterations unlock ~%s\n" "$(kdf_fmt_ms "${EST_FAST:-}")" - echo " 4) skip — keep pbkdf2 for now (re-run later, or use luks-tune.sh)" + echo " 4) skip — defer the re-costing (re-run later, or use luks-tune.sh)" + echo "" + echo " pbkdf2 is NOT an option to keep. It is a plain for loop over a hash —" + echo " no memory cost, so every GPU core runs its own copy — and a keyslot" + echo " left on it is not hardened. 'skip' only postpones the fix." if [ "$MEM_TOTAL_KIB" -gt 0 ] && [ "$MEM_TOTAL_KIB" -lt $((6 * 1024 * 1024)) ]; then echo "" echo -e " ${YELLOW}This machine has $((MEM_TOTAL_KIB / 1024 / 1024)) GiB RAM — 'aggressive' (4 GiB) is NOT safe" @@ -1376,6 +1388,12 @@ convert_luks1() { warn " cryptsetup luksConvertKey -S $slot --hash sha512 --pbkdf argon2id $dev" fi done + elif [ "$GRUB_RECOST" != "none" ]; then + echo "" + warn " Re-costing deferred. The keyslots are still pbkdf2 — a plain for loop" + warn " over a hash with no memory cost — so this volume is NOT hardened yet." + warn " pbkdf2 is never an option in LinuxLocker; re-run this script or" + warn " luks-tune.sh and convert every slot to argon2id before relying on it." fi echo "" From 969edf978b0056ed2c5a041f15be292802004dbb Mon Sep 17 00:00:00 2001 From: William MacKinnon Date: Mon, 21 Sep 2026 03:31:53 -0400 Subject: [PATCH 3/4] =?UTF-8?q?README:=20pbkdf2=20is=20a=20for=20loop,=20a?= =?UTF-8?q?nd=20BitLocker=20and=20FileVault=20still=20ship=20one=20?= =?UTF-8?q?=E2=80=94=20with=20cosmic=20time?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A new subsection under the BitLocker/FileVault comparison: the loop written out in four lines, the year it was standardized against the GPU of that year, why N buys nothing against sixteen thousand cores, and what each company put in front of its loop instead of replacing it. Ends with the cosmic-era table from luks-tune.sh's own model, run over all three functions with published RTX 4090 hashcat rates for the two loops. Co-Authored-By: Claude Fable 5.1 --- README.md | 80 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/README.md b/README.md index e1e289a..a66b511 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,86 @@ make every guess genuinely expensive on any silicon, and let the passphrase do the rest — which is why the passphrase advice here is not decoration. Under Linux it is the TPM you chose not to trust. +### pbkdf2 is a `for` loop — and two of the richest companies on Earth still ship it + +Strip the acronym off and this is what PBKDF2 is: + +``` +u = HMAC(password, salt) +for i in 2..N: + u = HMAC(password, u) # keep a running XOR; that is the key +``` + +That is the whole function. One hash, fed back into itself N times, with a +few hundred bytes of state. It was standardized in September 2000 (PKCS #5 +v2.0, RFC 2898), when the fastest graphics card you could buy had four pixel +pipelines and no programmable shaders. Its only knob is N, and N buys you +linear time on a machine that has nothing but time: a modern GPU holds +sixteen thousand cores, each of which runs this loop on its own password +with no need to talk to any other. Raising N by ten makes the attacker wait +ten times longer, and makes you wait ten times longer, and the attacker has +ten thousand times more cores than you do. That is the entire negotiating +position of a memory-free KDF, and it was lost before it started. + +Password hashing moved on. The Password Hashing Competition ran from 2013 +to 2015 precisely because the field had understood that the only cost a GPU +cannot parallelize away is **memory**: argon2 won, argon2id became RFC 9106, +and LUKS2 made it the default in cryptsetup 2.0. Every Linux distribution's +installer has been writing argon2 keyslots since 2018. It is not exotic. It +is not new. It is what a disk encryptor does now. + +**Apple's FileVault** stretches your login password with PBKDF2-SHA256. The +iteration count was last seen in public in 2012, when three researchers had +to reverse-engineer it to find out (41,000 rounds, in *Infiltrate the Vault*); +Apple has not published a number since and does not document the function +at all. On an Intel Mac without a T2, that loop is the entire wall between +your data and a graphics card. On newer Macs the Secure Enclave takes over, +which is Apple's tacit admission of the same point: the function cannot be +allowed anywhere an attacker can run it, so it is hidden inside a chip and +you are asked to trust the chip. + +**Microsoft's BitLocker** does not even reach PBKDF2. Its password and +recovery-key paths stretch with a home-grown chained SHA-256, 2^20 rounds, +another memory-free loop — and its *default* mode stretches nothing at all: +TPM-only BitLocker releases the volume key when the firmware measurements +match, and no passphrase is ever typed. The key then crosses a bus. Discrete +TPMs have had it sniffed off the SPI lines with a logic analyser in under a +minute, on camera, more than once. Microsoft's answer to "what if the +attacker has a GPU" was to move the problem into a chip and hope the wires +hold. They did not. + +Both companies own more silicon than most governments. Both employ people +who know exactly what argon2 is. Both ship a `for` loop from the year 2000 +and put a hardware chaperone in front of it, so that the loop is never +caught outside alone. Linux ships argon2id and lets it stand in the open, +because it can. + +**Cosmic time.** `luks-tune.sh` states the cost of every keyslot it writes +as the years a thousand 24 GiB GPUs would need to search half the passphrase +space, anchored to what the universe is doing by then. Run the same model +over the three functions — the GPU rates for the two loops are a +[published RTX 4090 hashcat run](https://gist.github.com/Chick3nman/32e662a5bb63bc4f51b847bb422222fd) +(BitLocker: 10,025 guesses/s per card; PBKDF2-SHA256: 8.87 billion +iterations/s per card, so ~216,000 guesses/s at 41,000 rounds); argon2id is +LinuxLocker's `aggressive` profile at the reference machine's 16 s per guess, +six guesses per card because 24 GiB holds six 4 GiB working sets: + +| Passphrase | BitLocker SHA-256 chain | FileVault PBKDF2 | LinuxLocker argon2id (4 GiB) | +|---|---|---|---| +| a typical human password (~40 bits) | **15 hours** | **42 minutes** | decades | +| 6 diceware words (77 bits) | 10^8 years — less than the age of the universe | 10^7 years — less than the age of the universe | 10^13 years — **long past the age of the universe** | +| 8 diceware words (103 bits) | 10^16 years — every star has burned out | 10^15 years — every star has burned out | 10^21 years — **galaxies have evaporated** | + +Read the top row. That is the password most people actually have, and +against the two loops it is a lunch break. The passphrase rows are where the +loops look respectable — until you notice that on those rows argon2id is a +million times further out, and that the universe is 1.4 × 10^10 years old: +six good words behind a `for` loop still fall inside its lifetime; behind +argon2id they do not. The KDF sets the price of one guess; the passphrase +sets how many guesses. Microsoft and Apple chose to keep the price low and +guard the till with a chip. LinuxLocker charges 4 GiB at the door, on any +silicon, and the chip is not invited. + ## Environment knobs (fleet / non-interactive use) ``` From e66ae8b4ef1eba13d81da7f23a81191267a66815 Mon Sep 17 00:00:00 2001 From: William MacKinnon Date: Mon, 21 Sep 2026 03:33:19 -0400 Subject: [PATCH 4/4] =?UTF-8?q?README:=20the=20same=20table=20against=20an?= =?UTF-8?q?=20ASIC=20farm=20=E2=80=94=20Bitcoin's=20zettahash=20of=20SHA-2?= =?UTF-8?q?56=20versus=20argon2id's=20bill=20for=20DRAM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BitLocker's chain and FileVault's PBKDF2 are built on SHA-256, the one hash with a zettahash per second of purpose-built silicon already running. The network's 1,000 EH/s and one Antminer S21 XP's 270 TH/s are worked through the same cosmic-time model: six diceware words fall in 2.5 years and 72 days respectively. argon2id at 4 GiB is bounded by DRAM bandwidth and capacity, which no ASIC discounts; a generous thousand-part memory-bound farm still leaves six words past the age of the universe. Co-Authored-By: Claude Fable 5.1 --- README.md | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/README.md b/README.md index a66b511..2f9c77c 100644 --- a/README.md +++ b/README.md @@ -479,6 +479,45 @@ sets how many guesses. Microsoft and Apple chose to keep the price low and guard the till with a chip. LinuxLocker charges 4 GiB at the door, on any silicon, and the chip is not invited. +**And against an ASIC farm.** A graphics card is a general-purpose machine +being polite. The real adversary of a memory-free hash is the ASIC: a chip +that does one hash and nothing else, and the hash it does, by the +exahash, is SHA-256 — the exact primitive inside BitLocker's chain and +FileVault's PBKDF2. This is not hypothetical hardware. It is the most +mass-produced special-purpose silicon on the planet, and there is roughly a +zettahash per second of it running right now: the Bitcoin network sat at +about 1,000 EH/s of SHA-256d through 2026, which is two thousand billion +billion SHA-256 compressions every second, all day, for a reward that +happens to be a coin. One +[Antminer S21 XP](https://support.bitmain.com/hc/en-us/articles/35383015643673-S21-XP-Specifications) +does 270 TH/s from a 3,645 W wall socket; on BitLocker's 2^20-round chain +that is about 260 million guesses a second, or twenty-five thousand RTX 4090s +in a box the size of a shoebox. Point the network at the loops and the +table above collapses: + +| Passphrase | BitLocker chain, all of Bitcoin's ASICs | FileVault PBKDF2, the same | argon2id 4 GiB, a thousand memory-bound ASICs* | +|---|---|---|---| +| a typical human password (~40 bits) | **0.6 milliseconds** | **45 microseconds** | two months | +| 6 diceware words (77 bits) | **2.5 years** | **72 days** | 10^10 years — long past the age of the universe | +| 8 diceware words (103 bits) | 10^8 years — less than the age of the universe | 10^7 years — less than the age of the universe | 10^18 years — **galaxies have evaporated** | + +\* There is no argon2id ASIC to buy, and the reason is the point. argon2id +at 4 GiB and ten passes moves about 80 GiB through memory per guess, so a +chip built for nothing else is bounded by DRAM bandwidth and DRAM capacity, +and those cost the attacker exactly what they cost you. The column assumes a +thousand parts each with 8 TB/s of HBM — twice the best accelerator on sale +— and 400 GiB resident to keep a hundred guesses in flight, so a hundred +guesses a second each. That is a generous upper bound on a machine nobody +has built, and six words are still past the age of the universe behind it. + +Six diceware words against Microsoft's function, on hardware that exists and +is switched on today, is a project of two and a half years. Against Apple's, +ten weeks. Nobody will redirect Bitcoin at your laptop; but the number says +what the function is worth on its own, which is why neither company lets it +stand on its own. argon2id's answer to the ASIC is not a bigger N. It is a +bill for 4 GiB of RAM per guess that no foundry can discount — the only +currency a hash can charge that special silicon does not devalue. + ## Environment knobs (fleet / non-interactive use) ```