Save SSH passwords in Windows Credential Manager and auto-type at prompt
This commit is contained in:
parent
872fb0e80e
commit
1c243b3f3f
11 changed files with 538 additions and 38 deletions
|
|
@ -3,7 +3,8 @@
|
|||
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
use crate::hosts::{self, SshHost};
|
||||
use crate::creds;
|
||||
use crate::hosts::{self, SshHost, SshHostView};
|
||||
use crate::pty::{list_wsl_distros, PaneId, PtyManager, SpawnSpec};
|
||||
|
||||
const WORKSPACE_FILE: &str = "workspace.json";
|
||||
|
|
@ -92,11 +93,47 @@ pub async fn load_workspace(app: AppHandle) -> Result<Option<String>, String> {
|
|||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_ssh_hosts(app: AppHandle) -> Result<Vec<SshHost>, String> {
|
||||
hosts::load(&app).map_err(|e| e.to_string())
|
||||
pub async fn list_ssh_hosts(app: AppHandle) -> Result<Vec<SshHostView>, String> {
|
||||
let raw = hosts::load(&app).map_err(|e| e.to_string())?;
|
||||
Ok(raw
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
let has_password = creds::has(&h.id);
|
||||
SshHostView { host: h, has_password }
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_ssh_hosts(app: AppHandle, hosts: Vec<SshHost>) -> Result<(), String> {
|
||||
// Sweep orphaned credentials: any host id that existed before this call
|
||||
// but isn't in the new list gets its keyring entry deleted. Saves the
|
||||
// frontend from having to diff and call delete_host_password itself.
|
||||
if let Ok(prior) = crate::hosts::load(&app) {
|
||||
let new_ids: std::collections::HashSet<&str> =
|
||||
hosts.iter().map(|h| h.id.as_str()).collect();
|
||||
for old in &prior {
|
||||
if !new_ids.contains(old.id.as_str()) {
|
||||
if let Err(e) = creds::delete(&old.id) {
|
||||
tracing::warn!("orphan credential cleanup failed for {}: {e}", old.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::hosts::save(&app, &hosts).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_host_password(host_id: String, password: String) -> Result<(), String> {
|
||||
creds::set(&host_id, &password).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_host_password(host_id: String) -> Result<(), String> {
|
||||
creds::delete(&host_id).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn has_host_password(host_id: String) -> Result<bool, String> {
|
||||
Ok(creds::has(&host_id))
|
||||
}
|
||||
|
|
|
|||
46
src-tauri/src/creds.rs
Normal file
46
src-tauri/src/creds.rs
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
//! Saved SSH-host credentials. Backed by Windows Credential Manager via
|
||||
//! `keyring-core` + `windows-native-keyring-store` — passwords are DPAPI-
|
||||
//! encrypted at rest and scoped to the user account. Never written to
|
||||
//! disk in plaintext, never logged, never sent to the frontend.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use keyring_core::{Entry, Error as KeyringError};
|
||||
|
||||
const SERVICE: &str = "tiletopia";
|
||||
|
||||
fn target_for(host_id: &str) -> String {
|
||||
format!("ssh-host:{host_id}")
|
||||
}
|
||||
|
||||
fn entry(host_id: &str) -> Result<Entry> {
|
||||
Entry::new(SERVICE, &target_for(host_id))
|
||||
.with_context(|| format!("create keyring entry for {host_id}"))
|
||||
}
|
||||
|
||||
pub fn set(host_id: &str, password: &str) -> Result<()> {
|
||||
entry(host_id)?
|
||||
.set_password(password)
|
||||
.with_context(|| format!("write credential for {host_id}"))
|
||||
}
|
||||
|
||||
pub fn get(host_id: &str) -> Result<Option<String>> {
|
||||
match entry(host_id)?.get_password() {
|
||||
Ok(p) => Ok(Some(p)),
|
||||
Err(KeyringError::NoEntry) => Ok(None),
|
||||
Err(e) => Err(anyhow::Error::from(e)
|
||||
.context(format!("read credential for {host_id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete(host_id: &str) -> Result<()> {
|
||||
match entry(host_id)?.delete_credential() {
|
||||
Ok(()) => Ok(()),
|
||||
Err(KeyringError::NoEntry) => Ok(()),
|
||||
Err(e) => Err(anyhow::Error::from(e)
|
||||
.context(format!("delete credential for {host_id}"))),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has(host_id: &str) -> bool {
|
||||
matches!(get(host_id), Ok(Some(_)))
|
||||
}
|
||||
|
|
@ -10,6 +10,18 @@ use tauri::{AppHandle, Manager};
|
|||
|
||||
const HOSTS_FILE: &str = "hosts.json";
|
||||
|
||||
/// What `list_ssh_hosts` returns: the saved host plus a flag derived from
|
||||
/// keyring (true iff a password is stored under this host's id). The flag
|
||||
/// is read-only — saving a host doesn't touch the credential store. See
|
||||
/// the dedicated set/delete password commands for that.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct SshHostView {
|
||||
#[serde(flatten)]
|
||||
pub host: SshHost,
|
||||
#[serde(rename = "hasPassword")]
|
||||
pub has_password: bool,
|
||||
}
|
||||
|
||||
/// One saved host. Fields beyond `hostname` are optional; ssh.exe will fall
|
||||
/// back to `~/.ssh/config` and its own defaults for anything we don't pass.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
//! Library entry point. `main.rs` calls `run()`.
|
||||
|
||||
mod commands;
|
||||
mod creds;
|
||||
mod hosts;
|
||||
mod pty;
|
||||
|
||||
|
|
@ -15,6 +16,16 @@ pub fn run() {
|
|||
.with_writer(std::io::stderr)
|
||||
.try_init();
|
||||
|
||||
// keyring-core 1.x requires explicit store registration before any
|
||||
// Entry::new() call. We're Windows-only so the Credential Manager
|
||||
// backend is the only choice. Failure here means SSH passwords won't
|
||||
// be retrievable — log and continue (host configs still work without
|
||||
// saved passwords; users just see the prompt and type it manually).
|
||||
match windows_native_keyring_store::Store::new() {
|
||||
Ok(store) => keyring_core::set_default_store(store),
|
||||
Err(e) => tracing::warn!("keyring store init failed: {e}"),
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_clipboard_manager::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
|
|
@ -29,6 +40,9 @@ pub fn run() {
|
|||
commands::load_workspace,
|
||||
commands::list_ssh_hosts,
|
||||
commands::save_ssh_hosts,
|
||||
commands::set_host_password,
|
||||
commands::delete_host_password,
|
||||
commands::has_host_password,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue