#!/usr/bin/env bash # pinentry-rofi: A rofi-based pinentry # # Dual-mode: # - SSH_ASKPASS mode: invoked with a prompt string as $1 (e.g. by # ssh-tpm-agent via SSH_ASKPASS). Prints the PIN on stdout. # - Assuan/pinentry mode: invoked with no args or only "--" options # (e.g. by rbw, gpg-agent). Speaks the Assuan protocol on stdin/stdout. # # Caches PIN in kernel keyring for 5 minutes (sliding window). set -efu CACHE_KEY="pinentry-rofi-pin" CACHE_TIMEOUT=300 # 5 minutes # Hard limit on how long a dialog may sit unanswered. Without it, a prompt # nobody answers (lost workspace, requester already dead) hangs forever, and # ssh-tpm-agent serializes TPM access behind it: every later signature blocks, # so all ssh auth on the machine wedges until the stray rofi is killed by hand. # On expiry `timeout` exits 124, which the callers treat like a cancel, so the # requester gets a failed auth it can retry instead of a deadlock. PROMPT_TIMEOUT="${PINENTRY_ROFI_TIMEOUT:-120}" # ---------- cache helpers ---------- get_cached_pin() { local key_id key_id=$(keyctl search @u user "$CACHE_KEY" 2>/dev/null) || return 1 keyctl pipe "$key_id" 2>/dev/null } cache_pin() { local pin="$1" keyctl purge user "$CACHE_KEY" >/dev/null 2>&1 || true local key_id key_id=$(printf '%s' "$pin" | keyctl padd user "$CACHE_KEY" @u) || return 0 keyctl timeout "$key_id" "$CACHE_TIMEOUT" || true } extend_cache() { local key_id key_id=$(keyctl search @u user "$CACHE_KEY" 2>/dev/null) || return 0 keyctl timeout "$key_id" "$CACHE_TIMEOUT" || true } clear_cache() { keyctl purge user "$CACHE_KEY" >/dev/null 2>&1 || true } # ---------- SSH context (for ssh-tpm-agent) ---------- get_ssh_args() { local pid="${SSH_CLIENT_PID:-}" [[ -z "$pid" ]] && return 1 [[ ! -r "/proc/$pid/cmdline" ]] && return 1 local -a args=() while IFS= read -r -d '' arg; do args+=("$arg") done < "/proc/$pid/cmdline" [[ "${args[0]}" == "ssh" || "${args[0]}" == */ssh ]] || return 1 local i=1 local line="" while [[ $i -lt ${#args[@]} ]]; do local arg="${args[$i]}" if [[ "$arg" == -* ]]; then [[ -n "$line" ]] && echo "$line" line="$arg" else if [[ -n "$line" ]]; then line="$line $arg" else line="$arg" fi fi ((i++)) done [[ -n "$line" ]] && echo "$line" } escape_pango() { sed 's/&/\&/g; s//\>/g' } # ---------- rofi prompt ---------- # # Args: $1 = prompt label (default "PIN") # $2 = optional pango-formatted -mesg text # $3 = use_cache: "cache" enables kernel-keyring caching, anything # else disables it (Assuan callers like rbw/gpg-agent do their # own caching, and sharing one keyring slot across unrelated # secrets is wrong). # Echoes PIN on success, returns non-zero on cancel. rofi_prompt() { local prompt_label="${1:-PIN}" local mesg="${2:-}" local use_cache="${3:-}" local mesg_args=() [[ -n "$mesg" ]] && mesg_args=(-mesg "$mesg") local theme_file="/var/theme/config/rofi-theme" local theme_args=() if [[ -r "$theme_file" ]]; then local theme theme=$(cat "$theme_file") theme_args=(-theme "$theme") fi # Cached PIN menu (only in modes that opted in) if [[ "$use_cache" == "cache" ]]; then local cached_pin="" if cached_pin=$(get_cached_pin 2>/dev/null) && [[ -n "$cached_pin" ]]; then local choice choice=$(printf "OK\nCancel\nClear cache" | timeout -k 5 "$PROMPT_TIMEOUT" rofi -dmenu \ -i \ -no-custom \ -no-fixed-num-lines \ "${mesg_args[@]}" \ "${theme_args[@]}" \ 2>/dev/null) || return 1 case "$choice" in "OK") extend_cache printf '%s' "$cached_pin" return 0 ;; "Cancel") return 1 ;; "Clear cache") clear_cache ;; esac fi fi local result result=$(timeout -k 5 "$PROMPT_TIMEOUT" rofi -dmenu \ -password \ -p "$prompt_label" \ -lines 0 \ -no-fixed-num-lines \ "${mesg_args[@]}" \ "${theme_args[@]}" \ < /dev/null 2>/dev/null) || return 1 [[ -z "$result" ]] && return 1 if [[ "$use_cache" == "cache" ]]; then cache_pin "$result" fi printf '%s' "$result" } # ---------- mode: SSH_ASKPASS ---------- askpass_main() { local ssh_args="" ssh_args=$(get_ssh_args 2>/dev/null | escape_pango) || ssh_args="" local mesg="" if [[ -n "$ssh_args" ]]; then mesg="SSH: $ssh_args" fi local pin pin=$(rofi_prompt "PIN" "$mesg" cache) || exit 1 printf '%s\n' "$pin" } # ---------- mode: Assuan pinentry ---------- # # Spec (abridged): https://www.gnupg.org/documentation/manuals/assuan/ # Server greets with "OK ...", responds OK to most settings, and on # GETPIN replies with "D " then "OK", or "ERR 83886179 cancelled". # Data lines must percent-encode %, CR, LF. assuan_decode() { # decode %HH escapes from SETDESC/SETPROMPT/SETERROR values printf '%b' "$(printf '%s' "$1" | sed 's/%\([0-9A-Fa-f][0-9A-Fa-f]\)/\\x\1/g')" } assuan_encode_data() { # encode %, CR, LF for D-line output printf '%s' "$1" | sed -e 's/%/%25/g' -e 's/\r/%0D/g' } assuan_main() { printf 'OK Pleased to meet you\n' local desc="" prompt="PIN" error="" # title and keyinfo are accepted but unused by the rofi UI local _title="" _keyinfo="" local line cmd arg while IFS= read -r line; do line="${line%$'\r'}" if [[ "$line" == *" "* ]]; then cmd="${line%% *}" arg="${line#* }" else cmd="$line" arg="" fi case "$cmd" in SETDESC) desc="$(assuan_decode "$arg")"; printf 'OK\n' ;; SETPROMPT) prompt="$(assuan_decode "$arg")"; printf 'OK\n' ;; SETERROR) error="$(assuan_decode "$arg")"; printf 'OK\n' ;; SETTITLE) _title="$(assuan_decode "$arg")"; printf 'OK\n' ;; SETKEYINFO) _keyinfo="$arg"; printf 'OK\n' ;; OPTION|SETOK|SETCANCEL|SETNOTOK|SETTIMEOUT|SETQUALITYBAR|SETQUALITYBAR_TT|SETREPEAT|SETREPEATERROR|SETGENPIN|SETGENPIN_TT|CLEARPASSPHRASE|GETINFO) printf 'OK\n' ;; GETPIN) # Build rofi -mesg from desc/error local mesg="" if [[ -n "$error" ]]; then mesg="$(printf '%s' "$error" | escape_pango)" error="" fi if [[ -n "$desc" ]]; then [[ -n "$mesg" ]] && mesg+=" " mesg+="$(printf '%s' "$desc" | escape_pango)" fi local pin # No cache in Assuan mode: each caller (rbw, gpg-agent, ...) # asks for a different secret and does its own caching. if pin=$(rofi_prompt "${prompt:-PIN}" "$mesg" no-cache); then printf 'D %s\n' "$(assuan_encode_data "$pin")" printf 'OK\n' else printf 'ERR 83886179 Operation cancelled\n' fi ;; CONFIRM|MESSAGE) printf 'OK\n' ;; RESET) desc=""; prompt="PIN"; error=""; _title=""; _keyinfo="" printf 'OK\n' ;; NOP) printf 'OK\n' ;; BYE) printf 'OK closing connection\n' exit 0 ;; "") ;; *) printf 'OK\n' ;; esac done } # ---------- dispatch ---------- is_assuan_mode() { # Assuan/pinentry mode: no args, or first arg is a flag. # Real pinentry callers (rbw, gpg-agent) always lead with flags like # --timeout 0 --ttyname /dev/pts/0 --display :0 --no-global-grab # Note we can't require *every* arg to start with '-', because flag # values ("0", "/dev/pts/0", ":0", ...) don't. # # SSH_ASKPASS mode: first arg is a bare prompt string (no leading '-'). [[ $# -eq 0 || "${1:-}" == -* ]] } if is_assuan_mode "$@"; then assuan_main else askpass_main fi