#!/usr/bin/env bash # amssh — encrypted SSH login manager # Storage : ~/.amssh (AES-256-CBC · PBKDF2-SHA256 · 600 000 iterations) # Modes : --tui (default, fzf TUI) | --drun (wofi launcher mode) # Auth : master passphrase; FIDO2/PAM layer if pam_u2f + pamtester present set -euo pipefail # ── constants ──────────────────────────────────────────────────────────────── STORE="${AMSSH_STORE:-$HOME/.amssh}" CONF_DIR="${HOME}/.config/amssh" AUTH_CONF="${CONF_DIR}/auth" ITERS=600000 TERM_CMD="${AMSSH_TERM:-kitty}" MODE="tui" VERSION="#amssh:v1" _TMP=$(mktemp -d /tmp/amssh.XXXXXX) trap 'rm -rf "$_TMP"' EXIT # ── usage ──────────────────────────────────────────────────────────────────── _usage() { cat >&2 <<'EOF' amssh — encrypted SSH login manager Usage: amssh [--tui] Interactive fzf TUI (default) amssh --drun Wofi launcher mode TUI keys: Enter Connect to selected host Ctrl-A Add new entry Ctrl-E Edit selected entry Ctrl-D Delete selected entry Esc Quit Store: ~/.amssh (AES-256-CBC, PBKDF2-SHA256) EOF } # ── argument parsing ───────────────────────────────────────────────────────── while [[ $# -gt 0 ]]; do case "$1" in --tui|-t) MODE="tui" ;; --drun|-d) MODE="drun" ;; --help|-h) _usage; exit 0 ;; *) printf '[amssh] Unknown option: %s\n' "$1" >&2; _usage; exit 1 ;; esac shift done # ── crypto ─────────────────────────────────────────────────────────────────── _decrypt() { AMSSH_PASS="$1" openssl enc -aes-256-cbc -pbkdf2 -iter "$ITERS" -d \ -in "$STORE" -pass env:AMSSH_PASS 2>/dev/null } _encrypt_stdin() { AMSSH_PASS="$1" openssl enc -aes-256-cbc -pbkdf2 -iter "$ITERS" \ -out "$STORE" -pass env:AMSSH_PASS } # ── passphrase helpers ──────────────────────────────────────────────────────── _ask_tty() { local p printf '%s' "${1:-Passphrase: }" >&2 IFS= read -rs p &2 printf '%s' "$p" } _ask_pinentry() { local cmd cmd=$(command -v pinentry-qt 2>/dev/null \ || command -v pinentry-gtk-2 2>/dev/null \ || command -v pinentry 2>/dev/null) || return 1 local out out=$(printf 'SETPROMPT amssh master passphrase\nSETDESC Enter master passphrase for the encrypted SSH store\nGETPIN\n' \ | "$cmd" 2>/dev/null) || return 1 printf '%s\n' "$out" | grep -q '^ERR' && return 1 printf '%s\n' "$out" | awk '/^D /{sub(/^D /,""); print; exit}' } # ── FIDO2 / PAM detection ──────────────────────────────────────────────────── _find_pam_u2f() { local f for f in /lib/security/pam_u2f.so \ /usr/lib/security/pam_u2f.so \ /lib/x86_64-linux-gnu/security/pam_u2f.so \ /usr/lib/x86_64-linux-gnu/security/pam_u2f.so \ /usr/lib/aarch64-linux-gnu/security/pam_u2f.so; do [[ -f "$f" ]] && printf '%s' "$f" && return 0 done return 1 } _fido_pam_available() { _find_pam_u2f >/dev/null && command -v pamtester &>/dev/null } _pam_authenticate() { pamtester login "$USER" authenticate 2>/dev/null } # ── master auth (passphrase + optional FIDO2 PAM layer) ───────────────────── _get_passphrase() { # FIDO2/PAM second-factor: opt-in via AMSSH_PAM=1 if [[ "${AMSSH_PAM:-}" == "1" ]] && _fido_pam_available; then printf '[amssh] FIDO2/PAM authentication required\n' >&2 _pam_authenticate || { printf '[amssh] PAM auth failed\n' >&2; exit 1; } printf '[amssh] PAM OK\n' >&2 fi local pass if [[ "$MODE" == "drun" ]]; then pass=$(_ask_pinentry) || pass=$(_ask_tty "amssh passphrase: ") else pass=$(_ask_tty "amssh passphrase: ") fi [[ -z "$pass" ]] && { printf '[amssh] No passphrase provided\n' >&2; exit 1; } printf '%s' "$pass" } # ── store helpers ───────────────────────────────────────────────────────────── # Entry format: alias|user|host|port|identity|description # (port defaults to 22; identity and description may be empty) _init_store() { printf '%s\n' "$VERSION" | _encrypt_stdin "$1" } _verify_pass() { _decrypt "$1" 2>/dev/null | grep -q "^${VERSION}" || return 1 } _load_entries() { _decrypt "$1" | grep -v '^#' | grep -v '^[[:space:]]*$' || true } _save_entries() { # reads entry lines from stdin, prepends version header, encrypts local pass="$1" { printf '%s\n' "$VERSION"; cat; } | grep -v '^[[:space:]]*$' | _encrypt_stdin "$pass" } # ── display formatting ──────────────────────────────────────────────────────── # Entry format: alias|user|host|port|identity|description|password (7 fields) # password is stored in plaintext inside the AES-256 encrypted store. _entry_to_display() { awk -F'|' '{ addr = $2 "@" $3 if ($4 != "" && $4 != "22") addr = addr ":" $4 line = $1 " \342\200\224 " addr if ($7 != "") line = line " [*]" if ($6 != "") line = line " \342\200\224 " $6 print line }' } # ── SSH connector ───────────────────────────────────────────────────────────── _connect() { local entry="$1" local alias user host port identity desc password IFS='|' read -r alias user host port identity desc password <<< "$entry" local args=() [[ -n "$port" && "$port" != "22" ]] && args+=(-p "$port") [[ -n "$identity" ]] && args+=(-i "$identity") args+=("${user}@${host}") printf '[amssh] Connecting: ssh %s\n' "${args[*]}" >&2 if [[ -n "$password" ]]; then # Use SSH_ASKPASS_REQUIRE=force so SSH calls our script for the password # instead of prompting interactively — works with OpenSSH 8.4+ local askpass="$_TMP/askpass" printf '#!/bin/sh\nprintf "%%s" "$AMSSH_CONN_PW"\n' > "$askpass" chmod 700 "$askpass" AMSSH_CONN_PW="$password" SSH_ASKPASS="$askpass" \ SSH_ASKPASS_REQUIRE=force ssh "${args[@]}" else ssh "${args[@]}" fi } # ── prompt for entry (drun / zenity) ───────────────────────────────────────── _prompt_entry_zenity() { local result result=$(zenity --forms \ --title="amssh — Add Entry" \ --text="SSH login details" \ --add-entry="user@host[:port]" \ --add-entry="Alias" \ --add-password="SSH password (optional)" \ --separator='|' 2>/dev/null) || return 1 local target alias password IFS='|' read -r target alias password <<< "$result" local user hostport host port user="${target%%@*}"; hostport="${target#*@}" if [[ "$hostport" == *:* ]]; then host="${hostport%%:*}"; port="${hostport##*:}" else host="$hostport"; port="22" fi [[ -z "$alias" || -z "$user" || -z "$host" ]] && { zenity --error --text="user@host and alias are required" 2>/dev/null || true return 1 } printf '%s|%s|%s|%s||%s|%s' "$alias" "$user" "$host" "$port" "$alias" "$password" } # ── TUI mode (fzf with execute+reload — stays inside fzf on add/delete) ────── _tui_mode() { local pass="$1" local passfile="$_TMP/.pass" local list_sh="$_TMP/list.sh" local add_sh="$_TMP/add.sh" local del_sh="$_TMP/del.sh" local prev_sh="$_TMP/prev.sh" # Secure passphrase file — readable only by owner printf '%s' "$pass" > "$passfile" chmod 600 "$passfile" # Snapshot constants into local vars for script generation local S="$STORE" I="$ITERS" V="$VERSION" # ── list.sh: decrypt + format for fzf display ──────────────────────────── { printf '#!/usr/bin/env bash\n' printf '_P=$(cat %q); _S=%q; _I=%q\n' "$passfile" "$S" "$I" cat << 'LIST' AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" -d \ -in "$_S" -pass env:AMSSH_PASS 2>/dev/null \ | awk -F'|' '!/^#/ && NF>0 { addr=$2"@"$3; if($4!=""&&$4!="22") addr=addr":"$4 line=$1" \342\200\224 "addr if($7!="") line=line" [*]" if($6!="") line=line" \342\200\224 "$6 print line }' 2>/dev/null || true LIST } > "$list_sh"; chmod +x "$list_sh" # ── add.sh: full-screen TUI form (alternate screen, hidden password) ──────── { printf '#!/usr/bin/env bash\n' printf '_P=$(cat %q); _S=%q; _I=%q; _V=%q\n' "$passfile" "$S" "$I" "$V" cat << 'ADD' # ── terminal setup ──────────────────────────────────────────────────────────── _stty=$(stty -g 2>/dev/null) _cleanup() { stty "$_stty" 2>/dev/null; tput rmcup 2>/dev/null; tput cnorm 2>/dev/null; } trap '_cleanup; exit 1' INT TERM trap '_cleanup' EXIT tput smcup 2>/dev/null # switch to alternate screen tput civis # hide cursor while drawing tput clear cols=$(tput cols 2>/dev/null || printf '80') rows=$(tput lines 2>/dev/null || printf '24') BW=66; BH=14 BX=$(( (cols - BW) / 2 )); [[ $BX -lt 0 ]] && BX=0 BY=$(( (rows - BH) / 2 )); [[ $BY -lt 0 ]] && BY=0 # colour / style shortcuts _b=$(tput bold); _r=$(tput sgr0) _cy=$(tput setaf 6); _gr=$(tput setaf 2) _re=$(tput setaf 1); _di=$(tput dim); _ye=$(tput setaf 3) # ── draw box ────────────────────────────────────────────────────────────────── tput cup $BY $BX printf "${_b}${_cy}╔$(printf '═%.0s' $(seq 1 $((BW-2))))╗${_r}" for (( i=1; i/dev/null \ | grep -v '^#' | grep -v '^[[:space:]]*$' || true) if printf '%s\n' "$_ex" | grep -q "^${_alias}|" 2>/dev/null; then _err "alias \"${_alias}\" already exists" fi # ── save ────────────────────────────────────────────────────────────────────── { printf '%s\n' "$_V" printf '%s\n' "$_ex" printf '%s|%s|%s|%s||%s|%s\n' "$_alias" "$_user" "$_host" "$_port" "$_alias" "$_pw" } | grep -v '^[[:space:]]*$' \ | AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" \ -out "$_S" -pass env:AMSSH_PASS tput cup $((BY+BH-2)) $((BX+3)) printf "${_gr}${_b}✓ Added:${_r} %s → %s@%s:%s" "$_alias" "$_user" "$_host" "$_port" sleep 0.8 ADD } > "$add_sh"; chmod +x "$add_sh" # ── del.sh: delete entry by alias ──────────────────────────────────────── { printf '#!/usr/bin/env bash\n' printf '_P=$(cat %q); _S=%q; _I=%q; _V=%q\n' "$passfile" "$S" "$I" "$V" cat << 'DEL' _alias="$1"; [[ -z "$_alias" ]] && exit 0 _ex=$(AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" -d \ -in "$_S" -pass env:AMSSH_PASS 2>/dev/null \ | grep -v '^#' | grep -v '^[[:space:]]*$' || true) { printf '%s\n' "$_V" printf '%s\n' "$_ex" | grep -v "^${_alias}|" } | grep -v '^[[:space:]]*$' \ | AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" \ -out "$_S" -pass env:AMSSH_PASS printf ' Deleted: %s\n' "$_alias" >&2 DEL } > "$del_sh"; chmod +x "$del_sh" # ── prev.sh: preview panel ──────────────────────────────────────────────── { printf '#!/usr/bin/env bash\n' printf '_P=$(cat %q); _S=%q; _I=%q\n' "$passfile" "$S" "$I" cat << 'PREV' _line="$1" _a=$(printf '%s' "$_line" | awk -F' \342\200\224 ' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$1);print $1}') [[ -z "$_a" ]] && exit 0 AMSSH_PASS="$_P" openssl enc -aes-256-cbc -pbkdf2 -iter "$_I" -d \ -in "$_S" -pass env:AMSSH_PASS 2>/dev/null \ | awk -F'|' -v a="$_a" '$1==a { addr=$3; if($4!=""&&$4!="22") addr=addr":"$4 printf " Alias : %s\n User : %s\n Host : %s\n Port : %s\n Identity : %s\n Password : %s\n Note : %s\n", $1,$2,addr,($4?$4:"22"),($5?$5:"(default)"),($7?"stored":"none"),($6?$6:"-") }' 2>/dev/null || true PREV } > "$prev_sh"; chmod +x "$prev_sh" # ── fzf loop: stay open after SSH session ends ──────────────────────────── while true; do local sel sel=$(bash "$list_sh" \ | fzf \ --prompt="SSH › " \ --header=$' \e[1mEnter\e[0m connect \e[1ma\e[0m add \e[1md\e[0m delete \e[1mq\e[0m quit\n \e[2mj/k\e[0m down/up \e[2mg/G\e[0m top/bottom \e[2m/\e[0m filter' \ --bind="a:execute(bash '$add_sh')+reload(bash '$list_sh')" \ --bind="d:execute(bash '$del_sh' {1})+reload(bash '$list_sh')" \ --preview="bash '$prev_sh' {}" \ --preview-window="right:50%:wrap" \ --no-sort --ansi \ --bind="j:down,k:up,g:first,G:last,/:toggle-search,q:abort" \ 2>/dev/null || true) [[ -z "$sel" ]] && break local alias alias=$(printf '%s' "$sel" \ | awk -F' \342\200\224 ' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",$1); print $1}') [[ -z "$alias" ]] && break local entry entry=$(AMSSH_PASS="$pass" openssl enc -aes-256-cbc -pbkdf2 -iter "$ITERS" -d \ -in "$STORE" -pass env:AMSSH_PASS 2>/dev/null \ | awk -F'|' -v a="$alias" '$1==a' || true) [[ -z "$entry" ]] && { printf '[amssh] Entry not found: %s\n' "$alias" >&2; continue; } _connect "$entry" # fzf reopens after SSH session ends done } # ── drun mode (wofi) ────────────────────────────────────────────────────────── _drun_mode() { local pass="$1" local raw_entries display_list raw_entries=$(_load_entries "$pass") if [[ -z "$raw_entries" ]]; then display_list="(no entries — add via amssh --tui)" else display_list=$(printf '%s\n' "$raw_entries" | _entry_to_display) fi local selected selected=$(printf '%s\n' "$display_list" \ | wofi --show=dmenu --prompt="SSH: " --dmenu \ 2>/dev/null) || exit 0 [[ -z "$selected" || "$selected" == "(no entries"* ]] && exit 0 local alias alias=$(printf '%s' "$selected" | awk -F' — ' '{gsub(/^[[:space:]]+|[[:space:]]+$/,"",\$1); print $1}') local entry entry=$(printf '%s\n' "$raw_entries" | awk -F'|' -v a="$alias" '$1==a') [[ -z "$entry" ]] && exit 1 local e_alias e_user e_host e_port e_identity e_desc IFS='|' read -r e_alias e_user e_host e_port e_identity e_desc <<< "$entry" local args=() [[ -n "$e_port" && "$e_port" != "22" ]] && args+=(-p "$e_port") [[ -n "$e_identity" ]] && args+=(-i "$e_identity") args+=("${e_user}@${e_host}") # Launch SSH in a terminal window "$TERM_CMD" -e ssh "${args[@]}" } # ── FIDO2 info on startup (TUI only) ───────────────────────────────────────── _print_fido_status() { [[ "$MODE" != "tui" ]] && return if _fido_pam_available; then if [[ "${AMSSH_PAM:-}" == "1" ]]; then printf '[amssh] FIDO2/PAM: active (second factor enabled)\n' >&2 else printf '[amssh] FIDO2/PAM: available — set AMSSH_PAM=1 to require as second factor\n' >&2 fi fi } # ── auth config (persisted choice from first-launch dialog) ────────────────── _load_auth_config() { [[ ! -f "$AUTH_CONF" ]] && return local method method=$(cat "$AUTH_CONF") if [[ "$method" == "fido" ]] && _fido_pam_available; then export AMSSH_PAM=1 fi } _first_launch_dialog() { mkdir -p "$CONF_DIR" # FIDO2 unavailable — silently default to passphrase if ! _fido_pam_available; then printf 'passphrase' > "$AUTH_CONF" return fi local choice if [[ "$MODE" == "drun" ]]; then choice=$(zenity --list \ --radiolist \ --title="amssh — Authentication Setup" \ --text="Choose how to unlock your SSH store:" \ --column="" --column="Method" --column="Description" \ TRUE "passphrase" "Master passphrase (recommended)" \ FALSE "fido" "FIDO2 hardware key (second factor)" \ --height=210 --width=500 2>/dev/null) || choice="passphrase" else # whiptail writes selection to stderr; swap fds so $() captures it choice=$(whiptail \ --title "amssh — Authentication Setup" \ --menu "Choose how to unlock your SSH store:" \ 13 62 2 \ "passphrase" "Master passphrase" \ "fido" "FIDO2 hardware key (second factor)" \ 3>&1 1>&2 2>&3) || choice="passphrase" fi [[ -z "$choice" ]] && choice="passphrase" printf '%s' "$choice" > "$AUTH_CONF" if [[ "$choice" == "fido" ]]; then printf '\n[amssh] FIDO2 selected — your key will be required on each unlock.\n' >&2 printf '[amssh] Ensure pam_u2f is configured for your user (pamu2fcfg).\n' >&2 fi } # ── main ───────────────────────────────────────────────────────────────────── main() { _load_auth_config if [[ ! -f "$STORE" ]]; then _first_launch_dialog _load_auth_config # apply choice made in dialog fi _print_fido_status local pass pass=$(_get_passphrase) if [[ ! -f "$STORE" ]]; then printf '[amssh] Creating new store at %s\n' "$STORE" >&2 local confirm if [[ "$MODE" == "drun" ]]; then confirm=$(_ask_pinentry) || { printf '[amssh] Cancelled\n' >&2; exit 1; } else confirm=$(_ask_tty "Confirm passphrase: ") fi [[ "$pass" != "$confirm" ]] && { printf '[amssh] Passphrases do not match\n' >&2; exit 1; } _init_store "$pass" printf '[amssh] Store initialised\n' >&2 [[ "$MODE" == "drun" ]] && exit 0 else _verify_pass "$pass" || { printf '[amssh] Wrong passphrase or corrupted store\n' >&2; exit 1; } fi case "$MODE" in tui) _tui_mode "$pass" ;; drun) _drun_mode "$pass" ;; esac } main