Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

102 changes: 102 additions & 0 deletions src-tauri/src/askpass/client.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! Askpass client mode.
//!
//! When the SSH tunnel code launches `ssh` with `SSH_ASKPASS` pointing at the
//! Tabularis executable, ssh re-runs this binary with the prompt as its only
//! argument. This module detects that situation (via the endpoint env var the
//! server injected), forwards the prompt to the main Tabularis process over a
//! local socket, prints the user's answer to stdout for ssh, and exits without
//! ever booting the full application.

use std::io::{BufRead, BufReader, Read, Write};

use super::protocol::{decode_response, encode_request, PromptKind};

/// Env var carrying the server endpoint: a unix socket path on unix, a
/// `127.0.0.1:<port>` address on Windows.
pub const SOCKET_ENV: &str = "TABULARIS_ASKPASS_SOCKET";
/// Windows only: shared secret proving the client was spawned by Tabularis.
#[cfg(windows)]
pub const TOKEN_ENV: &str = "TABULARIS_ASKPASS_TOKEN";

/// If we were invoked as ssh's askpass helper, run the client and exit the
/// process with the appropriate status. Returns without side effects when the
/// endpoint env var is absent (normal application startup).
pub fn maybe_run_askpass_client() {
let Ok(endpoint) = std::env::var(SOCKET_ENV) else {
return;
};
let prompt = std::env::args().nth(1).unwrap_or_default();
let kind = PromptKind::from_ssh_env(std::env::var("SSH_ASKPASS_PROMPT").ok().as_deref());
std::process::exit(run(&endpoint, kind, &prompt));
}

/// Exit code contract with ssh: 0 with the secret on stdout means success;
/// any non-zero status means the user cancelled (for `confirm` prompts ssh
/// only looks at the status).
fn run(endpoint: &str, kind: PromptKind, prompt: &str) -> i32 {
let stream = match connect(endpoint) {
Ok(s) => s,
Err(e) => {
eprintln!("[Askpass] Failed to reach Tabularis: {}", e);
return 1;
}
};
match exchange(stream, kind, prompt) {
Ok(Some(secret)) => {
println!("{}", secret);
0
}
Ok(None) => 1,
Err(e) => {
eprintln!("[Askpass] {}", e);
1
}
}
}

/// Send the request line and wait for the response.
///
/// For [`PromptKind::Notify`] the server never answers: the notification stays
/// on screen until ssh kills this process (user touched the key), at which
/// point the dropped connection tells the server to dismiss it. Blocking on a
/// read that only ever yields EOF gives exactly that behaviour.
fn exchange<S: Read + Write>(
mut stream: S,
kind: PromptKind,
prompt: &str,
) -> Result<Option<String>, String> {
let request = format!("{}\n", encode_request(kind, prompt));
stream
.write_all(request.as_bytes())
.and_then(|_| stream.flush())
.map_err(|e| format!("Failed to send prompt: {}", e))?;

let mut line = String::new();
BufReader::new(stream)
.read_line(&mut line)
.map_err(|e| format!("Failed to read response: {}", e))?;
if line.is_empty() {
// EOF without a response: server gone or notification dismissed.
return Ok(None);
}
decode_response(line.trim_end_matches(['\n', '\r']))
.ok_or_else(|| "Malformed response from Tabularis".to_string())
}

#[cfg(unix)]
fn connect(endpoint: &str) -> Result<std::os::unix::net::UnixStream, String> {
std::os::unix::net::UnixStream::connect(endpoint)
.map_err(|e| format!("connect({}): {}", endpoint, e))
}

