amssh: encrypted SSH manager with fzf TUI and wofi drun mode
- AES-256-CBC store with PBKDF2-SHA256 (600k iters), passphrase auth - FIDO2/PAM opt-in second factor via pam_u2f + pamtester - fzf TUI: vim-style nav (j/k/g/G//), a=add, d=delete, q=quit - Full-screen tput add form with Tab/Enter field advance, star-masked password - SSH_ASKPASS_REQUIRE=force for stored password auth (no sshpass) - First-launch dialog to choose passphrase vs FIDO2 - Hyprland keybind: SUPER-ALT-CTRL-S Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>main
parent
c4b9c5bf92
commit
4dbe200293
|
|
@ -291,6 +291,7 @@ bind= $mainMod SHIFT, X, exec, ~/.config/scripts/hyprland-toggle-touchpad.sh
|
||||||
|
|
||||||
bind= $mainMod CTRL, W ,exec, hyprctl hyprsunset gamma +10
|
bind= $mainMod CTRL, W ,exec, hyprctl hyprsunset gamma +10
|
||||||
bind= $mainMod CTRL, S ,exec, hyprctl hyprsunset gamma -10
|
bind= $mainMod CTRL, S ,exec, hyprctl hyprsunset gamma -10
|
||||||
|
bind= $mainMod ALT CTRL, S, exec, [tag +centered-L] kitty -e ~/.config/scripts/amssh
|
||||||
bind= $mainMod CTRL, A ,exec, hyprctl hyprsunset temperature +450
|
bind= $mainMod CTRL, A ,exec, hyprctl hyprsunset temperature +450
|
||||||
bind= $mainMod CTRL, Q ,exec, hyprctl hyprsunset temperature -450
|
bind= $mainMod CTRL, Q ,exec, hyprctl hyprsunset temperature -450
|
||||||
bind= $mainMod CTRL, X ,exec, hyprctl hyprsunset identity
|
bind= $mainMod CTRL, X ,exec, hyprctl hyprsunset identity
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,601 @@
|
||||||
|
#!/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 </dev/tty
|
||||||
|
printf '\n' >&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<BH-1; i++ )); do
|
||||||
|
tput cup $((BY+i)) $BX; printf "${_cy}║${_r}"
|
||||||
|
tput cup $((BY+i)) $((BX+BW-1)); printf "${_cy}║${_r}"
|
||||||
|
done
|
||||||
|
tput cup $((BY+BH-1)) $BX
|
||||||
|
printf "${_b}${_cy}╚$(printf '═%.0s' $(seq 1 $((BW-2))))╝${_r}"
|
||||||
|
|
||||||
|
_title=" Add SSH Server "
|
||||||
|
tput cup $BY $(( BX + (BW - ${#_title}) / 2 ))
|
||||||
|
printf "${_b}${_cy}${_title}${_r}"
|
||||||
|
|
||||||
|
# ── layout constants ──────────────────────────────────────────────────────────
|
||||||
|
# Columns
|
||||||
|
_LC=$((BX+3)); _LF=$((BX+13)) # left label col / field col
|
||||||
|
_RC=$((BX+37)); _RF=$((BX+43)) # right label col / field col
|
||||||
|
_LFW=22; _RFW=18 # field widths
|
||||||
|
|
||||||
|
# Rows
|
||||||
|
_R1=$((BY+3)) # Username | Host
|
||||||
|
_R2=$((BY+4)) # Password | Port
|
||||||
|
_R3=$((BY+6)) # Alias
|
||||||
|
_AFW=48 # alias field width
|
||||||
|
|
||||||
|
# draw one field slot: "▸ ·····"
|
||||||
|
_slot() {
|
||||||
|
local row=$1 col=$2 w=$3
|
||||||
|
tput cup "$row" "$col"
|
||||||
|
printf "${_cy}▸ ${_di}$(printf '·%.0s' $(seq 1 "$w"))${_r}"
|
||||||
|
}
|
||||||
|
|
||||||
|
# draw labels
|
||||||
|
tput cup $_R1 $_LC; printf "${_b}Username${_r}"
|
||||||
|
tput cup $_R2 $_LC; printf "${_b}Password${_r}"
|
||||||
|
tput cup $_R3 $_LC; printf "${_b}Alias${_r}"
|
||||||
|
tput cup $_R1 $_RC; printf "${_b}Host${_r}"
|
||||||
|
tput cup $_R2 $_RC; printf "${_b}Port${_r}"
|
||||||
|
|
||||||
|
# draw field slots
|
||||||
|
_slot $_R1 $_LF $_LFW
|
||||||
|
_slot $_R1 $_RF $_RFW
|
||||||
|
_slot $_R2 $_LF $_LFW
|
||||||
|
_slot $_R2 $_RF 5
|
||||||
|
_slot $_R3 $_LF $_AFW
|
||||||
|
|
||||||
|
# hint line
|
||||||
|
tput cup $((BY+BH-3)) $((BX+3))
|
||||||
|
printf "${_di}Tab/Enter to advance · Ctrl-C to cancel${_r}"
|
||||||
|
|
||||||
|
# ── field reader helpers ──────────────────────────────────────────────────────
|
||||||
|
# Reads chars until Tab or Enter; Backspace edits in place; sets named var.
|
||||||
|
_readfield() {
|
||||||
|
local _v="$1" _r="" _ch
|
||||||
|
while IFS= read -r -s -n1 _ch; do
|
||||||
|
case "$_ch" in
|
||||||
|
$'\t'|$'\n'|'') break ;;
|
||||||
|
$'\x7f'|$'\x08') [[ -n "$_r" ]] && { _r="${_r%?}"; printf '\b \b'; } ;;
|
||||||
|
*) _r+="$_ch"; printf '%s' "$_ch" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf -v "$_v" '%s' "$_r"
|
||||||
|
}
|
||||||
|
_readpw() {
|
||||||
|
local _v="$1" _r="" _ch
|
||||||
|
while IFS= read -r -s -n1 _ch; do
|
||||||
|
case "$_ch" in
|
||||||
|
$'\t'|$'\n'|'') break ;;
|
||||||
|
$'\x7f'|$'\x08') [[ -n "$_r" ]] && { _r="${_r%?}"; printf '\b \b'; } ;;
|
||||||
|
*) _r+="$_ch"; printf '*' ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
printf -v "$_v" '%s' "$_r"
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── read fields ───────────────────────────────────────────────────────────────
|
||||||
|
tput cnorm
|
||||||
|
_user="" _host="" _pw="" _port="" _alias=""
|
||||||
|
|
||||||
|
tput cup $_R1 $((_LF+2)); _readfield _user
|
||||||
|
tput cup $_R1 $((_RF+2)); _readfield _host
|
||||||
|
tput cup $_R2 $((_LF+2)); _readpw _pw
|
||||||
|
tput cup $_R2 $((_RF+2)); printf "22"; tput cup $_R2 $((_RF+2)); _readfield _port
|
||||||
|
tput cup $_R3 $((_LF+2)); _readfield _alias
|
||||||
|
|
||||||
|
[[ -z "$_port" ]] && _port="22"
|
||||||
|
tput civis
|
||||||
|
|
||||||
|
# ── validate ──────────────────────────────────────────────────────────────────
|
||||||
|
_err() {
|
||||||
|
tput cup $((BY+BH-2)) $((BX+3))
|
||||||
|
printf "${_re}${_b}✗ %s${_r}" "$1"
|
||||||
|
sleep 1.5; exit 1
|
||||||
|
}
|
||||||
|
|
||||||
|
[[ -z "$_user" ]] && _err "username is required"
|
||||||
|
[[ -z "$_host" ]] && _err "host is required"
|
||||||
|
[[ -z "$_alias" ]] && _err "alias is required"
|
||||||
|
|
||||||
|
_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)
|
||||||
|
|
||||||
|
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
|
||||||
Loading…
Reference in New Issue