Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
22842c4
feat(ssh): confirm targeted updates for changed host keys
WilliamWang1721 Oct 3, 2026
6c9fc73
style(ssh): apply Actions rustfmt output
WilliamWang1721 Oct 3, 2026
7617ed0
fix(ssh): isolate host parsing and preserve Windows trust ACLs
WilliamWang1721 Oct 3, 2026
f3f25ad
style(ssh): align safety fix with remote rustfmt
WilliamWang1721 Oct 3, 2026
a35b9ba
fix(ssh): leave nonmatching malformed patterns uninterpreted
WilliamWang1721 Oct 3, 2026
847dee8
fix(ssh): retain changed-key confirmation across file races
WilliamWang1721 Oct 3, 2026
c256760
refactor(platform): own trust-file replacement by platform capability
WilliamWang1721 Oct 3, 2026
7ad2f22
fix(ssh): keep explicitly recorded server keys trusted
WilliamWang1721 Oct 3, 2026
75b2fee
style(ssh): format loopback snapshot classification fixture
WilliamWang1721 Oct 3, 2026
70539af
test(windows): isolate ACL fixture module paths
WilliamWang1721 Oct 3, 2026
1ba5ffe
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 4, 2026
0e96857
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 6, 2026
e418975
Merge main into codex/issue-252-host-key-update
claude Oct 9, 2026
46ab15e
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 11, 2026
57673db
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 11, 2026
c98eca6
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 13, 2026
d157d04
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 13, 2026
e239078
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 14, 2026
a7ec5d4
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 14, 2026
378b552
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 14, 2026
42a3738
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 15, 2026
7c48d67
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 15, 2026
567dd93
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 16, 2026
f9f091a
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 17, 2026
2772bd3
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 19, 2026
152c4d2
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 21, 2026
2114cc1
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 21, 2026
9b102b6
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 22, 2026
8401e95
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 23, 2026
3856f3f
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 23, 2026
426d47b
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 23, 2026
b30b3cc
Merge main into codex/issue-252-host-key-update
WilliamWang1721 Oct 24, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Changed SSH host-key confirmation

## Status

Proposed for review.

## Context

Issue #252 requests an explicit update when a known SSH server changes identity.
The existing russh check rejects changed keys, without a confirmation path.

## Evidence

russh 0.62.2 checks literal/hashed host records and reports `KeyChanged` for an
incoming algorithm with a different recorded key. Its line index excludes
comments; marker records are not interpreted as ordinary host entries.
`ssh-key::known_hosts::Entry` supplies the existing semantic parser.

## Decision

Keep the handshake refused until the existing bounded prompt receives explicit
trust and the changed record is saved successfully. Show host/port, old SHA256
fingerprints, the new SHA256 fingerprint and the identity-change warning.

A presented key already trusted in the same snapshot remains trusted even when
another ordinary record retains an older key. This is not a reason to update.
Use the library parser, preserve unrelated original lines, and split only the
literal target from multi-host records. Preserve hashed names, comments, line
endings and other algorithms. Marked matching records and changed wildcard or
negated patterns fail closed; Pebrel does not implement certificate trust or
silently convert revocation into ordinary trust.

Confirmation owns a file snapshot. The short application writer lock and two
byte comparisons reject concurrent edits observed before replacement. Stage a
regular sibling file, preserve file permissions, sync and use existing atomic
replacement on Unix and DACL-preserving replacement on Windows. Reject symlinks rather than replacing a user's indirection.

## Rejected alternatives

- Automatically trusting a changed server bypasses identity verification.
- Deleting a reported line can remove other hosts and uses an unreliable index.
- Appending a literal record leaves the stale key authoritative.
- Reimplementing the complete OpenSSH parser adds unnecessary protocol policy.

## Consequences

Canceled, unattended and unavailable UI paths cannot update trust. Pattern and
marked records require external known_hosts administration. Invalid matching keys
fail closed; records without a valid matching pattern remain uninterpreted. Independent OpenSSH writers do not share Pebrel's lock: the comparison
is not a cross-tool atomic compare-and-swap. Windows uses `ReplaceFileW` without ACL/merge-error bypass flags, preserving
the target DACL. An explicit backup protects failure cases that move the original
file; recovery never overwrites a concurrent destination and retains the backup
if automatic recovery cannot complete.

