#!/bin/bash # enroll-biometrics.sh — TUI for face biometric setup. # # Two subsystems: # 1. Presence detection — configure/test the webcam used by presence-detect.sh # 2. Howdy face auth — enroll/manage/test face models for PAM authentication BACKTITLE="Biometric Enrollment" PRESENCE_CFG="${XDG_CONFIG_HOME:-$HOME/.config}/presence-detect.conf" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PYTHON_DETECT="$SCRIPT_DIR/python/presence_detect.py" # howdy keeps its own camera setting in this INI, entirely separate from the # presence-detect camera. Enrollment aborts in VideoCapture before touching the # lens if `device_path` is left at the package default of `none`, so the howdy # menu has to write a real /dev/video* node in here. HOWDY_CONFIG="/usr/lib/security/howdy/config.ini" # ── 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) # howdy 2.x has no pam_howdy.so — it authenticates through pam_python.so loading # its own entrypoint script. pam_python.so ships in the AUR package `pam-python`. PAM_PYTHON_SO="/usr/lib/security/pam_python.so" HOWDY_PAM_PY="/usr/lib/security/howdy/pam.py" 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) ───────────────────────────────────────────────── TMP_D=$(mktemp -d) trap 'rm -rf "$TMP_D"' EXIT INT TERM export DIALOGRC="$TMP_D/dialogrc" cat > "$DIALOGRC" << 'RCEOF' use_shadow = ON use_colors = ON screen_color = (BLACK,BLACK,ON) shadow_color = (BLACK,BLACK,ON) title_color = (MAGENTA,BLACK,ON) border_color = (MAGENTA,BLACK,ON) button_active_color = (BLACK,MAGENTA,ON) button_inactive_color = (WHITE,BLACK,OFF) button_key_active_color = (BLACK,CYAN,ON) button_key_inactive_color = (CYAN,BLACK,ON) button_label_active_color = (BLACK,MAGENTA,ON) button_label_inactive_color = (WHITE,BLACK,OFF) inputbox_color = (WHITE,BLACK,OFF) inputbox_border_color = (MAGENTA,BLACK,ON) menubox_color = (WHITE,BLACK,OFF) menubox_border_color = (MAGENTA,BLACK,ON) item_color = (WHITE,BLACK,OFF) item_selected_color = (BLACK,MAGENTA,ON) tag_color = (CYAN,BLACK,ON) tag_selected_color = (BLACK,CYAN,ON) check_color = (WHITE,BLACK,OFF) check_selected_color = (BLACK,MAGENTA,ON) uarrow_color = (MAGENTA,BLACK,ON) darrow_color = (MAGENTA,BLACK,ON) RCEOF # ── Helpers ─────────────────────────────────────────────────────────────────── die() { clear; printf "\nError: %s\n\n" "$1" >&2; exit 1; } msg() { dialog --backtitle "$BACKTITLE" --title " $1 " --msgbox "\n$2\n" "$3" "$4" } require_dialog() { command -v dialog &>/dev/null && return sudo pacman -S --noconfirm dialog || die "dialog not found" } # ── Camera helpers ───────────────────────────────────────────────────────────── list_cameras() { for dev in /dev/video*; do [[ -c "$dev" ]] || continue local id="${dev#/dev/video}" local name name=$(v4l2-ctl --device="$dev" --info 2>/dev/null \ | awk -F': ' '/Card type/{print $2}' | head -1) [[ -z "$name" ]] && name="Camera $id" printf '%s\n%s\n' "$id" "$name" done } get_camera_id() { if [[ -n "$PRESENCE_DETECT_CAMERA" ]]; then echo "$PRESENCE_DETECT_CAMERA" elif [[ -f "$PRESENCE_CFG" ]]; then grep -oP 'CAMERA=\K[0-9]+' "$PRESENCE_CFG" 2>/dev/null || echo 0 else echo 0 fi } set_camera_id() { mkdir -p "$(dirname "$PRESENCE_CFG")" printf 'CAMERA=%s\n' "$1" > "$PRESENCE_CFG" } # Grab a single still from a v4l2 node into $2. No sudo: /dev/video* is acl/group # readable by the user. The first frame off a cold sensor is often black, so pull # a few and keep the last. camera_capture_frame() { local dev="$1" out="$2" command -v ffmpeg &>/dev/null || return 2 ffmpeg -hide_banner -loglevel error -f v4l2 -i "$dev" \ -frames:v 5 -update 1 -y "$out" /dev/null [[ -s "$out" ]] } # Render an image inline via kitty's graphics protocol. KITTY_WINDOW_ID is the # reliable signal — $TERM is frequently xterm-256color even under kitty. preview_frame() { local img="$1" [[ -s "$img" ]] || return 1 command -v kitty &>/dev/null || return 1 [[ -n "$KITTY_WINDOW_ID" || "$TERM" == *kitty* ]] || return 1 kitty +kitten icat --align left --clear "$img" } # IR sensors deliver near-zero chroma; colour webcams don't. Returns 0 when the # still looks monochrome (likely IR), 1 otherwise. Assumes colour when it can't # measure — that's the case that needs the extra non-IR tuning anyway. camera_looks_ir() { local img="$1" sat [[ -s "$img" ]] || return 1 command -v ffmpeg &>/dev/null || return 1 sat=$(ffmpeg -hide_banner -loglevel error -i "$img" \ -vf signalstats,metadata=mode=print:file=- -f null - 2>/dev/null \ | awk -F= '/SATAVG/{print $2; exit}') [[ -z "$sat" ]] && return 1 awk -v s="$sat" 'BEGIN{exit !(s < 12)}' } # Shared camera chooser: menu → live still preview in kitty → confirm. The pick # is returned in the global PICKED_CAM (numeric id); returns 1 if cancelled. PICKED_CAM="" PICKED_CAM_FRAME="" pick_camera_with_preview() { local title="$1" current="$2" local -a cam_items mapfile -t cam_items < <(list_cameras) if [[ ${#cam_items[@]} -eq 0 ]]; then msg "No Cameras Found" \ "No video devices found at /dev/video*.\n\nMake sure your webcam is connected." \ 10 55 return 1 fi while true; do local choice choice=$(dialog --backtitle "$BACKTITLE" \ --title " $title " \ --menu "\nCurrent: ${current}\n\nSelect a camera:" \ 16 62 6 \ "${cam_items[@]}" \ 3>&1 1>&2 2>&3) || return 1 local dev="/dev/video${choice}" clear printf "\n\033[1;35m Preview — %s\033[0m\n" "$dev" printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" local frame="$TMP_D/cam_preview.png" camera_capture_frame "$dev" "$frame" case $? in 0) preview_frame "$frame" \ || printf " (kitty preview unavailable; frame saved to %s)\n" "$frame" ;; 2) printf " ffmpeg is not installed — skipping preview.\n" ;; *) printf " Could not capture a frame from %s.\n The device may be busy or not a capture node.\n" "$dev" ;; esac printf "\n" local ans read -rp " Use ${dev}? [Y]es / [n]o, pick another / [c]ancel: " ans case "${ans,,}" in ""|y|yes) PICKED_CAM="$choice"; PICKED_CAM_FRAME="$frame"; return 0 ;; c|cancel) return 1 ;; *) continue ;; esac done } # ── Presence detection ──────────────────────────────────────────────────────── presence_configure_camera() { local current; current=$(get_camera_id) pick_camera_with_preview "Select Camera (Presence)" "/dev/video${current}" || return set_camera_id "$PICKED_CAM" msg "Camera Set" \ "Presence detection will use /dev/video${PICKED_CAM}.\n\nRestart presence-detect.sh for the change to take effect." \ 9 62 } presence_test_camera() { local cam; cam=$(get_camera_id) clear printf "\n\033[1;35m Testing presence detection on /dev/video%s...\033[0m\n" "$cam" printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" printf " Look at the camera and stay still.\n\n" python3 "$PYTHON_DETECT" "$cam" 2>/dev/null local rc=$? case $rc in 0) msg "Test Result" "Face detected!\n\nPresence detection is working correctly." 8 52 ;; 1) msg "Test Result" \ "No face detected.\n\nMake sure you are in front of the camera\nand there is adequate lighting." \ 10 56 ;; 2) msg "Camera Error" \ "Could not open /dev/video${cam}.\n\nTry configuring a different camera first." \ 9 56 ;; *) msg "Error" "Unexpected exit code ($rc) from detection script." 7 52 ;; esac } # ── Howdy helpers ───────────────────────────────────────────────────────────── # Detection must survive howdy 2.x's layout: its CLI lives under root-only # /usr/lib/security/howdy/ with /usr/bin/howdy symlinked into it, so a normal # user can neither resolve nor run the link — `command -v howdy` reports "not # found" even when it is installed. Every op here invokes it via `sudo howdy` # anyway, so fall back to the symlink itself and the package DB. howdy_installed() { command -v howdy &>/dev/null && return 0 [[ -L /usr/bin/howdy || -e /usr/bin/howdy ]] && return 0 command -v pacman &>/dev/null && pacman -Qq howdy &>/dev/null } howdy_require() { howdy_installed && return 0 # howdy lives only in the AUR — it is not in the official repos, so a # plain `pacman -S howdy` always fails. Require an AUR helper. local helper for h in yay paru; do command -v "$h" &>/dev/null && { helper="$h"; break; } done 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 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 camera (device_path) ──────────────────────────────────────────────── # config.ini is root-only, so reads and writes both go through sudo. Returns the # bare value (e.g. /dev/video1, or `none` when unconfigured). howdy_get_device_path() { sudo grep -oP '^\s*device_path\s*=\s*\K.*' "$HOWDY_CONFIG" 2>/dev/null | head -1 } # Generic [video] key writer: replaces the key in place, or inserts it under the # [video] header if absent. Every key we touch (device_path, recording_plugin, # dark_threshold) lives only in that section, so a global replace is safe. howdy_set_cfg() { local key="$1" val="$2" if sudo grep -qE "^\s*${key}\s*=" "$HOWDY_CONFIG"; then sudo sed -i -E "s|^(\s*${key}\s*=).*|\1 ${val}|" "$HOWDY_CONFIG" else sudo sed -i -E "/^\[video\]/a ${key} = ${val}" "$HOWDY_CONFIG" fi } howdy_set_device_path() { howdy_set_cfg device_path "$1"; } # True when howdy points at a device node that actually exists. howdy_camera_ok() { local dp; dp=$(howdy_get_device_path) [[ -n "$dp" && "$dp" != none && -e "$dp" ]] } # howdy's stock tuning assumes a Windows-Hello IR sensor. A regular colour # webcam works too — dlib doesn't care about IR — but needs two tweaks: # • recording_plugin = ffmpeg (opencv mis-decodes many YUYV colour streams to # grey garbage; ffmpeg reads them cleanly — the same path the preview used) # • dark_threshold = 80 (IR's flashing emitter wants a low value; a # colour feed in normal light gets rejected as "too dark" at the default 50) # The camera-type question defaults from the preview's measured colourfulness. howdy_apply_camera_profile() { local frame="$1" dev="$2" local dn="" camera_looks_ir "$frame" && dn="--defaultno" # looks IR ⇒ default the answer to "No" dialog --backtitle "$BACKTITLE" --title " Camera Type " $dn --yesno \ "\nIs this a regular (non-IR / colour) webcam?\n\n Yes — tune howdy for a colour camera\n (ffmpeg recorder, dark_threshold 80).\n Works in normal light; less secure.\n\n No — IR sensor (Windows Hello style).\n Keep howdy's default IR tuning." 16 62 case $? in 0) howdy_set_cfg recording_plugin ffmpeg howdy_set_cfg dark_threshold 80 msg "Howdy Camera Set" \ "howdy will use ${dev}.\n\nTuned for a NON-IR colour webcam:\n recorder = ffmpeg\n dark_threshold = 80\n\nRun 'add face model' next. If frames come out\ngrey or fail, try the other /dev/video node." 14 62 ;; 1) howdy_set_cfg recording_plugin opencv howdy_set_cfg dark_threshold 50 msg "Howdy Camera Set" \ "howdy will use ${dev}.\n\nTuned for an IR sensor (howdy defaults):\n recorder = opencv\n dark_threshold = 50" 12 62 ;; *) msg "Howdy Camera Set" \ "howdy will use ${dev}.\n\nCamera-type tuning left unchanged." 9 56 ;; esac } howdy_configure_camera() { howdy_require || return local current; current=$(howdy_get_device_path) [[ -z "$current" ]] && current="(unset)" pick_camera_with_preview "Select Camera (Howdy)" "$current" || return local dev="/dev/video${PICKED_CAM}" howdy_set_device_path "$dev" howdy_apply_camera_profile "$PICKED_CAM_FRAME" "$dev" } # Howdy aborts in VideoCapture when device_path is unset/wrong, surfacing only a # generic exit code 1. Catch that here and offer to fix it before enrolling. howdy_ensure_camera() { howdy_camera_ok && return 0 local dp; dp=$(howdy_get_device_path) dialog --backtitle "$BACKTITLE" --title " Camera Not Configured " --yesno \ "\nhowdy has no usable camera (device_path = ${dp:-none}).\n\nEnrollment will fail until this is set.\nConfigure the howdy camera now?" 11 60 \ || return 1 howdy_configure_camera howdy_camera_ok || { msg "Still Not Configured" \ "howdy's camera is still unset, aborting enrollment." 7 52 return 1 } } # ── Howdy operations ────────────────────────────────────────────────────────── howdy_add() { howdy_require || return howdy_ensure_camera || return local name name=$(dialog --backtitle "$BACKTITLE" \ --title " Add Face Model " \ --inputbox "\nEnter a label for this face model:" 8 52 "$USER" \ 3>&1 1>&2 2>&3) || return [[ -z "$name" ]] && return clear printf "\n\033[1;35m Enrolling face model '%s'...\033[0m\n" "$name" printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" printf " Look at the camera and hold still.\n\n" # howdy has no label flag; the label is read from stdin during `add`. printf '%s\n' "$name" | sudo howdy -U "$USER" add local rc=${PIPESTATUS[1]} if [[ $rc -eq 0 ]]; then msg "Enrolled" \ "Face model '$name' added.\n\nYou may need to relogin for PAM changes to take effect." \ 9 58 else msg "Failed" "Enrollment failed (code $rc).\n\nCheck camera and lighting." 8 52 fi } howdy_list() { howdy_require || return local out; out=$(sudo howdy -U "$USER" list 2>&1) msg "Enrolled Face Models" "$out" 20 64 } howdy_remove() { howdy_require || return local models; models=$(sudo howdy -U "$USER" list 2>&1) if [[ -z "$models" || "$models" == *"no models"* ]]; then msg "No Models" "No face models are currently enrolled." 7 44 return fi local id id=$(dialog --backtitle "$BACKTITLE" \ --title " Remove Face Model " \ --inputbox "\nCurrent models:\n\n${models}\n\nEnter model ID to remove:" 20 64 \ 3>&1 1>&2 2>&3) || return [[ -z "$id" || ! "$id" =~ ^[0-9]+$ ]] && { msg "Invalid ID" "Please enter a numeric model ID." 6 36; return } dialog --backtitle "$BACKTITLE" --title " Confirm " \ --yesno "\nRemove face model ID $id?" 7 40 || return sudo howdy -U "$USER" remove "$id" msg "Done" "Face model $id removed." 6 38 } howdy_test() { howdy_require || return clear printf "\n\033[1;35m Testing howdy authentication...\033[0m\n" printf "\033[35m ─────────────────────────────────────────\033[0m\n\n" printf " Look at the camera.\n\n" sudo howdy -U "$USER" test local rc=$? if [[ $rc -eq 0 ]]; then msg "Result" "Authentication successful." 6 38 else msg "Result" "Authentication failed (code $rc).\n\nCheck enrolled models and camera setup." 9 52 fi } # ── howdy PAM module (pam_python.so) ────────────────────────────────────────── # howdy's PAM entrypoint is a Python script loaded by pam_python.so. The module # is /usr/lib/security/pam_python.so directly (world-traversable, unlike howdy's # own root-only dir), so a plain existence test is reliable here; pacman -Qq is # the fallback in case it lands elsewhere. pam_python_installed() { [[ -e "$PAM_PYTHON_SO" ]] && return 0 command -v pacman &>/dev/null && pacman -Qq pam-python &>/dev/null } pam_python_require() { pam_python_installed && return 0 # pam-python is AUR-only, so it needs an AUR helper just like howdy. local helper for h in yay paru; do command -v "$h" &>/dev/null && { helper="$h"; break; } done if [[ -z "$helper" ]]; then msg "No AUR Helper" \ "howdy's PAM module needs the AUR package 'pam-python', which requires an AUR helper (yay or paru).\n\nInstall yay first, then re-run." \ 11 62 return 1 fi dialog --backtitle "$BACKTITLE" --title " pam-python Needed " \ --yesno "\nhowdy authenticates via pam_python.so, provided by the AUR package 'pam-python'.\n\nInstall it now via ${helper}?" 10 62 || return 1 clear printf "\nInstalling pam-python via %s...\n\n" "$helper" "$helper" -S --noconfirm --needed pam-python pam_python_installed || { msg "Install Failed" "pam-python installation failed.\nInstall it manually: ${helper} -S pam-python" 8 58 return 1 } } # ── 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_python.so %s\n' "$HOWDY_PAM_PY" 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 pam_python_require || return fido_require || return 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..\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. 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.." 8 56 } # ── Main menu ───────────────────────────────────────────────────────────────── main_menu() { local choice choice=$(dialog --backtitle "$BACKTITLE" \ --title " Biometric Enrollment " \ --menu "\nSelect an option:" 23 70 10 \ "1" "Presence detection — configure camera" \ "2" "Presence detection — test detection" \ "3" "Howdy face auth — configure camera" \ "4" "Howdy face auth — add face model" \ "5" "Howdy face auth — list enrolled models" \ "6" "Howdy face auth — remove face model" \ "7" "Howdy face auth — test authentication" \ "8" "PAM 2FA — set up howdy + FIDO key" \ "9" "PAM 2FA — remove howdy + FIDO key" \ 3>&1 1>&2 2>&3) || { clear; exit 0; } case "$choice" in 1) presence_configure_camera ;; 2) presence_test_camera ;; 3) howdy_configure_camera ;; 4) howdy_add ;; 5) howdy_list ;; 6) howdy_remove ;; 7) howdy_test ;; 8) pam_setup ;; 9) pam_teardown ;; esac main_menu } require_dialog main_menu