Skip to main content

purple_ssh/
security_key.rs

1//! Security key (FIDO2) PINs for ssh runs that have no terminal.
2//!
3//! OpenSSH asks for a key's PIN through askpass. A background run answers it
4//! from a PIN typed in the TUI, carried in environment variables for that
5//! one run. A wrong PIN costs an attempt on the key, so every run that
6//! carried PINs records a refusal. askpass reads that record before it hands
7//! the PIN out again.
8
9use std::io::Write;
10use std::path::Path;
11use std::process::Command;
12use std::time::{Duration, SystemTime};
13
14use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
15
16use crate::runtime::env::{Env, Paths};
17
18/// Shortest PIN CTAP2 accepts. A shorter one can never be right and would
19/// still cost an attempt on the key.
20pub const PIN_MIN_CHARS: usize = 4;
21
22/// Longest PIN CTAP2 accepts, in bytes.
23pub const PIN_MAX_BYTES: usize = 63;
24
25/// PINs one run carries at most. One per key file, and nobody uses more.
26const MAX_PINS_PER_RUN: usize = 8;
27
28/// How long a request or hand-out marker counts. Covers a slow touch on top
29/// of a two-step file browser listing.
30const MARKER_MAX_AGE: Duration = Duration::from_secs(120);
31
32/// Bytes of the key path digest used in a refusal marker name.
33const KEY_DIGEST_BYTES: usize = 16;
34
35/// Filename prefix shared by every PIN marker, so the retry marker sweep
36/// can leave them alone.
37pub(crate) const PIN_MARKER_PREFIX: &str = ".askpass_pin.";
38
39/// Names the flow that may ask the TUI for a missing PIN.
40pub(crate) const FLOW_VAR: &str = "PURPLE_ASKPASS_SK_FLOW";
41const KEY_VAR_PREFIX: &str = "PURPLE_ASKPASS_SK_KEY_";
42const PIN_VAR_PREFIX: &str = "PURPLE_ASKPASS_SK_PIN_";
43
44/// First OpenSSH release that prefixes a keyboard-interactive prompt with
45/// `(user@host) `. Before it a server could send a prompt that reads like a
46/// PIN prompt, so purple hands out no PIN there.
47const SAFE_PROMPT_OPENSSH: (u32, u32) = (8, 5);
48
49/// A PIN typed for one key file.
50#[derive(Clone, PartialEq, Eq)]
51pub struct SecurityKeyPin {
52    pub key_path: String,
53    pub pin: String,
54}
55
56// Hand-written so a stray `{:?}` can never print the PIN.
57impl std::fmt::Debug for SecurityKeyPin {
58    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
59        f.debug_struct("SecurityKeyPin")
60            .field("key_path", &self.key_path)
61            .finish_non_exhaustive()
62    }
63}
64
65/// A flow that can ask for a missing PIN and run again with it.
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum PinFlow {
68    Tunnel,
69    FileBrowser,
70    KeyPush,
71}
72
73impl PinFlow {
74    pub fn as_str(self) -> &'static str {
75        match self {
76            PinFlow::Tunnel => "tunnel",
77            PinFlow::FileBrowser => "file_browser",
78            PinFlow::KeyPush => "key_push",
79        }
80    }
81
82    fn parse(value: &str) -> Option<Self> {
83        match value {
84            "tunnel" => Some(PinFlow::Tunnel),
85            "file_browser" => Some(PinFlow::FileBrowser),
86            "key_push" => Some(PinFlow::KeyPush),
87            _ => None,
88        }
89    }
90}
91
92/// The PINs one ssh run may hand out, plus the flow that asks for a missing
93/// one. Runs without a flow (containers, snippets) only reuse PINs.
94#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct SecurityKeyRun {
96    pub pins: Vec<SecurityKeyPin>,
97    pub flow: Option<PinFlow>,
98}
99
100/// A run that carries no PIN and asks for none.
101pub static NO_SECURITY_KEY: SecurityKeyRun = SecurityKeyRun {
102    pins: Vec::new(),
103    flow: None,
104};
105
106impl SecurityKeyRun {
107    /// True when this run hands out a PIN for `key_path`.
108    pub fn carries(&self, key_path: &str) -> bool {
109        self.pins.iter().any(|p| p.key_path == key_path)
110    }
111}
112
113/// The parts of OpenSSH's `Enter PIN for <type> key <path>: ` prompt.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct PinPrompt<'a> {
116    pub key_type: &'a str,
117    pub key_path: &'a str,
118}
119
120/// Recognize the PIN prompt ssh shows for a FIDO2 key file. Anchored at the
121/// start: a keyboard-interactive prompt begins with `(user@host) ` and the
122/// agent's own prompt names a fingerprint, so neither matches.
123pub fn parse_pin_prompt(prompt: &str) -> Option<PinPrompt<'_>> {
124    let rest = prompt.strip_prefix("Enter PIN for ")?;
125    let rest = rest.trim_end().strip_suffix(':')?;
126    let (key_type, key_path) = rest.split_once(" key ")?;
127    if key_type.contains(char::is_whitespace) || !is_security_key_type(key_type) {
128        return None;
129    }
130    if key_path.is_empty()
131        || key_path.contains('\n')
132        || key_path.starts_with("SHA256:")
133        || key_path.starts_with("MD5:")
134    {
135        return None;
136    }
137    Some(PinPrompt { key_type, key_path })
138}
139
140/// `ED25519-SK`, `ECDSA-SK` and their `-CERT` forms, as `sshkey_type` names them.
141fn is_security_key_type(key_type: &str) -> bool {
142    key_type.ends_with("-SK") || key_type.ends_with("-SK-CERT")
143}
144
145/// Why a security key did not sign.
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum RefusalReason {
148    /// The key turned the PIN down or none was given.
149    WrongPin,
150    /// The key did not answer: it locks after three wrong PINs in a row and
151    /// a touch can time out.
152    NoAnswer,
153    /// No key was plugged in.
154    NotFound,
155    Other,
156}
157
158impl RefusalReason {
159    fn as_str(self) -> &'static str {
160        match self {
161            RefusalReason::WrongPin => "wrong_pin",
162            RefusalReason::NoAnswer => "no_answer",
163            RefusalReason::NotFound => "not_found",
164            RefusalReason::Other => "other",
165        }
166    }
167
168    fn parse(value: &str) -> Self {
169        match value {
170            "wrong_pin" => RefusalReason::WrongPin,
171            "no_answer" => RefusalReason::NoAnswer,
172            "not_found" => RefusalReason::NotFound,
173            _ => RefusalReason::Other,
174        }
175    }
176
177    /// Map the `ssh_err` text ssh appends to a signing failure.
178    fn from_ssh_error(text: &str) -> Self {
179        if text.contains("incorrect passphrase") {
180            RefusalReason::WrongPin
181        } else if text.contains("invalid format") {
182            RefusalReason::NoAnswer
183        } else if text.contains("not found") {
184            RefusalReason::NotFound
185        } else {
186            RefusalReason::Other
187        }
188    }
189}
190
191/// A security key file ssh failed to sign with.
192#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct SignFailure {
194    pub key_path: String,
195    pub reason: RefusalReason,
196}
197
198/// Read one `sign_and_send_pubkey: signing failed for <type> "<path>": <why>`
199/// line. Only security key files count. A key held by an agent is the
200/// agent's business.
201pub fn parse_sign_failure(line: &str) -> Option<SignFailure> {
202    const MARKER: &str = "signing failed for ";
203    let rest = &line[line.find(MARKER)? + MARKER.len()..];
204    let (key_type, rest) = rest.split_once(" \"")?;
205    if !is_security_key_type(key_type) {
206        return None;
207    }
208    let end = rest.rfind('"')?;
209    let (key_path, tail) = (&rest[..end], &rest[end + 1..]);
210    if key_path.is_empty() || tail.starts_with(" from agent") {
211        return None;
212    }
213    Some(SignFailure {
214        key_path: key_path.to_string(),
215        reason: RefusalReason::from_ssh_error(tail),
216    })
217}
218
219/// Every security key signing failure in an ssh run's stderr.
220pub fn sign_failures(stderr: &str) -> Vec<SignFailure> {
221    stderr.lines().filter_map(parse_sign_failure).collect()
222}
223
224/// Hand the run's PINs and flow to ssh. Only the env carries them, never argv.
225pub(crate) fn configure(cmd: &mut Command, run: &SecurityKeyRun) {
226    for (i, pin) in run.pins.iter().take(MAX_PINS_PER_RUN).enumerate() {
227        cmd.env(format!("{KEY_VAR_PREFIX}{i}"), &pin.key_path)
228            .env(format!("{PIN_VAR_PREFIX}{i}"), &pin.pin);
229    }
230    if let Some(flow) = run.flow {
231        cmd.env(FLOW_VAR, flow.as_str());
232    }
233}
234
235/// The PIN this run carries for exactly `key_path`, if any.
236pub(crate) fn pin_from_env<'a>(env: &'a Env, key_path: &str) -> Option<&'a str> {
237    (0..MAX_PINS_PER_RUN)
238        .map_while(|i| env.var(&format!("{KEY_VAR_PREFIX}{i}")).map(|k| (i, k)))
239        .find(|(_, k)| *k == key_path)
240        .and_then(|(i, _)| env.var(&format!("{PIN_VAR_PREFIX}{i}")))
241}
242
243/// The flow this run may ask a missing PIN for.
244pub(crate) fn flow_from_env(env: &Env) -> Option<PinFlow> {
245    env.var(FLOW_VAR).and_then(PinFlow::parse)
246}
247
248/// True when `ssh -V` names an OpenSSH that cannot pass a server prompt off
249/// as a PIN prompt. Unknown versions count as too old.
250pub fn ssh_version_allows_pins(version: &str) -> bool {
251    let Some(rest) = version.split("OpenSSH_").nth(1) else {
252        return false;
253    };
254    let mut parts = rest.split(|c: char| !c.is_ascii_digit());
255    let major = parts.next().and_then(|p| p.parse::<u32>().ok());
256    let minor = parts.next().and_then(|p| p.parse::<u32>().ok());
257    matches!((major, minor), (Some(ma), Some(mi)) if (ma, mi) >= SAFE_PROMPT_OPENSSH)
258}
259
260fn key_digest(key_path: &str) -> String {
261    use sha2::{Digest, Sha256};
262    Sha256::digest(key_path.as_bytes())
263        .iter()
264        .take(KEY_DIGEST_BYTES)
265        .map(|b| format!("{b:02x}"))
266        .collect()
267}
268
269/// Contents of a marker written within `MARKER_MAX_AGE`.
270fn read_fresh(path: &Path) -> Option<String> {
271    let modified = std::fs::metadata(path).ok()?.modified().ok()?;
272    // A clock that stepped back makes a marker look newer than now. It still
273    // counts, since it guards PIN attempts.
274    let age = SystemTime::now()
275        .duration_since(modified)
276        .unwrap_or(Duration::ZERO);
277    if age >= MARKER_MAX_AGE {
278        return None;
279    }
280    std::fs::read_to_string(path).ok()
281}
282
283fn write_marker(path: &Path, body: &str) {
284    if let Err(e) = crate::fs_util::atomic_write(path, body.as_bytes()) {
285        log::warn!(
286            "[config] security key: marker write failed path={}: {e}",
287            path.display()
288        );
289    }
290}
291
292/// Record that the key refused. askpass hands out no PIN for it until a new
293/// one is typed.
294pub fn note_refusal(paths: Option<&Paths>, failure: &SignFailure) {
295    let Some(paths) = paths else {
296        return;
297    };
298    let path = paths.askpass_pin_refused_marker(&key_digest(&failure.key_path));
299    write_marker(
300        &path,
301        &format!("{}\n{}\n", failure.reason.as_str(), failure.key_path),
302    );
303    log::debug!(
304        "[purple] security key: refusal recorded for {} reason={}",
305        failure.key_path,
306        failure.reason.as_str()
307    );
308}
309
310/// Longest stderr line the refusal scan keeps. ssh's sign failure line is
311/// far shorter; the cap only bounds memory on a runaway line.
312const SCAN_LINE_MAX: usize = 4096;
313
314/// Passes ssh's stderr through and records a refusal of a key the run
315/// carried a PIN for the moment its line arrives. A jump host that refused
316/// the PIN is then on record before the target asks for it.
317pub struct RefusalScan<R> {
318    inner: R,
319    paths: Option<Paths>,
320    run: SecurityKeyRun,
321    line: Vec<u8>,
322}
323
324impl<R: std::io::Read> RefusalScan<R> {
325    pub fn new(inner: R, paths: Option<&Paths>, run: &SecurityKeyRun) -> Self {
326        Self {
327            inner,
328            paths: paths.cloned(),
329            run: run.clone(),
330            line: Vec::new(),
331        }
332    }
333
334    fn end_line(&mut self) {
335        let text = String::from_utf8_lossy(&self.line);
336        if let Some(failure) = parse_sign_failure(text.trim_end_matches('\r'))
337            && self.run.carries(&failure.key_path)
338        {
339            note_refusal(self.paths.as_ref(), &failure);
340        }
341        self.line.clear();
342    }
343}
344
345impl<R: std::io::Read> std::io::Read for RefusalScan<R> {
346    fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
347        let n = self.inner.read(buf)?;
348        if self.run.pins.is_empty() {
349            return Ok(n);
350        }
351        for &byte in &buf[..n] {
352            if byte == b'\n' {
353                self.end_line();
354            } else if self.line.len() < SCAN_LINE_MAX {
355                self.line.push(byte);
356            }
357        }
358        if n == 0 && !self.line.is_empty() {
359            self.end_line();
360        }
361        Ok(n)
362    }
363}
364
365/// Why the key refused the PIN purple last handed out for it, if it did.
366pub fn refusal_for(paths: Option<&Paths>, key_path: &str) -> Option<RefusalReason> {
367    let path = paths?.askpass_pin_refused_marker(&key_digest(key_path));
368    let body = std::fs::read_to_string(path).ok()?;
369    Some(RefusalReason::parse(body.lines().next().unwrap_or("")))
370}
371
372/// Forget a refusal, because the user typed a new PIN for the key.
373pub fn clear_refusal(paths: Option<&Paths>, key_path: &str) {
374    let Some(paths) = paths else {
375        return;
376    };
377    if std::fs::remove_file(paths.askpass_pin_refused_marker(&key_digest(key_path))).is_ok() {
378        log::debug!("[purple] security key: refusal cleared for {key_path}");
379    }
380}
381
382/// Tell the TUI this run met a PIN prompt it had no PIN for.
383pub(crate) fn arm_pin_request(paths: Option<&Paths>, flow: PinFlow, alias: &str, key_path: &str) {
384    let Some(paths) = paths else {
385        return;
386    };
387    write_marker(
388        &paths.askpass_pin_request_marker(flow.as_str(), alias),
389        key_path,
390    );
391    log::debug!(
392        "[purple] security key: PIN requested alias={alias} flow={} key={key_path}",
393        flow.as_str()
394    );
395}
396
397/// Take the key path a run of `flow` for `alias` asked a PIN for.
398pub fn take_pin_request(paths: Option<&Paths>, flow: PinFlow, alias: &str) -> Option<String> {
399    let path = paths?.askpass_pin_request_marker(flow.as_str(), alias);
400    let body = read_fresh(&path);
401    let _ = std::fs::remove_file(&path);
402    let key_path = body?.trim_end_matches('\n').to_string();
403    (!key_path.is_empty()).then_some(key_path)
404}
405
406/// `(pid, key path)` pairs from a hand-out marker.
407fn parse_hand_outs(body: &str) -> Vec<(u32, String)> {
408    body.lines()
409        .filter_map(|l| {
410            let (pid, key) = l.split_once('\t')?;
411            Some((pid.parse().ok()?, key.to_string()))
412        })
413        .collect()
414}
415
416/// True when the ssh process `pid` already received the PIN for `key_path`.
417/// One ssh can sign twice with the same key file (the key plus its
418/// certificate). A wrong PIN would then cost two attempts.
419pub(crate) fn handed_out(paths: Option<&Paths>, alias: &str, pid: u32, key_path: &str) -> bool {
420    let Some(paths) = paths else {
421        return false;
422    };
423    read_fresh(&paths.askpass_pin_used_marker(alias)).is_some_and(|b| {
424        parse_hand_outs(&b)
425            .iter()
426            .any(|(p, k)| *p == pid && k == key_path)
427    })
428}
429
430/// Record that the ssh process `pid` received the PIN for `key_path`.
431pub(crate) fn record_hand_out(paths: Option<&Paths>, alias: &str, pid: u32, key_path: &str) {
432    let Some(paths) = paths else {
433        return;
434    };
435    let path = paths.askpass_pin_used_marker(alias);
436    let mut body = read_fresh(&path).unwrap_or_default();
437    body.push_str(&format!("{pid}\t{key_path}\n"));
438    write_marker(&path, &body);
439    log::debug!("[purple] security key: PIN handed out alias={alias} key={key_path}");
440}
441
442/// Least time between two hand-outs of one key's PIN. A wrong PIN fails
443/// before the key asks for a touch, so its refusal is on record well within.
444const HAND_OUT_SPACING: Duration = Duration::from_secs(2);
445
446/// Wait after a hand-out whose ssh already exited. Its signing is done, and
447/// purple reads a refusal line as ssh writes it, so this only covers the
448/// moment between the two.
449const EXITED_HOLDER_GRACE: Duration = Duration::from_millis(250);
450
451/// Let the ssh process `pid` have the PIN for `key_path`, queued behind any
452/// other hand-out of the same key. Waits until the last one is
453/// `HAND_OUT_SPACING` old (less once that ssh is gone). Returns false when
454/// the key refused it meanwhile. Parallel runs on hosts sharing a key then
455/// spend one attempt on a stale PIN, not one each.
456pub(crate) fn claim_hand_out(paths: Option<&Paths>, alias: &str, pid: u32, key_path: &str) -> bool {
457    let Some(paths) = paths else {
458        return true;
459    };
460    let digest = key_digest(key_path);
461    let _lock = KeyLock::acquire(&paths.askpass_pin_lock_marker(&digest));
462    let last = paths.askpass_pin_last_marker(&digest);
463    if let Some(wait) = spacing_left(&last) {
464        log::debug!(
465            "[purple] security key: PIN hand-out waits {}ms key={key_path}",
466            wait.as_millis()
467        );
468        std::thread::sleep(wait);
469    }
470    if let Some(reason) = refusal_for(Some(paths), key_path) {
471        log::debug!(
472            "[purple] security key: PIN refused while waiting, withheld key={key_path} reason={}",
473            reason.as_str()
474        );
475        return false;
476    }
477    write_marker(&last, &pid.to_string());
478    record_hand_out(Some(paths), alias, pid, key_path);
479    true
480}
481
482/// Time left before the hand-out recorded at `last` may be followed. The
483/// marker holds the pid of the ssh that got the PIN.
484fn spacing_left(last: &Path) -> Option<Duration> {
485    let modified = std::fs::metadata(last).ok()?.modified().ok()?;
486    // A clock that stepped back makes the marker look new: wait it out.
487    let age = SystemTime::now()
488        .duration_since(modified)
489        .unwrap_or(Duration::ZERO);
490    let holder_gone = std::fs::read_to_string(last)
491        .ok()
492        .and_then(|body| body.trim().parse::<u32>().ok())
493        .is_some_and(|holder| !process_alive(holder));
494    if holder_gone {
495        // Counted from now: the holder may have exited only just now.
496        return (age < HAND_OUT_SPACING).then_some(EXITED_HOLDER_GRACE);
497    }
498    HAND_OUT_SPACING.checked_sub(age).filter(|d| !d.is_zero())
499}
500
501/// True while process `pid` exists. Unknown counts as alive, so the caller
502/// waits rather than risk a second attempt.
503fn process_alive(pid: u32) -> bool {
504    #[cfg(unix)]
505    {
506        let Ok(pid) = libc::pid_t::try_from(pid) else {
507            return true;
508        };
509        // SAFETY: signal 0 only checks that the process exists.
510        if unsafe { libc::kill(pid, 0) } == 0 {
511            return true;
512        }
513        std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
514    }
515    #[cfg(not(unix))]
516    {
517        let _ = pid;
518        true
519    }
520}
521
522/// Exclusive lock on a file, released when dropped.
523struct KeyLock {
524    _file: Option<std::fs::File>,
525}
526
527impl KeyLock {
528    fn acquire(path: &Path) -> Self {
529        if let Some(parent) = path.parent() {
530            let _ = std::fs::create_dir_all(parent);
531        }
532        let file = match std::fs::OpenOptions::new()
533            .create(true)
534            .write(true)
535            .truncate(false)
536            .open(path)
537        {
538            Ok(file) => file,
539            Err(e) => {
540                log::warn!(
541                    "[config] security key: PIN lock unavailable path={}: {e}",
542                    path.display()
543                );
544                return Self { _file: None };
545            }
546        };
547        #[cfg(unix)]
548        {
549            use std::os::unix::io::AsRawFd;
550            // SAFETY: the descriptor belongs to `file`, which outlives the call.
551            if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 {
552                log::warn!(
553                    "[config] security key: PIN lock failed path={}: {}",
554                    path.display(),
555                    std::io::Error::last_os_error()
556                );
557            }
558        }
559        Self { _file: Some(file) }
560    }
561}
562
563/// Take the key paths whose PIN went out to a run for `alias`.
564pub fn take_handed_out_keys(paths: Option<&Paths>, alias: &str) -> Vec<String> {
565    let Some(paths) = paths else {
566        return Vec::new();
567    };
568    let path = paths.askpass_pin_used_marker(alias);
569    let body = read_fresh(&path).unwrap_or_default();
570    let _ = std::fs::remove_file(&path);
571    parse_hand_outs(&body).into_iter().map(|(_, k)| k).collect()
572}
573
574/// What one key press does to a PIN typed on the terminal.
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576pub(crate) enum PinInput {
577    Typed,
578    Erased,
579    Ignored,
580    Submit,
581    Cancel,
582}
583
584/// Apply one key press to the PIN buffer. Ctrl-C and Ctrl-D cancel like Esc.
585/// Raw mode turns them into plain key presses.
586pub(crate) fn step_pin_input(buf: &mut String, key: &KeyEvent) -> PinInput {
587    if key.kind == KeyEventKind::Release {
588        return PinInput::Ignored;
589    }
590    let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
591    match key.code {
592        KeyCode::Enter => PinInput::Submit,
593        KeyCode::Esc => PinInput::Cancel,
594        KeyCode::Char('c') | KeyCode::Char('d') if ctrl => PinInput::Cancel,
595        KeyCode::Backspace => {
596            if buf.pop().is_some() {
597                PinInput::Erased
598            } else {
599                PinInput::Ignored
600            }
601        }
602        KeyCode::Char(c)
603            if !ctrl
604                && !key.modifiers.contains(KeyModifiers::ALT)
605                && !c.is_control()
606                && buf.len() + c.len_utf8() <= PIN_MAX_BYTES =>
607        {
608            buf.push(c);
609            PinInput::Typed
610        }
611        _ => PinInput::Ignored,
612    }
613}
614
615/// Leaves raw mode however the read ends.
616struct RawModeGuard;
617
618impl Drop for RawModeGuard {
619    fn drop(&mut self) {
620        let _ = crossterm::terminal::disable_raw_mode();
621    }
622}
623
624fn open_tty() -> std::io::Result<std::fs::File> {
625    std::fs::OpenOptions::new().write(true).open("/dev/tty")
626}
627
628/// Ask for the PIN on the terminal, one star per character. Returns `None`
629/// when the user cancels.
630pub(crate) fn read_pin_from_tty(prompt: &str) -> std::io::Result<Option<String>> {
631    let mut tty = open_tty()?;
632    loop {
633        write!(tty, "{prompt}")?;
634        tty.flush()?;
635        let pin = {
636            crossterm::terminal::enable_raw_mode()?;
637            let _raw = RawModeGuard;
638            read_pin_keys(&mut tty)?
639        };
640        match pin {
641            None => return Ok(None),
642            Some(p) if p.chars().count() < PIN_MIN_CHARS => {
643                writeln!(tty, "{}", crate::messages::askpass::pin_too_short())?;
644            }
645            Some(p) => return Ok(Some(p)),
646        }
647    }
648}
649
650fn read_pin_keys(tty: &mut std::fs::File) -> std::io::Result<Option<String>> {
651    let mut buf = String::new();
652    loop {
653        let crossterm::event::Event::Key(key) = crossterm::event::read()? else {
654            continue;
655        };
656        match step_pin_input(&mut buf, &key) {
657            PinInput::Typed => write!(tty, "*")?,
658            PinInput::Erased => write!(tty, "\x08 \x08")?,
659            PinInput::Ignored => {}
660            PinInput::Submit => {
661                write!(tty, "\r\n")?;
662                return Ok(Some(buf));
663            }
664            PinInput::Cancel => {
665                write!(tty, "\r\n")?;
666                return Ok(None);
667            }
668        }
669        tty.flush()?;
670    }
671}
672
673/// Show ssh's notice on the terminal. purple pipes ssh's stderr, so ssh
674/// cannot print it there itself.
675pub(crate) fn show_on_tty(text: &str) {
676    if let Ok(mut tty) = open_tty() {
677        let _ = write!(tty, "{text}\r\n");
678    }
679}
680
681#[cfg(test)]
682#[path = "security_key_tests.rs"]
683mod tests;