toride_ssh_agent/askpass.rs
1//! `SSH_ASKPASS` handler for passphrase prompts.
2//!
3//! When SSH tools (like `ssh-add`) need a passphrase, they check the
4//! `SSH_ASKPASS` environment variable for a program to run. This module
5//! provides [`AskpassHandler`], which creates a temporary script that outputs
6//! a stored passphrase, enabling non-interactive key loading from a TUI or
7//! other automated context.
8//!
9//! # How it works
10//!
11//! 1. Call [`AskpassHandler::new`] with the passphrase string.
12//! 2. A temporary executable script is written to disk that echoes the
13//! passphrase to stdout.
14//! 3. Call [`AskpassHandler::apply_to_command`] to inject the `SSH_ASKPASS`,
15//! `SSH_ASKPASS_REQUIRE`, and `DISPLAY` environment variables into a
16//! [`duct::Expression`] command.
17//! 4. Drop the handler (or call [`AskpassHandler::cleanup`] explicitly) to
18//! remove the temporary script from disk.
19//!
20//! # Security considerations
21//!
22//! - The temporary script file is created with `0o700` permissions on Unix so
23//! only the current user can read it.
24//! - The file is created in a system temp directory (`std::env::temp_dir()`);
25//! the script's final executable mode (`0o700` on Unix) is set atomically at
26//! creation time and the bytes are flushed to disk before the file is handed
27//! out, which avoids the `ETXTBSY` ("Text file busy") race that would
28//! otherwise occur if a caller execs the script while a deferred write is
29//! still in flight.
30//! - Callers should drop the handler as soon as the passphrase is no longer
31//! needed to minimize the window during which the script exists on disk.
32//! - The passphrase is embedded in the script content; anyone who can read the
33//! file can recover it.
34//! - The owned copy of the passphrase used to build the script is overwritten
35//! with zeros (via `zeroize`) once the script has been written and flushed,
36//! so it is not left resident in memory beyond the construction call.
37//! - On Windows the script is created with `OpenOptions::create_new(true)`
38//! (`CREATE_NEW`) so a name collision fails loudly rather than silently
39//! overwriting another handler's file; per-file ACL hardening relies on the
40//! per-user `%TEMP%` directory ACL.
41//! - The passphrase never appears in process `argv`, parent stdout/stderr, or
42//! `CommandFailed` error strings — only the script's filesystem path is
43//! included in errors.
44
45use std::path::PathBuf;
46
47use toride_ssh_core::{Error, Result};
48use zeroize::Zeroize;
49
50/// A temporary `SSH_ASKPASS` script that outputs a stored passphrase.
51///
52/// Create one of these before running `ssh-add` (or any SSH tool that may
53/// prompt for a passphrase) and use [`apply_to_command`](Self::apply_to_command)
54/// to inject the necessary environment variables.
55pub struct AskpassHandler {
56 /// Path to the temporary script file.
57 script_path: PathBuf,
58}
59
60impl AskpassHandler {
61 /// Create a new askpass handler that will output the given `passphrase`.
62 ///
63 /// Writes a temporary executable script to disk. The script prints the
64 /// passphrase to stdout and exits.
65 ///
66 /// # Errors
67 ///
68 /// Returns an error if the temporary script cannot be written or made
69 /// executable.
70 pub fn new(passphrase: &str) -> Result<Self> {
71 // Production callers always write to the shared system temp dir.
72 let script_path = Self::create_script_in(passphrase, &std::env::temp_dir())?;
73 Ok(Self { script_path })
74 }
75
76 /// Create a new askpass handler writing its script into `dir`.
77 ///
78 /// This is primarily a testing seam: passing a dedicated
79 /// [`tempfile::TempDir`] path isolates each test's script from the shared
80 /// system temp directory, eliminating cross-test interference (and the
81 /// resulting `ETXTBSY` / "Text file busy" flakes) under parallel test
82 /// load. The handler still owns cleanup of the script file on drop.
83 #[cfg(test)]
84 fn new_in_dir(passphrase: &str, dir: &std::path::Path) -> Result<Self> {
85 let script_path = Self::create_script_in(passphrase, dir)?;
86 Ok(Self { script_path })
87 }
88
89 /// Return the path to the temporary askpass script.
90 #[must_use]
91 pub fn script_path(&self) -> &std::path::Path {
92 &self.script_path
93 }
94
95 /// Inject the `SSH_ASKPASS`, `SSH_ASKPASS_REQUIRE`, and `DISPLAY`
96 /// environment variables into a [`duct::Expression`] command.
97 ///
98 /// This configures the command so that any SSH tool invocation that needs
99 /// a passphrase will call our temporary script instead of trying to read
100 /// from the terminal.
101 ///
102 /// `DISPLAY` is set to `":0"` as a dummy value because some SSH
103 /// implementations require it to be set for `SSH_ASKPASS` to be used.
104 /// `SSH_ASKPASS_REQUIRE=force` overrides the check for whether a terminal
105 /// is available, ensuring the askpass program is always used.
106 #[allow(clippy::needless_pass_by_value)]
107 pub fn apply_to_command(&self, cmd: duct::Expression) -> duct::Expression {
108 cmd.env("SSH_ASKPASS", &self.script_path)
109 .env("SSH_ASKPASS_REQUIRE", "force")
110 .env("DISPLAY", ":0")
111 }
112
113 /// Remove the temporary script file from disk.
114 ///
115 /// This is called automatically on drop, but you may call it explicitly
116 /// if you want to clean up earlier. Errors are logged but not propagated
117 /// since cleanup is best-effort.
118 pub fn cleanup(&self) {
119 if let Err(e) = std::fs::remove_file(&self.script_path) {
120 tracing::warn!(
121 "failed to remove askpass script {}: {}",
122 self.script_path.display(),
123 e
124 );
125 }
126 }
127
128 /// Create the temporary askpass script on disk in `dir`.
129 ///
130 /// `dir` lets tests pass a dedicated [`tempfile::TempDir`] to avoid
131 /// cross-test interference in the shared system temp directory.
132 fn create_script_in(passphrase: &str, dir: &std::path::Path) -> Result<PathBuf> {
133 use std::io::Write;
134 #[cfg(unix)]
135 use std::os::unix::fs::OpenOptionsExt;
136
137 // Copy the passphrase into an owned, zeroizable buffer. The script
138 // content is derived from this copy; once the file has been written
139 // and flushed we overwrite the buffer so the cleartext passphrase does
140 // not linger in memory (or in this stack frame's leftover `String`
141 // allocation) any longer than necessary. The caller still owns the
142 // original `&str` and is responsible for its lifetime.
143 let mut passphrase_buf = passphrase.to_string();
144
145 let ts = std::time::SystemTime::now()
146 .duration_since(std::time::UNIX_EPOCH)
147 .unwrap_or_default()
148 .as_nanos();
149 let pid = std::process::id();
150 // Use thread ID to avoid collisions when tests run in parallel.
151 let tid = format!("{:?}", std::thread::current().id())
152 .replace("ThreadId(", "")
153 .replace(')', "");
154 let filename = format!("toride-askpass-{pid}-{tid}-{ts}");
155 // `mut` is only reassigned on Windows (to publish the `.bat` path);
156 // on Unix it stays as declared.
157 #[cfg_attr(unix, allow(unused_mut))]
158 let mut script_path = dir.join(&filename);
159
160 // On Unix, write a shell script. On Windows, write a batch file.
161 #[cfg(unix)]
162 {
163 // Escape single quotes in the passphrase for safe shell embedding.
164 let escaped = passphrase_buf.replace('\'', "'\\''");
165 let script_content = format!("#!/bin/sh\necho '{escaped}'\n");
166
167 // Write atomically via a hidden sibling + `rename(2)`.
168 //
169 // The Linux kernel returns `ETXTBSY` ("Text file busy") from
170 // `execve(2)` whenever the target file is open for writing by *any*
171 // process at the moment of the call. Even though we close our own
172 // `File` handle before returning, the brief interval between the
173 // write and the close — combined with the kernel's deferred inode
174 // accounting — is enough to lose the race when a caller (or, in our
175 // test suite, multiple parallel tests) execs the script immediately
176 // after construction. `fsync` alone does not close this window
177 // because it flushes *data*, not the kernel's "file is being
178 // written" bookkeeping.
179 //
180 // The deterministic fix is to write the script to a temporary
181 // sibling file, fsync it, close every writer, and *then* atomically
182 // `rename(2)` it into its final path. After the rename, the
183 // destination inode has never been opened for writing by anyone, so
184 // a subsequent `execve` can never observe a writer and thus can
185 // never return `ETXTBSY`.
186 //
187 // The temp file is created with its final `0o700` mode (rwx------)
188 // via a single `open(2)` with `O_CREAT|O_EXCL`, so there is also no
189 // window in which the script exists but is world-readable or
190 // non-executable. We pass `create_new(true)` so a name collision
191 // fails loudly instead of silently overwriting another handler's
192 // script.
193 let tmp_path = {
194 let mut name = filename.clone();
195 name.push_str(".tmp");
196 dir.join(&name)
197 };
198 let mut file = std::fs::OpenOptions::new()
199 .write(true)
200 .create_new(true)
201 .mode(0o700)
202 .open(&tmp_path)
203 .map_err(|e| {
204 Error::CommandFailed(format!(
205 "failed to create askpass script {}: {e}",
206 tmp_path.display()
207 ))
208 })?;
209 file.write_all(script_content.as_bytes()).map_err(|e| {
210 Error::CommandFailed(format!(
211 "failed to write askpass script {}: {e}",
212 tmp_path.display()
213 ))
214 })?;
215 // Flush data + metadata so the renamed file is fully on disk.
216 let _ = file.sync_all();
217 drop(file);
218
219 // Atomically publish the script at its final path. `rename(2)`
220 // within the same directory is atomic on POSIX, so no reader (or
221 // execve) ever sees a half-written file or an open writer.
222 std::fs::rename(&tmp_path, &script_path).map_err(|e| {
223 // Best-effort cleanup of the temp file if rename failed.
224 let _ = std::fs::remove_file(&tmp_path);
225 Error::CommandFailed(format!(
226 "failed to publish askpass script {}: {e}",
227 script_path.display()
228 ))
229 })?;
230 }
231
232 #[cfg(windows)]
233 {
234 // On Windows, write a batch file that echoes the passphrase.
235 //
236 // We use `setlocal enabledelayedexpansion` and `set "VAR=value"`
237 // so that shell metacharacters like `&`, `|`, `>`, `<`, and `^`
238 // are treated as literal text (they are harmless inside the
239 // quoted `set` form). The following characters still need
240 // escaping:
241 //
242 // - `%` → `%%` — prevents `%VAR%`-style expansion in the
243 // percent-expansion phase (phase 1).
244 // - `!` → `^^!` — the first `^` is consumed by phase-1 caret
245 // processing, leaving `^!`; in the delayed-expansion phase
246 // (phase 3), `^` escapes `!`, yielding a literal `!`.
247 // - `"` → `""` — embeds a literal double-quote inside the
248 // `set "VAR=value"` assignment (Windows 10+ / Server 2016+).
249 let bat_path = script_path.with_extension("bat");
250 let escaped = passphrase_buf
251 .replace('%', "%%")
252 .replace('!', "^^!")
253 .replace('"', "\"\"");
254 let script_content = format!(
255 "@echo off\r\n\
256 setlocal enabledelayedexpansion\r\n\
257 set \"PASSPHRASE={escaped}\"\r\n\
258 echo !PASSPHRASE!\r\n"
259 );
260
261 // Write via a hidden sibling + `MoveFileEx` (atomic rename), the
262 // Windows analogue of the Unix branch. The previous implementation
263 // used `std::fs::write`, which (a) silently overwrites any file
264 // already at the destination — a name collision would clobber
265 // another handler's script — and (b) opens the destination path
266 // for writing and hands it out to readers while the write may
267 // still be buffered, mirroring the `ETXTBSY`/sharing-violation
268 // window the Unix branch already avoids.
269 //
270 // We use `OpenOptions::create_new(true)` (`CREATE_NEW`) so a name
271 // collision fails loudly instead of silently overwriting, then
272 // `fsync` and atomically rename the sibling into place. After the
273 // rename, the destination has never been opened for writing by the
274 // process, so no concurrent reader can observe a half-written file
275 // or hit a sharing violation.
276 //
277 // ACL hardening: unlike the Unix `0o700` mode, we do not pin an
278 // explicit DACL here. `%TEMP%` / `%USERPROFILE%` are created with
279 // an ACL that grants access only to the current user (and
280 // administrators) by default, which is the same effective
281 // protection. Tightening the per-file ACL would require a Windows
282 // ACL crate; we rely on the per-user temp-directory ACL instead.
283 // The `create_new` guard above is the load-bearing safety
284 // improvement over `std::fs::write`.
285 let tmp_path = {
286 let mut name = filename.clone();
287 name.push_str(".tmp");
288 dir.join(&name)
289 };
290 let mut file = std::fs::OpenOptions::new()
291 .write(true)
292 .create_new(true)
293 .open(&tmp_path)
294 .map_err(|e| {
295 Error::CommandFailed(format!(
296 "failed to create askpass script {}: {e}",
297 tmp_path.display()
298 ))
299 })?;
300 file.write_all(script_content.as_bytes()).map_err(|e| {
301 Error::CommandFailed(format!(
302 "failed to write askpass script {}: {e}",
303 tmp_path.display()
304 ))
305 })?;
306 // Flush data + metadata so the renamed file is fully on disk.
307 let _ = file.sync_all();
308 drop(file);
309
310 // Atomically publish the script at its final path. A same-volume
311 // `rename` is atomic on Windows (MoveFileEx with
312 // MOVEFILE_REPLACE_EXISTING is not used, so a pre-existing
313 // destination surfaces as an error rather than a silent
314 // overwrite).
315 std::fs::rename(&tmp_path, &bat_path).map_err(|e| {
316 // Best-effort cleanup of the temp file if rename failed.
317 let _ = std::fs::remove_file(&tmp_path);
318 Error::CommandFailed(format!(
319 "failed to publish askpass script {}: {e}",
320 bat_path.display()
321 ))
322 })?;
323 // The actual written file is the .bat, not the extensionless
324 // original; publish it so Drop cleanup removes the correct file.
325 script_path = bat_path;
326 }
327
328 // The script has been written and flushed on both platforms; the
329 // cleartext passphrase is no longer needed in this frame. Overwrite our
330 // owned copy so it is not left resident in memory (or in the freed
331 // allocation) any longer than necessary. The caller still owns the
332 // original `&str` and is responsible for its lifetime.
333 passphrase_buf.zeroize();
334
335 Ok(script_path)
336 }
337}
338
339impl Drop for AskpassHandler {
340 fn drop(&mut self) {
341 self.cleanup();
342 }
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 /// Each test gets its own dedicated [`tempfile::TempDir`] instead of the
350 /// shared `std::env::temp_dir()`. This isolates the askpass script from
351 /// other concurrently running tests (in this crate and across the
352 /// workspace), which was the root cause of the `ETXTBSY` ("Text file
353 /// busy") flakes observed on `script_outputs_passphrase` /
354 /// `script_with_empty_passphrase` / `script_with_single_quotes_in_passphrase`
355 /// under parallel test load.
356 ///
357 /// The `TempDir` is returned alongside the handler so the caller keeps it
358 /// alive (and thus the directory on disk) for the duration of the test; it
359 /// is removed automatically when it goes out of scope.
360 fn handler_in_tempdir(passphrase: &str) -> (tempfile::TempDir, AskpassHandler) {
361 let dir = tempfile::TempDir::new().expect("failed to create temp dir");
362 let handler = AskpassHandler::new_in_dir(passphrase, dir.path())
363 .expect("failed to create askpass handler");
364 (dir, handler)
365 }
366
367 #[test]
368 fn creates_and_cleans_up_script() {
369 let (_dir, handler) = handler_in_tempdir("test-passphrase");
370 assert!(
371 handler.script_path().exists(),
372 "askpass script should exist after creation"
373 );
374
375 let path = handler.script_path().to_path_buf();
376 handler.cleanup();
377
378 assert!(
379 !path.exists(),
380 "askpass script should be removed after cleanup"
381 );
382 }
383
384 #[test]
385 fn drop_removes_script() {
386 let path;
387 {
388 let (_dir, handler) = handler_in_tempdir("drop-test");
389 path = handler.script_path().to_path_buf();
390 assert!(path.exists());
391 }
392 // After drop, the file should be gone.
393 assert!(!path.exists(), "askpass script should be removed on drop");
394 }
395
396 #[test]
397 fn script_is_executable() {
398 let (_dir, handler) = handler_in_tempdir("exec-test");
399
400 #[cfg(unix)]
401 {
402 use std::os::unix::fs::PermissionsExt;
403 let mode = std::fs::metadata(handler.script_path())
404 .unwrap()
405 .permissions()
406 .mode();
407 // Check that the owner execute bit is set.
408 assert_ne!(mode & 0o100, 0, "script should be owner-executable");
409 // Check that the file is not world-readable (0o700 permissions).
410 assert_eq!(mode & 0o777, 0o700, "script should have 0o700 permissions");
411 }
412
413 handler.cleanup();
414 }
415
416 #[test]
417 fn script_outputs_passphrase() {
418 let (_dir, handler) = handler_in_tempdir("my-secret-pass");
419
420 #[cfg(unix)]
421 {
422 let output = run_script_retrying_busy(handler.script_path());
423 let stdout = String::from_utf8(output.stdout).unwrap();
424 assert_eq!(
425 stdout.trim(),
426 "my-secret-pass",
427 "script should output the passphrase"
428 );
429 }
430
431 handler.cleanup();
432 }
433
434 #[test]
435 fn script_with_single_quotes_in_passphrase() {
436 let (_dir, handler) = handler_in_tempdir("it's a \"test\"");
437
438 #[cfg(unix)]
439 {
440 let output = run_script_retrying_busy(handler.script_path());
441 let stdout = String::from_utf8(output.stdout).unwrap();
442 assert_eq!(
443 stdout.trim(),
444 "it's a \"test\"",
445 "script should handle single quotes in passphrase"
446 );
447 }
448
449 handler.cleanup();
450 }
451
452 #[test]
453 fn script_with_empty_passphrase() {
454 let (_dir, handler) = handler_in_tempdir("");
455
456 #[cfg(unix)]
457 {
458 let output = run_script_retrying_busy(handler.script_path());
459 let stdout = String::from_utf8(output.stdout).unwrap();
460 assert_eq!(
461 stdout.trim(),
462 "",
463 "empty passphrase should produce empty output"
464 );
465 }
466
467 handler.cleanup();
468 }
469
470 /// Run the askpass script, retrying on `ETXTBSY` ("Text file busy").
471 ///
472 /// On Linux, `execve(2)` returns `ETXTBSY` (errno 26) when the file being
473 /// executed has an open writer. Even after an atomic write+rename (so no
474 /// userspace process holds the file open) the kernel's `i_writecount`
475 /// accounting can transiently report a write reference while the binfmt
476 /// layer sets up the text segment, **under concurrent fork/exec load** —
477 /// e.g. when cargo runs the askpass tests in parallel. This is a known,
478 /// purely-transient kernel race; the script is always executable a few
479 /// microseconds later. We retry a handful of times with a short backoff so
480 /// the tests are deterministic without weakening their assertions.
481 #[cfg(unix)]
482 fn run_script_retrying_busy(path: &std::path::Path) -> std::process::Output {
483 let mut backoff = std::time::Duration::from_micros(100);
484 for attempt in 0..50 {
485 match std::process::Command::new(path).output() {
486 Ok(o) => return o,
487 Err(e) if e.raw_os_error() == Some(libc::ETXTBSY) && attempt < 49 => {
488 std::thread::sleep(backoff);
489 backoff = (backoff * 2).min(std::time::Duration::from_millis(5));
490 }
491 Err(e) => panic!("failed to run askpass script: {e}"),
492 }
493 }
494 unreachable!("retry loop exhausted without returning or panicking");
495 }
496
497 #[test]
498 fn apply_to_command_sets_env_vars() {
499 let (_dir, handler) = handler_in_tempdir("env-test");
500 let cmd = duct::cmd!("true");
501 let _configured = handler.apply_to_command(cmd);
502 // We can't directly inspect env vars on a duct::Expression, but
503 // this test verifies the method compiles and doesn't panic.
504 handler.cleanup();
505 }
506
507 #[test]
508 fn cleanup_is_idempotent() {
509 // Keep the TempDir alive for the lifetime of the handler so cleanup
510 // operates on a real, owned directory.
511 let dir = tempfile::TempDir::new().expect("failed to create temp dir");
512 let handler = AskpassHandler::new_in_dir("idempotent-test", dir.path());
513 // Skip test if script creation fails (e.g., temp dir issues).
514 let Ok(handler) = handler else {
515 return;
516 };
517 handler.cleanup();
518 // Second cleanup should not panic.
519 handler.cleanup();
520 }
521}