1use 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
18pub const PIN_MIN_CHARS: usize = 4;
21
22pub const PIN_MAX_BYTES: usize = 63;
24
25const MAX_PINS_PER_RUN: usize = 8;
27
28const MARKER_MAX_AGE: Duration = Duration::from_secs(120);
31
32const KEY_DIGEST_BYTES: usize = 16;
34
35pub(crate) const PIN_MARKER_PREFIX: &str = ".askpass_pin.";
38
39pub(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
44const SAFE_PROMPT_OPENSSH: (u32, u32) = (8, 5);
48
49#[derive(Clone, PartialEq, Eq)]
51pub struct SecurityKeyPin {
52 pub key_path: String,
53 pub pin: String,
54}
55
56impl 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#[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#[derive(Debug, Clone, Default, PartialEq, Eq)]
95pub struct SecurityKeyRun {
96 pub pins: Vec<SecurityKeyPin>,
97 pub flow: Option<PinFlow>,
98}
99
100pub static NO_SECURITY_KEY: SecurityKeyRun = SecurityKeyRun {
102 pins: Vec::new(),
103 flow: None,
104};
105
106impl SecurityKeyRun {
107 pub fn carries(&self, key_path: &str) -> bool {
109 self.pins.iter().any(|p| p.key_path == key_path)
110 }
111}
112
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub struct PinPrompt<'a> {
116 pub key_type: &'a str,
117 pub key_path: &'a str,
118}
119
120pub 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
140fn is_security_key_type(key_type: &str) -> bool {
142 key_type.ends_with("-SK") || key_type.ends_with("-SK-CERT")
143}
144
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
147pub enum RefusalReason {
148 WrongPin,
150 NoAnswer,
153 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 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#[derive(Debug, Clone, PartialEq, Eq)]
193pub struct SignFailure {
194 pub key_path: String,
195 pub reason: RefusalReason,
196}
197
198pub 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
219pub fn sign_failures(stderr: &str) -> Vec<SignFailure> {
221 stderr.lines().filter_map(parse_sign_failure).collect()
222}
223
224pub(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
235pub(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
243pub(crate) fn flow_from_env(env: &Env) -> Option<PinFlow> {
245 env.var(FLOW_VAR).and_then(PinFlow::parse)
246}
247
248pub 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
269fn read_fresh(path: &Path) -> Option<String> {
271 let modified = std::fs::metadata(path).ok()?.modified().ok()?;
272 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
292pub 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
310const SCAN_LINE_MAX: usize = 4096;
313
314pub 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
365pub 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
372pub 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
382pub(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
397pub 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
406fn 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
416pub(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
430pub(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
442const HAND_OUT_SPACING: Duration = Duration::from_secs(2);
445
446const EXITED_HOLDER_GRACE: Duration = Duration::from_millis(250);
450
451pub(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
482fn spacing_left(last: &Path) -> Option<Duration> {
485 let modified = std::fs::metadata(last).ok()?.modified().ok()?;
486 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 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
501fn 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 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
522struct 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 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
563pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576pub(crate) enum PinInput {
577 Typed,
578 Erased,
579 Ignored,
580 Submit,
581 Cancel,
582}
583
584pub(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
615struct 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
628pub(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
673pub(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;