fix(hyprlua): correct howdy CLI + add howdy/FIDO PAM 2FA setup

enroll-biometrics.sh used non-existent howdy flags and a broken install
path. Fixes and additions:

- howdy add: label has no flag; feed it via stdin, use -U "$USER"
- howdy remove: id is positional, not -I
- list/test: pass -U "$USER" so all ops target the same account
- install: howdy is AUR-only, so the pacman fallback could never work;
  require an AUR helper (yay/paru) and message clearly if absent
- new PAM 2FA menu: enroll FIDO key + wire howdy + pam_u2f into
  sudo/hyprlock/login (both factors required, password fallback kept)
- hyprlock gets its own clean fallback (include system-auth) so the
  block is not re-run via include login
- idempotent, timestamped backups, and a symmetric teardown option

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
Amir Alexander Abdelbaki 2026-06-13 21:55:06 +02:00
parent 325f5fcc1a
commit d35d6d17d6
1 changed files with 198 additions and 16 deletions

View File

@ -10,6 +10,16 @@ PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py" PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py"
# ── PAM 2FA (howdy face + FIDO U2F) ────────────────────────────────────────────
# Both factors required, with the normal password stack kept as a fallback so a
# dead camera or absent key can never lock you out.
PAM_TARGETS=(/etc/pam.d/sudo /etc/pam.d/hyprlock /etc/pam.d/login)
PAM_HOWDY_SO="/usr/lib/security/pam_howdy.so"
PAM_U2F_SO="/usr/lib/security/pam_u2f.so"
U2F_MAP="/etc/u2f_mappings"
PAM_MARK_BEGIN="# enroll-biometrics:begin howdy+u2f 2fa"
PAM_MARK_END="# enroll-biometrics:end howdy+u2f 2fa"
# ── Dialog theme (Cyberqueer) ───────────────────────────────────────────────── # ── Dialog theme (Cyberqueer) ─────────────────────────────────────────────────
TMP_D=$(mktemp -d) TMP_D=$(mktemp -d)
trap 'rm -rf "$TMP_D"' EXIT INT TERM trap 'rm -rf "$TMP_D"' EXIT INT TERM
@ -137,16 +147,26 @@ howdy_installed() { command -v howdy &>/dev/null; }
howdy_require() { howdy_require() {
howdy_installed && return 0 howdy_installed && return 0
dialog --backtitle "$BACKTITLE" --title " Howdy Not Found " \
--yesno "\nhowdy is not installed.\n\nInstall it now via yay?" 8 48 || return 1 # howdy lives only in the AUR — it is not in the official repos, so a
clear # plain `pacman -S howdy` always fails. Require an AUR helper.
printf "\nInstalling howdy...\n\n" local helper
if command -v yay &>/dev/null; then for h in yay paru; do
yay -S --noconfirm --needed howdy command -v "$h" &>/dev/null && { helper="$h"; break; }
else done
sudo pacman -S --noconfirm --needed howdy if [[ -z "$helper" ]]; then
msg "No AUR Helper" \
"howdy is an AUR package and needs an AUR helper (yay or paru) to install.\n\nInstall yay first, then re-run this menu." \
10 60
return 1
fi fi
howdy_installed || { msg "Install Failed" "howdy installation failed.\nInstall it manually: yay -S howdy" 8 52; return 1; }
dialog --backtitle "$BACKTITLE" --title " Howdy Not Found " \
--yesno "\nhowdy is not installed.\n\nInstall it now via ${helper}?" 8 48 || return 1
clear
printf "\nInstalling howdy via %s...\n\n" "$helper"
"$helper" -S --noconfirm --needed howdy
howdy_installed || { msg "Install Failed" "howdy installation failed.\nInstall it manually: ${helper} -S howdy" 8 52; return 1; }
} }
# ── Howdy operations ────────────────────────────────────────────────────────── # ── Howdy operations ──────────────────────────────────────────────────────────
@ -164,8 +184,9 @@ howdy_add() {
printf "\n\033[1;35m Enrolling face model '%s'...\033[0m\n" "$name" printf "\n\033[1;35m Enrolling face model '%s'...\033[0m\n" "$name"
printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" printf "\033[35m ─────────────────────────────────────────\033[0m\n\n"
printf " Look at the camera and hold still.\n\n" printf " Look at the camera and hold still.\n\n"
sudo howdy add -n "$name" # howdy has no label flag; the label is read from stdin during `add`.
local rc=$? printf '%s\n' "$name" | sudo howdy -U "$USER" add
local rc=${PIPESTATUS[1]}
if [[ $rc -eq 0 ]]; then if [[ $rc -eq 0 ]]; then
msg "Enrolled" \ msg "Enrolled" \
@ -178,14 +199,14 @@ howdy_add() {
howdy_list() { howdy_list() {
howdy_require || return howdy_require || return
local out; out=$(sudo howdy list 2>&1) local out; out=$(sudo howdy -U "$USER" list 2>&1)
msg "Enrolled Face Models" "$out" 20 64 msg "Enrolled Face Models" "$out" 20 64
} }
howdy_remove() { howdy_remove() {
howdy_require || return howdy_require || return
local models; models=$(sudo howdy list 2>&1) local models; models=$(sudo howdy -U "$USER" list 2>&1)
if [[ -z "$models" || "$models" == *"no models"* ]]; then if [[ -z "$models" || "$models" == *"no models"* ]]; then
msg "No Models" "No face models are currently enrolled." 7 44 msg "No Models" "No face models are currently enrolled." 7 44
return return
@ -203,7 +224,7 @@ howdy_remove() {
dialog --backtitle "$BACKTITLE" --title " Confirm " \ dialog --backtitle "$BACKTITLE" --title " Confirm " \
--yesno "\nRemove face model ID $id?" 7 40 || return --yesno "\nRemove face model ID $id?" 7 40 || return
sudo howdy remove -I "$id" sudo howdy -U "$USER" remove "$id"
msg "Done" "Face model $id removed." 6 38 msg "Done" "Face model $id removed." 6 38
} }
@ -213,7 +234,7 @@ howdy_test() {
printf "\n\033[1;35m Testing howdy authentication...\033[0m\n" printf "\n\033[1;35m Testing howdy authentication...\033[0m\n"
printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" printf "\033[35m ─────────────────────────────────────────\033[0m\n\n"
printf " Look at the camera.\n\n" printf " Look at the camera.\n\n"
sudo howdy test sudo howdy -U "$USER" test
local rc=$? local rc=$?
if [[ $rc -eq 0 ]]; then if [[ $rc -eq 0 ]]; then
msg "Result" "Authentication successful." 6 38 msg "Result" "Authentication successful." 6 38
@ -222,18 +243,177 @@ howdy_test() {
fi fi
} }
# ── FIDO U2F helpers ────────────────────────────────────────────────────────────
fido_installed() { [[ -e "$PAM_U2F_SO" ]] && command -v pamu2fcfg &>/dev/null; }
fido_require() {
fido_installed && return 0
dialog --backtitle "$BACKTITLE" --title " FIDO U2F Not Found " \
--yesno "\npam-u2f is not installed.\n\nInstall it now via pacman?" 8 50 || return 1
clear
printf "\nInstalling pam-u2f...\n\n"
sudo pacman -S --noconfirm --needed pam-u2f
fido_installed || { msg "Install Failed" "pam-u2f installation failed.\nInstall it manually: sudo pacman -S pam-u2f" 8 56; return 1; }
}
# Ensure $USER has a registered key in the central mapping; register one if not.
fido_register() {
if sudo grep -q "^${USER}:" "$U2F_MAP" 2>/dev/null; then
dialog --backtitle "$BACKTITLE" --title " FIDO Key " \
--yesno "\nA FIDO key is already registered for $USER.\n\nRegister an additional key?" 9 56 \
|| return 0
clear
printf "\n\033[1;35m Insert your FIDO key and touch it when it blinks...\033[0m\n\n"
local extra
extra=$(pamu2fcfg -n -u "$USER") || { msg "Registration Failed" "Could not read FIDO key." 7 44; return 1; }
[[ -z "$extra" ]] && { msg "No Key" "No FIDO key detected." 7 36; return 1; }
# pamu2fcfg -n prints just the credential; append it to the user's line.
sudo sed -i "s|^\(${USER}:.*\)\$|\1:${extra}|" "$U2F_MAP"
return 0
fi
clear
printf "\n\033[1;35m Registering FIDO key for %s...\033[0m\n" "$USER"
printf "\033[35m ─────────────────────────────────────────\033[0m\n\n"
printf " Insert your FIDO key and touch it when it blinks.\n\n"
local line
line=$(pamu2fcfg -u "$USER") || { msg "Registration Failed" "Could not read FIDO key.\n\nMake sure it is plugged in." 9 48; return 1; }
[[ -z "$line" ]] && { msg "No Key" "No FIDO key detected." 7 36; return 1; }
printf '%s\n' "$line" | sudo tee -a "$U2F_MAP" >/dev/null
sudo chmod 600 "$U2F_MAP"
sudo chown root:root "$U2F_MAP"
return 0
}
# ── PAM file editing ──────────────────────────────────────────────────────────
# The inserted block: try howdy, then U2F. Both must pass to satisfy auth and
# skip the password stack; if either fails, control falls through to the
# existing (password) auth lines below.
pam_emit_block() {
printf '%s\n' "$PAM_MARK_BEGIN"
printf 'auth [success=ignore default=1] pam_howdy.so\n'
printf 'auth sufficient pam_u2f.so authfile=%s cue\n' "$U2F_MAP"
printf '%s\n' "$PAM_MARK_END"
}
pam_apply_file() {
local f="$1"
[[ -f "$f" ]] || { printf ' skip %s (not present)\n' "$f"; return 0; }
if sudo grep -qF "$PAM_MARK_BEGIN" "$f"; then
printf ' ok %s (already configured)\n' "$f"; return 0
fi
sudo cp -a "$f" "${f}.bak.$(date +%s)"
local tmp first; tmp=$(mktemp); first=$(sudo head -n1 "$f")
if [[ "$first" == "#%PAM"* ]]; then
{ printf '%s\n' "$first"; pam_emit_block; sudo tail -n +2 "$f"; } > "$tmp"
else
{ pam_emit_block; sudo cat "$f"; } > "$tmp"
fi
sudo cp "$tmp" "$f"; rm -f "$tmp"
printf ' added %s\n' "$f"
}
pam_remove_file() {
local f="$1"
[[ -f "$f" ]] || return 0
sudo grep -qF "$PAM_MARK_BEGIN" "$f" || { printf ' skip %s (not configured)\n' "$f"; return 0; }
sudo cp -a "$f" "${f}.bak.$(date +%s)"
local tmp; tmp=$(mktemp)
sudo awk -v b="$PAM_MARK_BEGIN" -v e="$PAM_MARK_END" \
'$0==b{s=1} !s{print} $0==e{s=0}' "$f" > "$tmp"
sudo cp "$tmp" "$f"; rm -f "$tmp"
printf ' removed from %s\n' "$f"
}
# hyprlock ships as `auth include login`. Since the 2FA block also lands in
# /etc/pam.d/login, a plain include would re-run the block on the failure path.
# Give hyprlock its own clean fallback by pointing it at system-auth instead.
HYPRLOCK_REDIRECT='s/^(auth[[:space:]]+include[[:space:]]+)login[[:space:]]*$/\1system-auth/'
HYPRLOCK_RESTORE='s/^(auth[[:space:]]+include[[:space:]]+)system-auth[[:space:]]*$/\1login/'
pam_apply_hyprlock() {
local f="/etc/pam.d/hyprlock"
[[ -f "$f" ]] || { printf ' skip %s (not present)\n' "$f"; return 0; }
if sudo grep -qF "$PAM_MARK_BEGIN" "$f"; then
printf ' ok %s (already configured)\n' "$f"; return 0
fi
sudo cp -a "$f" "${f}.bak.$(date +%s)"
local tmp first; tmp=$(mktemp); first=$(sudo head -n1 "$f")
if [[ "$first" == "#%PAM"* ]]; then
{ printf '%s\n' "$first"; pam_emit_block
sudo tail -n +2 "$f" | sed -E "$HYPRLOCK_REDIRECT"; } > "$tmp"
else
{ pam_emit_block; sudo cat "$f" | sed -E "$HYPRLOCK_REDIRECT"; } > "$tmp"
fi
sudo cp "$tmp" "$f"; rm -f "$tmp"
printf ' added %s (fallback → system-auth)\n' "$f"
}
pam_remove_hyprlock() {
local f="/etc/pam.d/hyprlock"
[[ -f "$f" ]] || return 0
sudo grep -qF "$PAM_MARK_BEGIN" "$f" || { printf ' skip %s (not configured)\n' "$f"; return 0; }
sudo cp -a "$f" "${f}.bak.$(date +%s)"
local tmp; tmp=$(mktemp)
sudo awk -v b="$PAM_MARK_BEGIN" -v e="$PAM_MARK_END" \
'$0==b{s=1} !s{print} $0==e{s=0}' "$f" | sed -E "$HYPRLOCK_RESTORE" > "$tmp"
sudo cp "$tmp" "$f"; rm -f "$tmp"
printf ' removed from %s (fallback → login)\n' "$f"
}
pam_setup() {
howdy_require || return
fido_require || return
if [[ ! -e "$PAM_HOWDY_SO" ]]; then
msg "Missing PAM Module" "pam_howdy.so not found at:\n$PAM_HOWDY_SO\n\nReinstall howdy and try again." 10 60
return
fi
dialog --backtitle "$BACKTITLE" --title " Set Up PAM 2FA " --yesno \
"\nThis will require BOTH face (howdy) and a FIDO key for:\n\n ${PAM_TARGETS[*]}\n\nYour password still works if a factor is unavailable.\nBackups of each file are saved as *.bak.<timestamp>.\n\nContinue?" 15 64 || return
fido_register || return
clear
printf "\n\033[1;35m Configuring PAM...\033[0m\n"
printf "\033[35m ─────────────────────────────────────────\033[0m\n\n"
local f
for f in "${PAM_TARGETS[@]}"; do
if [[ "$f" == */hyprlock ]]; then pam_apply_hyprlock; else pam_apply_file "$f"; fi
done
printf "\n"
msg "PAM Configured" \
"Face + FIDO 2FA is active (password fallback kept).\n\n*** KEEP THIS TERMINAL OPEN. ***\nIn a NEW terminal, run: sudo -k; sudo true\nand confirm you can authenticate.\n\nIf locked out, restore a *.bak.<timestamp> file." 15 64
}
pam_teardown() {
dialog --backtitle "$BACKTITLE" --title " Remove PAM 2FA " --yesno \
"\nRemove the howdy + FIDO 2FA block from:\n\n ${PAM_TARGETS[*]}\n\nContinue?" 12 64 || return
clear
printf "\n\033[1;35m Removing PAM 2FA...\033[0m\n\n"
local f
for f in "${PAM_TARGETS[@]}"; do
if [[ "$f" == */hyprlock ]]; then pam_remove_hyprlock; else pam_remove_file "$f"; fi
done
printf "\n"
msg "PAM Restored" "The 2FA block was removed.\nBackups remain as *.bak.<timestamp>." 8 56
}
# ── Main menu ───────────────────────────────────────────────────────────────── # ── Main menu ─────────────────────────────────────────────────────────────────
main_menu() { main_menu() {
local choice local choice
choice=$(dialog --backtitle "$BACKTITLE" \ choice=$(dialog --backtitle "$BACKTITLE" \
--title " Biometric Enrollment " \ --title " Biometric Enrollment " \
--menu "\nSelect an option:" 20 66 8 \ --menu "\nSelect an option:" 22 70 9 \
"1" "Presence detection — configure camera" \ "1" "Presence detection — configure camera" \
"2" "Presence detection — test detection" \ "2" "Presence detection — test detection" \
"3" "Howdy face auth — add face model" \ "3" "Howdy face auth — add face model" \
"4" "Howdy face auth — list enrolled models" \ "4" "Howdy face auth — list enrolled models" \
"5" "Howdy face auth — remove face model" \ "5" "Howdy face auth — remove face model" \
"6" "Howdy face auth — test authentication" \ "6" "Howdy face auth — test authentication" \
"7" "PAM 2FA — set up howdy + FIDO key" \
"8" "PAM 2FA — remove howdy + FIDO key" \
3>&1 1>&2 2>&3) || { clear; exit 0; } 3>&1 1>&2 2>&3) || { clear; exit 0; }
case "$choice" in case "$choice" in
@ -243,6 +423,8 @@ main_menu() {
4) howdy_list ;; 4) howdy_list ;;
5) howdy_remove ;; 5) howdy_remove ;;
6) howdy_test ;; 6) howdy_test ;;
7) pam_setup ;;
8) pam_teardown ;;
esac esac
main_menu main_menu
} }