#[cfg(windows)]
fn connect(endpoint: &str) -> Result<std::net::TcpStream, String> {
let mut stream = std::net::TcpStream::connect(endpoint)
.map_err(|e| format!("connect({}): {}", endpoint, e))?;
// Authenticate first: anyone on the machine can reach a loopback port.
let token = std::env::var(TOKEN_ENV).map_err(|_| "Missing askpass token".to_string())?;
stream
.write_all(format!("AUTH {}\n", token).as_bytes())
.map_err(|e| format!("Failed to send auth token: {}", e))?;
Ok(stream)
}
137 changes: 137 additions & 0 deletions src-tauri/src/askpass/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
//! In-app SSH askpass support.
//!
//! When a connection opts into SSH passphrase/PIN prompts (e.g. FIDO2
//! security keys), the system `ssh` process needs an `SSH_ASKPASS` helper to
//! collect the secret. Instead of depending on a desktop-specific helper
//! being installed (`ksshaskpass`, `seahorse`, ...), Tabularis acts as its
//! own: ssh re-executes this binary in a thin client mode that forwards the
//! prompt to the running app over a private local socket, and the app shows
//! a native modal.
//!
//! Module layout:
//! - `protocol`: pure encode/decode helpers for the wire format
//! - `client`: the helper process ssh spawns (`SSH_ASKPASS` side)
//! - `server`: socket listener living inside the main process

mod client;
mod protocol;
mod server;

#[cfg(test)]
mod tests;

pub use client::maybe_run_askpass_client;
pub use protocol::PromptKind;
pub use server::{AskpassServer, AskpassUi};

use std::collections::HashMap;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{sync_channel, SyncSender};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;

use serde::Serialize;
use tauri::{AppHandle, Emitter};

/// Event emitted to the frontend when ssh needs user input.
pub const REQUEST_EVENT: &str = "ssh-askpass://request";
/// Event emitted to the frontend when a prompt is no longer relevant
/// (notification dismissed, request timed out).
pub const DISMISS_EVENT: &str = "ssh-askpass://dismiss";

/// How long the user gets to answer a prompt before ssh receives a cancel.
const RESPONSE_TIMEOUT_SECS: u64 = 300;

/// Global handle for code paths (the SSH tunnel module) that run without a
/// Tauri context. Set once during application setup.
static APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();

static NEXT_REQUEST_ID: AtomicU64 = AtomicU64::new(1);

fn pending_responses() -> &'static Mutex<HashMap<u64, SyncSender<Option<String>>>> {
static PENDING: OnceLock<Mutex<HashMap<u64, SyncSender<Option<String>>>>> = OnceLock::new();
PENDING.get_or_init(|| Mutex::new(HashMap::new()))
}

/// Store the app handle so SSH tunnel code can reach the frontend.
pub fn set_app_handle(app: AppHandle) {
let _ = APP_HANDLE.set(app);
}

/// Start an askpass server bridged to the frontend. Fails when the app is not
/// fully initialised (e.g. in unit tests), letting callers fall back to the
/// system askpass behaviour.
pub fn start_frontend_server() -> Result<AskpassServer, String> {
let app = APP_HANDLE
.get()
.ok_or_else(|| "Askpass UI unavailable: application not initialised".to_string())?;
AskpassServer::start(Arc::new(FrontendUi { app: app.clone() }))
}

#[derive(Serialize, Clone)]
struct AskpassRequestPayload {
id: u64,
kind: &'static str,
prompt: String,
}

/// Bridges askpass exchanges to the webview via Tauri events.
struct FrontendUi {
app: AppHandle,
}

impl AskpassUi for FrontendUi {
fn request(&self, kind: PromptKind, prompt: &str) -> Option<String> {
let id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
let (tx, rx) = sync_channel(1);
pending_responses().lock().unwrap().insert(id, tx);

let payload = AskpassRequestPayload {
id,
kind: kind.as_str(),
prompt: prompt.to_string(),
};
if let Err(e) = self.app.emit(REQUEST_EVENT, payload) {
eprintln!("[Askpass] Failed to notify frontend: {}", e);
pending_responses().lock().unwrap().remove(&id);
return None;
}

let response = rx
.recv_timeout(Duration::from_secs(RESPONSE_TIMEOUT_SECS))
.ok()
.flatten();
// Entry is still present when the wait timed out (the command removes
// it on a real answer); clean up and close the stale modal.
if pending_responses().lock().unwrap().remove(&id).is_some() {
let _ = self.app.emit(DISMISS_EVENT, id);
}
response
}

fn show_notification(&self, prompt: &str) -> u64 {
let id = NEXT_REQUEST_ID.fetch_add(1, Ordering::Relaxed);
let payload = AskpassRequestPayload {
id,
kind: PromptKind::Notify.as_str(),
prompt: prompt.to_string(),
};
if let Err(e) = self.app.emit(REQUEST_EVENT, payload) {
eprintln!("[Askpass] Failed to notify frontend: {}", e);
}
id
}

fn dismiss_notification(&self, id: u64) {
let _ = self.app.emit(DISMISS_EVENT, id);
}
}

