Skip to content

Commit 08d1588

Browse files
committed
feat: release 2.45.2. ProxyJump bastions get their own credentials
Each hop in a jump chain now resolves its own purple:askpass source. Purple inspects the SSH password prompt argument to identify which hop is being authenticated, builds the connection's ProxyJump chain from the target alias, and looks up the matching entry's credential. A malicious server cannot exfiltrate credentials for unrelated entries in ~/.ssh/config because resolution is restricted to the chain. Retry markers are scoped per resolved hop. The marker cleanup on successful connection now sweeps every ~/.purple/.askpass_* file so stranded bastion markers do not block the next reconnect. Closes #43.
1 parent 2844e16 commit 08d1588

10 files changed

Lines changed: 565 additions & 35 deletions

File tree

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,10 @@
1+
## 2.45.2 - 2026-04-25
2+
3+
- feat: ProxyJump bastions get their own credentials.
4+
- feat: Each hop in your jump chain reads its own keychain, 1Password, Bitwarden, pass or Vault KV entry, so multi-hop SSH connects without typing.
5+
- feat: Every hop is scoped to its own retry window, so a stale bastion marker no longer blocks the next reconnect.
6+
- change: Prompt-based credential lookup is restricted to the hosts in your ProxyJump chain, so a malicious server cannot phish credentials for other entries in your config.
7+
18
## 2.45.1 - 2026-04-20
29

310
- fix: Tighter releases. Build verification stays stable across calendar days and CI runners.

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "purple-ssh"
3-
version = "2.45.1"
3+
version = "2.45.2"
44
edition = "2024"
55
description = "Open-source terminal SSH manager and SSH config editor. Search hundreds of hosts, sync from 16 clouds, transfer files, manage Docker and Podman over SSH, sign short-lived Vault SSH certs and expose an MCP server for AI agents. Rust TUI, MIT licensed."
66
license = "MIT"

llms.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -440,7 +440,7 @@ A: Press m in the host list to open the theme picker with live preview. 11 built
440440

441441
## Status
442442

443-
- Current version: 2.45.1 (April 2026)
443+
- Current version: 2.45.2 (April 2026)
444444
- Release cadence: approximately bi-weekly
445445
- Test suite: 6500+ tests (unit, integration, property-based, HTTP mocking and OpenSSH ground-truth cross-validation)
446446
- CI: fmt, clippy, build, test on macOS and Linux, cargo-deny, MSRV 1.86 check, rustdoc warnings, site sync, TUI smoke test, design system, message centralization, keybinding invariants and visual regression

site/page.html

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@
4141
"url": "https://getpurple.sh",
4242
"downloadUrl": "https://getpurple.sh",
4343
"installUrl": "https://github.com/erickochen/purple/releases",
44-
"softwareVersion": "2.45.1",
44+
"softwareVersion": "2.45.2",
4545
"datePublished": "2024-10-01",
46-
"dateModified": "2026-04-20",
46+
"dateModified": "2026-04-25",
4747
"softwareRequirements": "macOS or Linux",
4848
"programmingLanguage": "Rust",
4949
"license": "https://opensource.org/licenses/MIT",
@@ -861,7 +861,7 @@ <h2 class="tagline">Open-source terminal SSH manager and SSH config editor for m
861861
<button class="copy-btn copy-inline" id="copy-btn" onclick="copy(this)" style="display:none">copy</button>
862862
</div>
863863
<div class="install-output" id="install-output" style="display:none">
864-
<div>Downloading purple v2.45.1 for darwin-arm64...</div>
864+
<div>Downloading purple v2.45.2 for darwin-arm64...</div>
865865
<div>Installing to /usr/local/bin/purple... <span class="success">done.</span></div>
866866
</div>
867867
<div class="alt-installs" id="alt-installs" style="display:none">
@@ -983,7 +983,7 @@ <h2 class="tagline">Open-source terminal SSH manager and SSH config editor for m
983983
<summary>How do I troubleshoot connection problems?</summary>
984984
<div class="answer">Run with <code>--verbose</code> to enable debug logging, then <code>purple logs --tail</code> in another terminal. Logs are written to <code>~/.purple/purple.log</code> with fault domain prefixes: <code>[external]</code> for remote/tool errors, <code>[config]</code> for local config issues. Set <code>PURPLE_LOG=trace</code> for maximum detail.</div>
985985
</details>
986-
<div class="man-foot"><span>purple v2.45.1</span><span>2026-04-20</span><span>PURPLE(1)</span></div>
986+
<div class="man-foot"><span>purple v2.45.2</span><span>2026-04-25</span><span>PURPLE(1)</span></div>
987987
</div>
988988
</div>
989989