## Validation

Active regressions cover isolated files, shared host lines, hashed names, ports,
other algorithms, comments/endings, markers, patterns and changed snapshots.
Unrelated malformed keys and unknown algorithms cannot block a trusted host.
Windows regressions compare a protected target DACL against a broader parent
and verify replacement failure retains the original file.
A real loopback SSH service rejects the old record, remains refused after
cancellation, and connects after explicit confirmation saves the new key.
The GPUI cancellation path and English/Chinese fingerprint formatting are covered.
All execution is delegated to GitHub Actions; local automated execution is prohibited.

## Supersedes

None.

## Revisit when

Safe wildcard replacement, certificate-authority policy, stronger cross-tool
coordination is requested.
3 changes: 3 additions & 0 deletions nebula_app/i18n/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -1742,6 +1742,9 @@
"changed": "The package changed after inspection. Choose the file again before installing."
}
},
"ssh.changedHost.title": "SSH host identity changed",
"ssh.changedHost.description": "{endpoint}\n\nPreviously saved SHA256 fingerprint(s):\n{previous}\n\nNew SHA256 fingerprint:\n{fingerprint}\n\nThis may mean the server was replaced or an attacker is impersonating it. Verify the new fingerprint through a trusted channel. Updating replaces this host’s saved key; cancel keeps the previous trust record and refuses the connection.",
"ssh.changedHost.confirm": "Update key and connect",
"ssh.ports.button": "Ports",
"ssh.ports.title": "SSH port forwarding",
"ssh.ports.empty": "No forwarded ports",
Expand Down
3 changes: 3 additions & 0 deletions nebula_app/i18n/zh-CN.json
Original file line number Diff line number Diff line change
Expand Up @@ -1742,6 +1742,9 @@
"changed": "主题包在检查后发生了变化。请重新选择文件,再确认安装。"
}
},
"ssh.changedHost.title": "SSH 主机身份已变更",
"ssh.changedHost.description": "{endpoint}\n\n之前保存的 SHA256 指纹:\n{previous}\n\n新的 SHA256 指纹:\n{fingerprint}\n\n这可能表示服务器已更换,也可能有攻击者冒充主机。请通过可信渠道核对新指纹。更新将替换此主机保存的密钥;取消将保留原信任记录并拒绝连接。",
"ssh.changedHost.confirm": "更新密钥并连接",
"ssh.ports.button": "端口",
"ssh.ports.title": "SSH 端口转发",
"ssh.ports.empty": "暂无端口转发",
Expand Down
52 changes: 52 additions & 0 deletions nebula_app/src/gpui_shell/workspace/ssh_dialog.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ pub(super) fn show(request: Arc<Prompt>, window: &mut Window, cx: &mut App) {
false,
false,
),
PromptKind::ChangedHostKey { host, port, previous, fingerprint } => (
language.text(crate::i18n::Message::SshChangedHostTitle),
language.format(
crate::i18n::Message::SshChangedHostDescription,
&[
("endpoint", &format!("{host}:{port}")),
("previous", previous),
("fingerprint", fingerprint),
],
),
false,
false,
),
PromptKind::Secret { label, allow_save } => {
(language.pick("SSH 身份验证", "SSH authentication"), label.clone(), true, *allow_save)
},
Expand Down Expand Up @@ -65,6 +78,8 @@ pub(super) fn show(request: Arc<Prompt>, window: &mut Window, cx: &mut App) {
description.clone(),
if is_secret {
language.pick("继续", "Continue")
} else if matches!(&request.kind, PromptKind::ChangedHostKey { .. }) {
language.text(crate::i18n::Message::SshChangedHostConfirm)
} else {
language.pick("信任并连接", "Trust and connect")
},
Expand Down Expand Up @@ -133,6 +148,43 @@ mod tests {
);
}

