Skip to main content

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}