site/worker.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -177,9 +177,9 @@ const LANDING_PAGE = `<!DOCTYPE html>
177177
"url": "https://getpurple.sh",
178178
"downloadUrl": "https://getpurple.sh",
179179
"installUrl": "https://github.com/erickochen/purple/releases",
180-
"softwareVersion": "2.45.1",
180+
"softwareVersion": "2.45.2",
181181
"datePublished": "2024-10-01",
182-
"dateModified": "2026-04-20",
182+
"dateModified": "2026-04-25",
183183
"softwareRequirements": "macOS or Linux",
184184
"programmingLanguage": "Rust",
185185
"license": "https://opensource.org/licenses/MIT",
@@ -997,7 +997,7 @@ footer .sep { margin: 0 0.3em; }
997997
<button class="copy-btn copy-inline" id="copy-btn" onclick="copy(this)" style="display:none">copy</button>
998998
</div>
999999
<div class="install-output" id="install-output" style="display:none">
1000-
<div>Downloading purple v2.45.1 for darwin-arm64...</div>
1000+
<div>Downloading purple v2.45.2 for darwin-arm64...</div>
10011001
<div>Installing to /usr/local/bin/purple... <span class="success">done.</span></div>
10021002
</div>
10031003
<div class="alt-installs" id="alt-installs" style="display:none">
@@ -1119,7 +1119,7 @@ footer .sep { margin: 0 0.3em; }
11191119
<summary>How do I troubleshoot connection problems?</summary>
11201120
<div class="answer">Run with <code>--verbose</code> to enable debug logging, then <code>purple logs --tail</code> in another terminal. Logs are written to <code>~/.purple/purple.log</code> with fault domain prefixes: <code>[external]</code> for remote/tool errors, <code>[config]</code> for local config issues. Set <code>PURPLE_LOG=trace</code> for maximum detail.</div>
11211121
</details>
1122-
<div class="man-foot"><span>purple v2.45.1</span><span>2026-04-20</span><span>PURPLE(1)</span></div>
1122+
<div class="man-foot"><span>purple v2.45.2</span><span>2026-04-25</span><span>PURPLE(1)</span></div>
11231123
</div>
11241124
</div>
11251125
@@ -1616,7 +1616,7 @@ A: Press m in the host list to open the theme picker with live preview. 11 built
16161616
16171617
## Status
16181618
1619-
- Current version: 2.45.1 (April 2026)
1619+
- Current version: 2.45.2 (April 2026)
16201620
- Release cadence: approximately bi-weekly
16211621
- Test suite: 6500+ tests (unit, integration, property-based, HTTP mocking and OpenSSH ground-truth cross-validation)
16221622
- CI: fmt, clippy, build, test on macOS and Linux, cargo-deny, MSRV 1.86 check, rustdoc warnings, site sync, TUI smoke test, design system, message centralization, keybinding invariants and visual regression

src/askpass.rs

Lines changed: 143 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use std::collections::HashSet;
12
use std::path::PathBuf;
23
use std::process::Command;
34
use std::time::SystemTime;
@@ -80,17 +81,37 @@ pub fn handle() -> Result<()> {
8081
std::process::exit(1);
8182
}
8283

83-
// Retry detection: if we've been called recently for this alias, the password was wrong.
84-
// Exit with error so SSH falls back to interactive prompt.
85-
let marker = marker_path(&alias);
84+
// Parse config first so we can resolve the prompt's host to the right entry.
85+
// With ProxyJump, ssh fires askpass for each hop. The prompt argv[1] tells us
86+
// which hop is being authenticated; PURPLE_HOST_ALIAS only knows the final
87+
// target. Resolving the prompt host to its own alias scopes the keychain
88+
// lookup to the correct entry per hop.
89+
let config =
90+
SshConfigFile::parse(&PathBuf::from(&config_path)).context("Failed to parse SSH config")?;
91+
92+
// Restrict prompt-based resolution to PURPLE_HOST_ALIAS and the hosts
93+
// reachable via its ProxyJump chain. Without this scope, a malicious
94+
// server could send a keyboard-interactive prompt formatted like a
95+
// password prompt for an unrelated host (`attacker@victim's password:`)
96+
// and exfiltrate that host's credential. Chain membership ensures we
97+
// only ever supply credentials for hosts the user has wired into this
98+
// connection.
99+
let chain = build_proxy_chain(&config, &alias);
100+
let resolved_alias = parse_password_prompt_host(&prompt)
101+
.and_then(|h| find_alias_for_host(&config, h, &chain))
102+
.unwrap_or_else(|| alias.clone());
103+
104+
// Retry detection: if we've been called recently for this resolved alias,
105+
// the password was wrong. Exit with error so SSH falls back to interactive.
106+
// The marker is keyed on the resolved alias so retries on one ProxyJump hop
107+
// do not block askpass on the next hop.
108+
let marker = marker_path(&resolved_alias);
86109
if let Some(marker_path) = &marker {
87110
if is_recent_marker(marker_path) {
88-
debug!("Askpass retry detected for {alias}");
89-
// Clean up and bail
111+
debug!("Askpass retry detected for {resolved_alias}");
90112
let _ = std::fs::remove_file(marker_path);
91113
std::process::exit(1);
92114
}
93-
// Create marker for retry detection
94115
if let Err(e) = std::fs::create_dir_all(marker_path.parent().unwrap()) {
95116
debug!("[config] Failed to create askpass marker directory: {e}");
96117
}
@@ -99,31 +120,25 @@ pub fn handle() -> Result<()> {
99120
}
100121
}
101122

