This repository has no description
7.8 kB
285 lines
1#!/usr/bin/env bash
2# pinentry-rofi: A rofi-based pinentry
3#
4# Dual-mode:
5# - SSH_ASKPASS mode: invoked with a prompt string as $1 (e.g. by
6# ssh-tpm-agent via SSH_ASKPASS). Prints the PIN on stdout.
7# - Assuan/pinentry mode: invoked with no args or only "--" options
8# (e.g. by rbw, gpg-agent). Speaks the Assuan protocol on stdin/stdout.
9#
10# Caches PIN in kernel keyring for 5 minutes (sliding window).
11set -efu
12
13CACHE_KEY="pinentry-rofi-pin"
14CACHE_TIMEOUT=300 # 5 minutes
15
16# Hard limit on how long a dialog may sit unanswered. Without it, a prompt
17# nobody answers (lost workspace, requester already dead) hangs forever, and
18# ssh-tpm-agent serializes TPM access behind it: every later signature blocks,
19# so all ssh auth on the machine wedges until the stray rofi is killed by hand.
20# On expiry `timeout` exits 124, which the callers treat like a cancel, so the
21# requester gets a failed auth it can retry instead of a deadlock.
22PROMPT_TIMEOUT="${PINENTRY_ROFI_TIMEOUT:-120}"
23
24# ---------- cache helpers ----------
25
26get_cached_pin() {
27 local key_id
28 key_id=$(keyctl search @u user "$CACHE_KEY" 2>/dev/null) || return 1
29 keyctl pipe "$key_id" 2>/dev/null
30}
31
32cache_pin() {
33 local pin="$1"
34 keyctl purge user "$CACHE_KEY" >/dev/null 2>&1 || true
35 local key_id
36 key_id=$(printf '%s' "$pin" | keyctl padd user "$CACHE_KEY" @u) || return 0
37 keyctl timeout "$key_id" "$CACHE_TIMEOUT" || true
38}
39
40extend_cache() {
41 local key_id
42 key_id=$(keyctl search @u user "$CACHE_KEY" 2>/dev/null) || return 0
43 keyctl timeout "$key_id" "$CACHE_TIMEOUT" || true
44}
45
46clear_cache() {
47 keyctl purge user "$CACHE_KEY" >/dev/null 2>&1 || true
48}
49
50# ---------- SSH context (for ssh-tpm-agent) ----------
51
52get_ssh_args() {
53 local pid="${SSH_CLIENT_PID:-}"
54 [[ -z "$pid" ]] && return 1
55 [[ ! -r "/proc/$pid/cmdline" ]] && return 1
56
57 local -a args=()
58 while IFS= read -r -d '' arg; do
59 args+=("$arg")
60 done < "/proc/$pid/cmdline"
61
62 [[ "${args[0]}" == "ssh" || "${args[0]}" == */ssh ]] || return 1
63
64 local i=1
65 local line=""
66 while [[ $i -lt ${#args[@]} ]]; do
67 local arg="${args[$i]}"
68 if [[ "$arg" == -* ]]; then
69 [[ -n "$line" ]] && echo "$line"
70 line="$arg"
71 else
72 if [[ -n "$line" ]]; then
73 line="$line $arg"
74 else
75 line="$arg"
76 fi
77 fi
78 ((i++))
79 done
80 [[ -n "$line" ]] && echo "$line"
81}
82
83escape_pango() {
84 sed 's/&/\&/g; s/</\</g; s/>/\>/g'
85}
86
87# ---------- rofi prompt ----------
88#
89# Args: $1 = prompt label (default "PIN")
90# $2 = optional pango-formatted -mesg text
91# $3 = use_cache: "cache" enables kernel-keyring caching, anything
92# else disables it (Assuan callers like rbw/gpg-agent do their
93# own caching, and sharing one keyring slot across unrelated
94# secrets is wrong).
95# Echoes PIN on success, returns non-zero on cancel.
96rofi_prompt() {
97 local prompt_label="${1:-PIN}"
98 local mesg="${2:-}"
99 local use_cache="${3:-}"
100
101 local mesg_args=()
102 [[ -n "$mesg" ]] && mesg_args=(-mesg "$mesg")
103
104 local theme_file="/var/theme/config/rofi-theme"
105 local theme_args=()
106 if [[ -r "$theme_file" ]]; then
107 local theme
108 theme=$(cat "$theme_file")
109 theme_args=(-theme "$theme")
110 fi
111
112 # Cached PIN menu (only in modes that opted in)
113 if [[ "$use_cache" == "cache" ]]; then
114 local cached_pin=""
115 if cached_pin=$(get_cached_pin 2>/dev/null) && [[ -n "$cached_pin" ]]; then
116 local choice
117 choice=$(printf "OK\nCancel\nClear cache" | timeout -k 5 "$PROMPT_TIMEOUT" rofi -dmenu \
118 -i \
119 -no-custom \
120 -no-fixed-num-lines \
121 "${mesg_args[@]}" \
122 "${theme_args[@]}" \
123 2>/dev/null) || return 1
124
125 case "$choice" in
126 "OK")
127 extend_cache
128 printf '%s' "$cached_pin"
129 return 0
130 ;;
131 "Cancel")
132 return 1
133 ;;
134 "Clear cache")
135 clear_cache
136 ;;
137 esac
138 fi
139 fi
140
141 local result
142 result=$(timeout -k 5 "$PROMPT_TIMEOUT" rofi -dmenu \
143 -password \
144 -p "$prompt_label" \
145 -lines 0 \
146 -no-fixed-num-lines \
147 "${mesg_args[@]}" \
148 "${theme_args[@]}" \
149 < /dev/null 2>/dev/null) || return 1
150
151 [[ -z "$result" ]] && return 1
152
153 if [[ "$use_cache" == "cache" ]]; then
154 cache_pin "$result"
155 fi
156 printf '%s' "$result"
157}
158
159# ---------- mode: SSH_ASKPASS ----------
160
161askpass_main() {
162 local ssh_args=""
163 ssh_args=$(get_ssh_args 2>/dev/null | escape_pango) || ssh_args=""
164
165 local mesg=""
166 if [[ -n "$ssh_args" ]]; then
167 mesg="<b>SSH:</b>
168$ssh_args"
169 fi
170
171 local pin
172 pin=$(rofi_prompt "PIN" "$mesg" cache) || exit 1
173 printf '%s\n' "$pin"
174}
175
176# ---------- mode: Assuan pinentry ----------
177#
178# Spec (abridged): https://www.gnupg.org/documentation/manuals/assuan/
179# Server greets with "OK ...", responds OK to most settings, and on
180# GETPIN replies with "D <pin>" then "OK", or "ERR 83886179 cancelled".
181# Data lines must percent-encode %, CR, LF.
182
183assuan_decode() {
184 # decode %HH escapes from SETDESC/SETPROMPT/SETERROR values
185 printf '%b' "$(printf '%s' "$1" | sed 's/%\([0-9A-Fa-f][0-9A-Fa-f]\)/\\x\1/g')"
186}
187
188assuan_encode_data() {
189 # encode %, CR, LF for D-line output
190 printf '%s' "$1" | sed -e 's/%/%25/g' -e 's/\r/%0D/g'
191}
192
193assuan_main() {
194 printf 'OK Pleased to meet you\n'
195
196 local desc="" prompt="PIN" error=""
197 # title and keyinfo are accepted but unused by the rofi UI
198 local _title="" _keyinfo=""
199
200 local line cmd arg
201 while IFS= read -r line; do
202 line="${line%$'\r'}"
203 if [[ "$line" == *" "* ]]; then
204 cmd="${line%% *}"
205 arg="${line#* }"
206 else
207 cmd="$line"
208 arg=""
209 fi
210
211 case "$cmd" in
212 SETDESC) desc="$(assuan_decode "$arg")"; printf 'OK\n' ;;
213 SETPROMPT) prompt="$(assuan_decode "$arg")"; printf 'OK\n' ;;
214 SETERROR) error="$(assuan_decode "$arg")"; printf 'OK\n' ;;
215 SETTITLE) _title="$(assuan_decode "$arg")"; printf 'OK\n' ;;
216 SETKEYINFO) _keyinfo="$arg"; printf 'OK\n' ;;
217
218 OPTION|SETOK|SETCANCEL|SETNOTOK|SETTIMEOUT|SETQUALITYBAR|SETQUALITYBAR_TT|SETREPEAT|SETREPEATERROR|SETGENPIN|SETGENPIN_TT|CLEARPASSPHRASE|GETINFO)
219 printf 'OK\n' ;;
220
221 GETPIN)
222 # Build rofi -mesg from desc/error
223 local mesg=""
224 if [[ -n "$error" ]]; then
225 mesg="<b>$(printf '%s' "$error" | escape_pango)</b>"
226 error=""
227 fi
228 if [[ -n "$desc" ]]; then
229 [[ -n "$mesg" ]] && mesg+="
230"
231 mesg+="$(printf '%s' "$desc" | escape_pango)"
232 fi
233
234 local pin
235 # No cache in Assuan mode: each caller (rbw, gpg-agent, ...)
236 # asks for a different secret and does its own caching.
237 if pin=$(rofi_prompt "${prompt:-PIN}" "$mesg" no-cache); then
238 printf 'D %s\n' "$(assuan_encode_data "$pin")"
239 printf 'OK\n'
240 else
241 printf 'ERR 83886179 Operation cancelled\n'
242 fi
243 ;;
244
245 CONFIRM|MESSAGE)
246 printf 'OK\n' ;;
247
248 RESET)
249 desc=""; prompt="PIN"; error=""; _title=""; _keyinfo=""
250 printf 'OK\n' ;;
251
252 NOP)
253 printf 'OK\n' ;;
254
255 BYE)
256 printf 'OK closing connection\n'
257 exit 0 ;;
258
259 "")
260 ;;
261
262 *)
263 printf 'OK\n' ;;
264 esac
265 done
266}
267
268# ---------- dispatch ----------
269
270is_assuan_mode() {
271 # Assuan/pinentry mode: no args, or first arg is a flag.
272 # Real pinentry callers (rbw, gpg-agent) always lead with flags like
273 # --timeout 0 --ttyname /dev/pts/0 --display :0 --no-global-grab
274 # Note we can't require *every* arg to start with '-', because flag
275 # values ("0", "/dev/pts/0", ":0", ...) don't.
276 #
277 # SSH_ASKPASS mode: first arg is a bare prompt string (no leading '-').
278 [[ $# -eq 0 || "${1:-}" == -* ]]
279}
280
281if is_assuan_mode "$@"; then
282 assuan_main
283else
284 askpass_main
285fi