/// Frontend answer to an askpass prompt. `response = None` means the user
/// cancelled.
#[tauri::command]
pub fn respond_ssh_askpass(id: u64, response: Option<String>) {
if let Some(tx) = pending_responses().lock().unwrap().remove(&id) {
let _ = tx.send(response);
}
}
115 changes: 115 additions & 0 deletions src-tauri/src/askpass/protocol.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
//! Line-based wire protocol between the askpass client (this same binary,
//! re-executed by ssh as its `SSH_ASKPASS` helper) and the askpass server
//! running inside the main Tabularis process.
//!
//! Every message is a single line. Prompt and response text is escaped so a
//! message can never contain a raw newline.

/// The kind of interaction ssh is asking for, derived from the
/// `SSH_ASKPASS_PROMPT` environment variable (OpenSSH 8.4+).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PromptKind {
/// Regular secret prompt (key passphrase, security-key PIN).
Secret,
/// Yes/no confirmation; ssh only inspects the helper's exit status.
Confirm,
/// Notification only (e.g. "Confirm user presence for key ..."); ssh
/// terminates the helper once the condition is satisfied.
Notify,
}

impl PromptKind {
pub fn as_str(&self) -> &'static str {
match self {
PromptKind::Secret => "secret",
PromptKind::Confirm => "confirm",
PromptKind::Notify => "notify",
}
}

pub fn parse(s: &str) -> Option<Self> {
match s {
"secret" => Some(PromptKind::Secret),
"confirm" => Some(PromptKind::Confirm),
"notify" => Some(PromptKind::Notify),
_ => None,
}
}

/// Map the value of ssh's `SSH_ASKPASS_PROMPT` env var to a kind.
/// Unset or unknown values mean a regular secret prompt.
pub fn from_ssh_env(value: Option<&str>) -> Self {
match value {
Some("confirm") => PromptKind::Confirm,
Some("none") => PromptKind::Notify,
_ => PromptKind::Secret,
}
}
}

/// Escape backslashes and line breaks so the text fits on a single line.
pub fn escape(s: &str) -> String {
s.replace('\\', "\\\\")
.replace('\n', "\\n")
.replace('\r', "\\r")
}

/// Reverse [`escape`].
pub fn unescape(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('\\') => out.push('\\'),
Some(other) => {
// Unknown escape: keep it verbatim.
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}

/// Client → server: `ASK <kind> <escaped prompt>`.
pub fn encode_request(kind: PromptKind, prompt: &str) -> String {
format!("ASK {} {}", kind.as_str(), escape(prompt))
}

pub fn decode_request(line: &str) -> Option<(PromptKind, String)> {
let rest = line.strip_prefix("ASK ")?;
let (kind, prompt) = match rest.split_once(' ') {
Some((kind, prompt)) => (kind, prompt),
None => (rest, ""),
};
Some((PromptKind::parse(kind)?, unescape(prompt)))
}

/// Server → client: `OK <escaped secret>` when the user answered,
/// `CANCEL` when the prompt was dismissed or timed out.
pub fn encode_response(response: Option<&str>) -> String {
match response {
Some(secret) => format!("OK {}", escape(secret)),
None => "CANCEL".to_string(),
}
}

/// Returns `None` for malformed lines, `Some(None)` for a cancellation and
/// `Some(Some(secret))` for an answered prompt.
pub fn decode_response(line: &str) -> Option<Option<String>> {
if line == "CANCEL" {
return Some(None);
}
if line == "OK" {
return Some(Some(String::new()));
}
let secret = line.strip_prefix("OK ")?;
Some(Some(unescape(secret)))
}
Loading