102-
// Parse config and find askpass source
103-
let config =
104-
SshConfigFile::parse(&PathBuf::from(&config_path)).context("Failed to parse SSH config")?;
105-
106-
let source = find_askpass_source(&config, &alias);
123+
let source = find_askpass_source(&config, &resolved_alias);
107124

108125
let source = match source {
109126
Some(s) => s,
110127
None => std::process::exit(1),
111128
};
112129

113-
debug!("Askpass invoked for alias={alias} source={source}");
130+
debug!("Askpass invoked for alias={resolved_alias} source={source}");
114131

115-
// Retrieve password
116-
let hostname = find_hostname(&config, &alias);
117-
match retrieve_password(&source, &alias, &hostname) {
132+
let hostname = find_hostname(&config, &resolved_alias);
133+
match retrieve_password(&source, &resolved_alias, &hostname) {
118134
Ok(password) => {
119-
debug!("Askpass retrieved password for {alias} via {source}");
135+
debug!("Askpass retrieved password for {resolved_alias} via {source}");
120136
print!("{}", password);
121137
Ok(())
122138
}
123139
Err(err) => {
124140
warn!("[external] Password retrieval failed via {source}");
125141
debug!("[external] Password retrieval detail: {err}");
126-
// Clean up marker on failure
127142
if let Some(m) = &marker {
128143
let _ = std::fs::remove_file(m);
129144
}
@@ -132,6 +147,97 @@ pub fn handle() -> Result<()> {
132147
}
133148
}
134149

150+
/// Extract the host being authenticated from an OpenSSH password prompt.
151+
/// OpenSSH builds prompts as `<user>@<host>'s password:` (see `userauth_passwd`
152+
/// in openssh-portable). IPv6 hosts are rendered with brackets (`user@[::1]`),
153+
/// which we strip so the result matches a plain `HostName` entry. Returns
154+
/// `None` for keyboard-interactive prompts or any other format we cannot parse,
155+
/// so the caller falls back to PURPLE_HOST_ALIAS.
156+
fn parse_password_prompt_host(prompt: &str) -> Option<&str> {
157+
let idx = prompt.find("'s password")?;
158+
let head = &prompt[..idx];
159+
let (_, host) = head.rsplit_once('@')?;
160+
let host = host.trim();
161+
let host = host
162+
.strip_prefix('[')
163+
.and_then(|s| s.strip_suffix(']'))
164+
.unwrap_or(host);
165+
if host.is_empty() { None } else { Some(host) }
166+
}
167+
168+
/// Find the alias whose entry matches `host` by alias or hostname, restricted
169+
/// to entries in `permitted`. Alias match takes priority over hostname match
170+
/// in a single pass. Used to map the SSH prompt's host (which may be a bastion
171+
/// in a ProxyJump chain) back to the entry that owns its `# purple:askpass`
172+
/// config. The `permitted` scope blocks malicious-server attempts to coax a
173+
/// credential lookup for an unrelated host in `~/.ssh/config`.
174+
fn find_alias_for_host(
175+
config: &SshConfigFile,
176+
host: &str,
177+
permitted: &HashSet<String>,
178+
) -> Option<String> {
179+
let mut by_hostname: Option<String> = None;
180+
for entry in config.host_entries() {
181+
if !permitted.contains(&entry.alias) {
182+
continue;
183+
}
184+
if entry.alias.eq_ignore_ascii_case(host) {
185+
return Some(entry.alias.clone());
186+
}
187+
if by_hostname.is_none() && entry.hostname.eq_ignore_ascii_case(host) {
188+
by_hostname = Some(entry.alias.clone());
189+
}
190+
}
191+
by_hostname
192+
}
193+
194+
/// Build the set of aliases reachable from `target` via its ProxyJump chain,
195+
/// including `target` itself. ProxyJump values can be comma-separated and
196+
/// formatted `[user@]host[:port]`, including bracketed IPv6 hosts. Cycles are
197+
/// broken by the visited-set; entries that reference unknown hosts contribute
198+
/// nothing to the chain.
199+
fn build_proxy_chain(config: &SshConfigFile, target: &str) -> HashSet<String> {
200+
let entries = config.host_entries();
201+
let mut chain: HashSet<String> = HashSet::new();
202+
let mut queue: Vec<String> = vec![target.to_string()];
203+
while let Some(current) = queue.pop() {
204+
if !chain.insert(current.clone()) {
205+
continue;
206+
}
207+
let Some(entry) = entries.iter().find(|e| e.alias == current) else {
208+
continue;
209+
};
210+
if entry.proxy_jump.is_empty() {
211+
continue;
212+
}
213+
for jump in entry.proxy_jump.split(',') {
214+
let host = parse_proxy_jump_host(jump);
215+
if host.is_empty() {
216+
continue;
217+
}
218+
for e in &entries {
219+
if e.alias.eq_ignore_ascii_case(host) || e.hostname.eq_ignore_ascii_case(host) {
220+
queue.push(e.alias.clone());
221+
}
222+
}
223+
}
224+
}
225+
chain
226+
}
227+
228+
/// Extract the host portion from a single ProxyJump entry of the form
229+
/// `[user@]host[:port]`. Handles bracketed IPv6 hosts (`[::1]:22`).
230+
fn parse_proxy_jump_host(jump: &str) -> &str {
231+
let trimmed = jump.trim();
232+
let after_user = trimmed.rsplit_once('@').map(|(_, h)| h).unwrap_or(trimmed);
233+
if let Some(rest) = after_user.strip_prefix('[') {
234+
if let Some(end) = rest.find(']') {
235+
return &rest[..end];
236+
}
237+
}
238+
after_user.split(':').next().unwrap_or(after_user)
239+
}
240+
135241
/// Find the askpass source for a host. Checks per-host config, then global default.
136242
fn find_askpass_source(config: &SshConfigFile, alias: &str) -> Option<String> {
137243
// Per-host source
@@ -432,10 +538,26 @@ fn is_recent_marker(path: &PathBuf) -> bool {
432538
false
433539
}
434540

435-
/// Clean up the retry marker file for an alias. Called after a successful connection.
436-
pub fn cleanup_marker(alias: &str) {
437-
if let Some(path) = marker_path(alias) {
438-
let _ = std::fs::remove_file(path);
541+
/// Clean up retry markers after a successful connection. ProxyJump connections
542+
/// create one marker per hop and the parent process only knows the final
543+
/// target alias, so we clear every `~/.purple/.askpass_*` file on success.
544+
/// Each marker has a 60s expiry; this just keeps rapid reconnects snappy and
545+
/// prevents a stranded bastion marker from blocking the next attempt.
546+
pub fn cleanup_marker(_alias: &str) {
547+
let Some(home) = dirs::home_dir() else {
548+
return;
549+
};
550+
let Ok(read) = std::fs::read_dir(home.join(".purple")) else {
551+
return;
552+
};
553+
for entry in read.flatten() {
554+
if entry
555+
.file_name()
556+
.to_str()
557+
.is_some_and(|s| s.starts_with(".askpass_"))
558+
{
559+
let _ = std::fs::remove_file(entry.path());
560+
}
439561
}
440562
}
441563

0 commit comments

Comments
 (0)