Save SSH passwords in Windows Credential Manager and auto-type at prompt

This commit is contained in:
megaproxy 2026-05-25 20:08:31 +01:00
parent 872fb0e80e
commit 1c243b3f3f
11 changed files with 538 additions and 38 deletions

View file

@ -5,6 +5,8 @@
use std::collections::HashMap;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use anyhow::{anyhow, Context, Result};
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
@ -13,6 +15,8 @@ use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
use serde::{Deserialize, Serialize};
use tauri::{AppHandle, Emitter};
use crate::creds;
pub type PaneId = u64;
/// Discriminated union describing what to spawn into a fresh PTY. Serialized
@ -35,9 +39,19 @@ pub enum SpawnSpec {
jump_host: Option<String>,
#[serde(rename = "extraArgs")]
extra_args: Option<Vec<String>>,
/// SshHost.id (if any) — backend uses this to fetch a saved
/// password from keyring at spawn time. Never sent back to the
/// frontend.
#[serde(rename = "hostId")]
host_id: Option<String>,
},
}
/// Type alias for the shared writer handle. Wrapped in Arc<Mutex<>> so the
/// reader thread can also take it briefly to autotype a saved password at
/// the SSH prompt.
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
/// What we keep alive for each spawned PTY.
///
/// `master` stays in scope to keep the PTY alive; we never write through it
@ -46,7 +60,7 @@ pub enum SpawnSpec {
struct PaneHandle {
#[allow(dead_code)]
master: Box<dyn MasterPty + Send>,
writer: Box<dyn Write + Send>,
writer: SharedWriter,
#[allow(dead_code)]
child: Box<dyn portable_pty::Child + Send + Sync>,
}
@ -84,6 +98,21 @@ impl PtyManager {
})
.context("openpty failed")?;
// Look up any saved password BEFORE building the command (cheap, no
// bytes-on-the-wire involved). If this is an SSH spawn with a host
// id and the user has stored a credential, the reader thread will
// autotype it when ssh prompts.
let saved_password = match &spec {
SpawnSpec::Ssh { host_id: Some(id), .. } => match creds::get(id) {
Ok(p) => p,
Err(e) => {
tracing::warn!("keyring lookup for {id} failed: {e}");
None
}
},
_ => None,
};
let (cmd, spawn_err) = build_command(&spec)?;
let child = pair.slave.spawn_command(cmd).context(spawn_err)?;
@ -93,10 +122,11 @@ impl PtyManager {
.master
.try_clone_reader()
.context("try_clone_reader failed")?;
let writer = pair
let writer_raw = pair
.master
.take_writer()
.context("take_writer failed")?;
let writer: SharedWriter = Arc::new(Mutex::new(writer_raw));
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
@ -104,16 +134,19 @@ impl PtyManager {
id,
PaneHandle {
master: pair.master,
writer,
writer: writer.clone(),
child,
},
);
// Reader thread: pump bytes -> base64 -> emit.
// Reader thread: pump bytes -> base64 -> emit. Also handles the
// password-prompt autotype state machine if `saved_password` is set.
let app_for_reader = app.clone();
let event_name = format!("pane://{id}/data");
let writer_for_reader = writer.clone();
std::thread::spawn(move || {
let mut buf = [0u8; 8192];
let mut pw_state = PasswordState::from(saved_password);
loop {
match reader.read(&mut buf) {
Ok(0) => {
@ -122,6 +155,10 @@ impl PtyManager {
break;
}
Ok(n) => {
// Try to autotype before emitting so we don't wait
// on the renderer; pw_state mutates here.
pw_state.observe(&buf[..n], &writer_for_reader, id);
let chunk_b64 = B64.encode(&buf[..n]);
if let Err(e) =
app_for_reader.emit(&event_name, DataChunk { b64: chunk_b64 })
@ -142,12 +179,16 @@ impl PtyManager {
}
pub fn write(&self, id: PaneId, bytes: &[u8]) -> Result<()> {
let mut panes = self.panes.lock();
let pane = panes
.get_mut(&id)
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
pane.writer.write_all(bytes).context("pty write failed")?;
pane.writer.flush().ok();
let writer = {
let panes = self.panes.lock();
let pane = panes
.get(&id)
.ok_or_else(|| anyhow!("no pane with id {id}"))?;
pane.writer.clone()
};
let mut w = writer.lock();
w.write_all(bytes).context("pty write failed")?;
w.flush().ok();
Ok(())
}
@ -279,6 +320,80 @@ fn build_command(spec: &SpawnSpec) -> Result<(CommandBuilder, &'static str)> {
}
}
// ---- password-prompt autotype ----------------------------------------------
/// How long after spawn we keep watching for a password prompt. If nothing
/// matches in this window, we disarm and never autotype — so a remote shell
/// that prints "password" hours later can't get our credential injected.
const PASSWORD_AUTOTYPE_WINDOW: Duration = Duration::from_secs(30);
/// Sliding window of recent PTY output we scan for the prompt. Keeps the
/// scan bounded; matches don't need much context.
const PROMPT_SCAN_TAIL: usize = 256;
enum PasswordState {
Disabled,
Armed {
password: String,
deadline: Instant,
tail: Vec<u8>,
},
}
impl PasswordState {
fn from(password: Option<String>) -> Self {
match password {
None => Self::Disabled,
Some(p) => Self::Armed {
password: p,
deadline: Instant::now() + PASSWORD_AUTOTYPE_WINDOW,
tail: Vec::with_capacity(PROMPT_SCAN_TAIL * 2),
},
}
}
/// Called for each chunk of PTY output. Mutates state — once we write
/// the password (or time out) the state collapses to Disabled and this
/// becomes a no-op for the rest of the connection.
fn observe(&mut self, chunk: &[u8], writer: &SharedWriter, pane_id: PaneId) {
let (password, tail, deadline) = match self {
PasswordState::Disabled => return,
PasswordState::Armed { password, tail, deadline } => (password, tail, deadline),
};
if Instant::now() > *deadline {
*self = PasswordState::Disabled;
return;
}
tail.extend_from_slice(chunk);
if tail.len() > PROMPT_SCAN_TAIL {
let drop = tail.len() - PROMPT_SCAN_TAIL;
tail.drain(..drop);
}
if !looks_like_password_prompt(tail) {
return;
}
// Match — write the password + Enter, then collapse to Disabled.
let mut w = writer.lock();
if let Err(e) = w.write_all(password.as_bytes()) {
tracing::warn!("pane {pane_id}: password autotype write failed: {e}");
}
let _ = w.write_all(b"\n");
let _ = w.flush();
*self = PasswordState::Disabled;
}
}
fn looks_like_password_prompt(buf: &[u8]) -> bool {
// OpenSSH prompts: `<user>@<host>'s password:`, `Permission denied,
// please try again. password:`, `Enter passphrase for key '...':`.
// Lowercase the recent tail and substring-match — cheap and good enough.
let s = String::from_utf8_lossy(buf).to_ascii_lowercase();
s.contains("password:") || s.contains("passphrase")
}
// ---- distro enumeration -----------------------------------------------------
/// Run a process without flashing a console window on Windows.