#[gpui::test]
fn changed_host_dialog_cancel_preserves_refusal(cx: &mut TestAppContext) {
cx.update(|cx| {
gpui_component::init(cx);
cx.set_reduce_motion(true);
});
let (_, cx) = cx.add_window_view(|window, cx| {
let view = cx.new(|_| DialogProbe);
Root::new(view, window, cx)
});
let (request, mut response) = Prompt::for_test(PromptKind::ChangedHostKey {
host: "fixture.example".into(),
port: 2200,
previous: "SHA256:previous".into(),
fingerprint: "SHA256:new".into(),
});
cx.update(|window, cx| {
show(request, window, cx);
let _ = window.draw(cx);
});
click(cx, "confirm-dialog-cancel");
assert!(matches!(response.try_recv(), Ok(PromptResponse::Cancel)));
for language in [crate::i18n::UiLanguage::EnUs, crate::i18n::UiLanguage::ZhCn] {
let description = language.format(
crate::i18n::Message::SshChangedHostDescription,
&[
("endpoint", "fixture.example:2200"),
("previous", "SHA256:previous"),
("fingerprint", "SHA256:new"),
],
);
for value in ["fixture.example:2200", "SHA256:previous", "SHA256:new"] {
assert!(description.contains(value));
}
}
}

#[gpui::test]
fn ssh_dialog_cancel_does_not_accept_an_unknown_host(cx: &mut TestAppContext) {
cx.update(|cx| {
Expand Down
1 change: 1 addition & 0 deletions nebula_app/src/platform/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ mod ssh_config_probe_tests;
pub mod startup;
#[cfg(unix)]
pub(crate) mod tray_native;
pub(crate) mod trust_file;
pub(crate) mod update_installation;
#[cfg(feature = "gpui-shell")]
pub(crate) mod video_underlay;
Expand Down
17 changes: 17 additions & 0 deletions nebula_app/src/platform/trust_file.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//! Replacement of an existing trust file must preserve its access controls.
use std::io;
use std::path::Path;

#[cfg(windows)]
mod windows;

pub(crate) fn replace(source: &Path, destination: &Path) -> io::Result<()> {
#[cfg(windows)]
{
windows::replace(source, destination)
}
#[cfg(not(windows))]
{
crate::atomic_file::replace(source, destination)
}
}
103 changes: 103 additions & 0 deletions nebula_app/src/platform/trust_file/windows.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
//! Trust-file replacement preserves the target DACL and never ignores ACL merge errors.
use std::io;
use std::os::windows::ffi::OsStrExt as _;
use std::path::Path;
use windows_sys::Win32::Storage::FileSystem::ReplaceFileW;

pub(crate) fn replace(source: &Path, destination: &Path) -> io::Result<()> {
let backup = tempfile::Builder::new()
.prefix(".known-hosts-backup-")
.tempfile_in(destination.parent().ok_or_else(|| io::Error::other("No trust directory"))?)?
.into_temp_path();
std::fs::remove_file(&backup)?;
let wide = |path: &Path| path.as_os_str().encode_wide().chain(Some(0)).collect::<Vec<_>>();
let original = wide(destination);
let replacement = wide(source);
let saved = wide(&backup);
// No IGNORE_ACL_ERRORS / IGNORE_MERGE_ERRORS: permission preservation is
// mandatory. A backup is required because error 1176 without one can remove
// the destination, and 1177 can move it to the backup before returning failure.
let success = unsafe {
ReplaceFileW(
original.as_ptr(),
replacement.as_ptr(),
saved.as_ptr(),
0,
std::ptr::null(),
std::ptr::null(),
)
};
if success != 0 {
return Ok(());
}
let error = io::Error::last_os_error();
if backup.exists() {
// An exclusive hard-link creation restores the original file identity
// and DACL if the destination was moved away, without overwriting any
// concurrent writer. If recovery cannot complete, retain the backup.
if !destination.exists() && std::fs::hard_link(&backup, destination).is_ok() {
return Err(error);
}
let preserved = backup.keep().map_err(io::Error::other)?;
return Err(io::Error::new(
error.kind(),
format!("{error}; original trust file retained at {}", preserved.display()),
));
}
Err(error)
}

#[cfg(test)]
mod tests {
use super::*;

fn acl(path: &Path, setup: bool) -> String {
let script = if setup {
r#"$ErrorActionPreference='Stop'; $p=$env:PEBREL_KNOWN_HOSTS; $parent=Split-Path $p; $a=Get-Acl $parent; $rule=[System.Security.AccessControl.FileSystemAccessRule]::new([System.Security.Principal.SecurityIdentifier]::new('S-1-1-0'),'Modify','ContainerInherit,ObjectInherit','None','Allow'); $a.AddAccessRule($rule); Set-Acl $parent $a; $a=Get-Acl $p; $a.SetAccessRuleProtection($true,$false); foreach($rule in @($a.Access)) { $a.RemoveAccessRuleSpecific($rule) }; $user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User; $a.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($user,'FullControl','Allow')); Set-Acl $p $a; $a=Get-Acl $p; $parentAcl=Get-Acl $parent; if($a.GetSecurityDescriptorSddlForm('Access') -eq $parentAcl.GetSecurityDescriptorSddlForm('Access')) { throw 'Target must have a stricter DACL than its parent' }; $a.GetSecurityDescriptorSddlForm('Access')"#
} else {
r#"$ErrorActionPreference='Stop'; (Get-Acl $env:PEBREL_KNOWN_HOSTS).GetSecurityDescriptorSddlForm('Access')"#
};
// Indirect launches from pwsh inherit incompatible PowerShell 7 modules.
// Let Windows PowerShell rebuild its own module paths.
let output = std::process::Command::new("powershell.exe")
.args(["-NoProfile", "-NonInteractive", "-Command", script])
.env_remove("PSModulePath")
.env("PEBREL_KNOWN_HOSTS", path)
.output()
.unwrap();
assert!(output.status.success(), "{}", String::from_utf8_lossy(&output.stderr));
String::from_utf8(output.stdout).unwrap().trim().to_owned()
}

#[test]
fn changed_trust_file_keeps_its_stricter_windows_dacl() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("known_hosts");
let replacement = directory.path().join("replacement");
std::fs::write(&path, "original trusted key\n").unwrap();
std::fs::write(&replacement, "new trusted key\n").unwrap();
let before = acl(&path, true);
replace(&replacement, &path).unwrap();
assert_eq!(
acl(&path, false),
before,
"Parent's broader inherited DACL must not replace target DACL"
);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "new trusted key\n");
}

#[test]
fn locked_destination_failure_retains_original_trust_file() {
use std::os::windows::fs::OpenOptionsExt as _;
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("known_hosts");
let replacement = directory.path().join("replacement");
std::fs::write(&path, "original trust").unwrap();
std::fs::write(&replacement, "changed trust").unwrap();
let locked = std::fs::OpenOptions::new().read(true).share_mode(0).open(&path).unwrap();
assert!(replace(&replacement, &path).is_err());
drop(locked);
assert_eq!(std::fs::read_to_string(&path).unwrap(), "original trust");
assert_eq!(std::fs::read_to_string(&replacement).unwrap(), "changed trust");
}
}
18 changes: 17 additions & 1 deletion nebula_app/src/ssh_prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ use zeroize::Zeroizing;

pub enum PromptKind {
HostKey { host: String, port: u16, fingerprint: String },
ChangedHostKey { host: String, port: u16, previous: String, fingerprint: String },
Secret { label: String, allow_save: bool },
}

Expand Down Expand Up @@ -34,7 +35,10 @@ impl Prompt {

pub fn respond(&self, response: PromptResponse) -> bool {
let response = match (&self.kind, response) {
(PromptKind::HostKey { .. }, PromptResponse::Trust) => PromptResponse::Trust,
(
PromptKind::HostKey { .. } | PromptKind::ChangedHostKey { .. },
PromptResponse::Trust,
) => PromptResponse::Trust,
(PromptKind::Secret { allow_save, .. }, PromptResponse::Secret { value, save }) => {
PromptResponse::Secret { value, save: save && *allow_save }
},
Expand Down Expand Up @@ -98,6 +102,18 @@ pub async fn confirm_host(host: &str, port: u16, fingerprint: String) -> io::Res
Ok(matches!(response, PromptResponse::Trust))
}

pub async fn confirm_changed_host(
host: &str,
port: u16,
previous: String,
fingerprint: String,
) -> io::Result<bool> {
let response =
request(PromptKind::ChangedHostKey { host: host.to_owned(), port, previous, fingerprint })
.await?;
Ok(matches!(response, PromptResponse::Trust))
}

pub async fn secret(
label: String,
allow_save: bool,
Expand Down
Loading
Loading