Support encrypted native identity keys
ci / test (push) Has been cancelled
ci / remote-bench (push) Has been cancelled

This commit is contained in:
DuProcess
2026-06-13 15:26:31 -04:00
parent 55a0fc1ab4
commit f324a7627f
4 changed files with 287 additions and 20 deletions
Generated
+141
View File
@@ -12,6 +12,31 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "aes"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
dependencies = [
"cfg-if",
"cipher",
"cpufeatures",
]
[[package]]
name = "aes-gcm"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "831010a0f742e1209b3bcea8fab6a8e149051ba6099432c8cb2cc117dec3ead1"
dependencies = [
"aead",
"aes",
"cipher",
"ctr",
"ghash",
"subtle",
]
[[package]] [[package]]
name = "anstream" name = "anstream"
version = "1.0.0" version = "1.0.0"
@@ -98,6 +123,17 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bcrypt-pbkdf"
version = "0.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6aeac2e1fe888769f34f05ac343bbef98b14d1ffb292ab69d4608b3abc86f2a2"
dependencies = [
"blowfish",
"pbkdf2",
"sha2",
]
[[package]] [[package]]
name = "bincode" name = "bincode"
version = "1.3.3" version = "1.3.3"
@@ -128,12 +164,46 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-padding"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [
"generic-array",
]
[[package]]
name = "blowfish"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e412e2cd0f2b2d93e02543ceae7917b3c70331573df19ee046bcbc35e45e87d7"
dependencies = [
"byteorder",
"cipher",
]
[[package]]
name = "byteorder"
version = "1.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b"
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33"
[[package]]
name = "cbc"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
version = "1.0.4" version = "1.0.4"
@@ -284,6 +354,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "ctr"
version = "0.9.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835"
dependencies = [
"cipher",
]
[[package]] [[package]]
name = "curve25519-dalek" name = "curve25519-dalek"
version = "4.1.3" version = "4.1.3"
@@ -371,6 +450,7 @@ dependencies = [
"hmac", "hmac",
"portable-pty", "portable-pty",
"rand", "rand",
"rpassword",
"serde", "serde",
"sha2", "sha2",
"ssh-key", "ssh-key",
@@ -534,6 +614,16 @@ dependencies = [
"wasip3", "wasip3",
] ]
[[package]]
name = "ghash"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0d8a4362ccb29cb0b265253fb0a2728f592895ee6854fd9bc13f2ffda266ff1"
dependencies = [
"opaque-debug",
"polyval",
]
[[package]] [[package]]
name = "group" name = "group"
version = "0.13.0" version = "0.13.0"
@@ -608,6 +698,7 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [ dependencies = [
"block-padding",
"generic-array", "generic-array",
] ]
@@ -867,6 +958,15 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "pbkdf2"
version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "pem-rfc7468" name = "pem-rfc7468"
version = "0.7.0" version = "0.7.0"
@@ -920,6 +1020,18 @@ dependencies = [
"universal-hash", "universal-hash",
] ]
[[package]]
name = "polyval"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
dependencies = [
"cfg-if",
"cpufeatures",
"opaque-debug",
"universal-hash",
]
[[package]] [[package]]
name = "portable-pty" name = "portable-pty"
version = "0.8.1" version = "0.8.1"
@@ -1053,6 +1165,17 @@ dependencies = [
"subtle", "subtle",
] ]
[[package]]
name = "rpassword"
version = "7.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196"
dependencies = [
"libc",
"rtoolbox",
"windows-sys 0.61.2",
]
[[package]] [[package]]
name = "rsa" name = "rsa"
version = "0.9.10" version = "0.9.10"
@@ -1074,6 +1197,16 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "rtoolbox"
version = "0.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844"
dependencies = [
"libc",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rustc_version" name = "rustc_version"
version = "0.4.1" version = "0.4.1"
@@ -1335,8 +1468,15 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f"
dependencies = [ dependencies = [
"aes",
"aes-gcm",
"cbc",
"chacha20",
"cipher", "cipher",
"ctr",
"poly1305",
"ssh-encoding", "ssh-encoding",
"subtle",
] ]
[[package]] [[package]]
@@ -1356,6 +1496,7 @@ version = "0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3"
dependencies = [ dependencies = [
"bcrypt-pbkdf",
"ed25519-dalek", "ed25519-dalek",
"p256", "p256",
"p384", "p384",
+2 -1
View File
@@ -18,9 +18,10 @@ hkdf = "0.12"
hmac = "0.12" hmac = "0.12"
portable-pty = "0.8" portable-pty = "0.8"
rand = "0.8" rand = "0.8"
rpassword = "7.5.4"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
sha2 = "0.10" sha2 = "0.10"
ssh-key = { version = "0.6.7", features = ["ed25519"] } ssh-key = { version = "0.6.7", features = ["ed25519", "encryption"] }
tokio = { version = "1.41", features = ["full"] } tokio = { version = "1.41", features = ["full"] }
toml = "0.8" toml = "0.8"
vt100 = "0.15" vt100 = "0.15"
+102 -14
View File
@@ -10,8 +10,9 @@ use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_ser
use dosh::crypto; use dosh::crypto;
use dosh::native::{ use dosh::native::{
KnownHostStatus, TrustResult, derive_native_session_key, generate_native_ephemeral, KnownHostStatus, TrustResult, derive_native_session_key, generate_native_ephemeral,
host_fingerprint, load_ed25519_identity, parse_host_public_key_line, remove_trusted_host, host_fingerprint, load_ed25519_identity, load_ed25519_identity_with_passphrase,
sign_user_auth, trust_host, verify_known_host, verify_server_hello, parse_host_public_key_line, remove_trusted_host, sign_user_auth, trust_host, verify_known_host,
verify_server_hello,
}; };
use dosh::protocol::{ use dosh::protocol::{
self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input,
@@ -991,19 +992,33 @@ fn load_first_native_identity(
cli_identity: Option<&Path>, cli_identity: Option<&Path>,
ssh_config: &SshConfig, ssh_config: &SshConfig,
) -> Result<ed25519_dalek::SigningKey> { ) -> Result<ed25519_dalek::SigningKey> {
load_first_native_identity_with_prompt(
config,
cli_identity,
ssh_config,
prompt_identity_passphrase,
)
}
fn load_first_native_identity_with_prompt<F>(
config: &dosh::config::ClientConfig,
cli_identity: Option<&Path>,
ssh_config: &SshConfig,
mut prompt: F,
) -> Result<ed25519_dalek::SigningKey>
where
F: FnMut(&Path) -> Result<String>,
{
let mut paths = Vec::new(); let mut paths = Vec::new();
if let Some(path) = cli_identity { if let Some(path) = cli_identity {
paths.push(path.to_path_buf()); push_identity_path(&mut paths, path.to_path_buf());
}
for path in &ssh_config.identity_files {
push_identity_path(&mut paths, expand_tilde(path));
}
for path in &config.identity_files {
push_identity_path(&mut paths, expand_tilde(path));
} }
paths.extend(
ssh_config
.identity_files
.iter()
.map(|path| expand_tilde(path)),
);
paths.extend(config.identity_files.iter().map(|path| expand_tilde(path)));
paths.sort();
paths.dedup();
let mut errors = Vec::new(); let mut errors = Vec::new();
for path in paths { for path in paths {
@@ -1012,6 +1027,14 @@ fn load_first_native_identity(
} }
match load_ed25519_identity(&path) { match load_ed25519_identity(&path) {
Ok(identity) => return Ok(identity), Ok(identity) => return Ok(identity),
Err(err) if err.to_string().contains(" is encrypted") => {
match prompt(&path).and_then(|passphrase| {
load_ed25519_identity_with_passphrase(&path, Some(&passphrase))
}) {
Ok(identity) => return Ok(identity),
Err(err) => errors.push(format!("{}: {err:#}", path.display())),
}
}
Err(err) => errors.push(format!("{}: {err:#}", path.display())), Err(err) => errors.push(format!("{}: {err:#}", path.display())),
} }
} }
@@ -1025,6 +1048,17 @@ fn load_first_native_identity(
} }
} }
fn push_identity_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
fn prompt_identity_passphrase(path: &Path) -> Result<String> {
rpassword::prompt_password(format!("Enter passphrase for {}: ", path.display()))
.with_context(|| format!("read passphrase for {}", path.display()))
}
async fn bootstrap_attach( async fn bootstrap_attach(
socket: &UdpSocket, socket: &UdpSocket,
server_name: &str, server_name: &str,
@@ -1729,10 +1763,13 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::{ use super::{
FrameBuffer, Predictor, auth_allows, parse_ssh_config, raw_contains_host_table, FrameBuffer, Predictor, SshConfig, auth_allows, load_first_native_identity_with_prompt,
ssh_destination_host, ssh_username, startup_command, toml_bare_key_or_quoted, parse_ssh_config, raw_contains_host_table, ssh_destination_host, ssh_username,
startup_command, toml_bare_key_or_quoted,
}; };
use dosh::config::ClientConfig;
use dosh::protocol::Frame; use dosh::protocol::Frame;
use ed25519_dalek::{SigningKey, VerifyingKey};
#[test] #[test]
fn parses_ssh_config_hostname() { fn parses_ssh_config_hostname() {
@@ -1801,6 +1838,57 @@ mod tests {
assert!(!auth_allows("native", "ssh")); assert!(!auth_allows("native", "ssh"));
} }
#[test]
fn encrypted_identity_file_prompts_and_loads() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("id_ed25519");
let signing = SigningKey::from_bytes(&[61u8; 32]);
write_encrypted_identity(&path, &signing, "let-me-in");
let mut config = ClientConfig::default();
config.identity_files.clear();
let loaded = load_first_native_identity_with_prompt(
&config,
Some(&path),
&SshConfig::default(),
|_| Ok("let-me-in".to_string()),
)
.unwrap();
assert_eq!(
VerifyingKey::from(&loaded).to_bytes(),
VerifyingKey::from(&signing).to_bytes()
);
}
#[test]
fn encrypted_identity_file_reports_wrong_passphrase() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("id_ed25519");
let signing = SigningKey::from_bytes(&[62u8; 32]);
write_encrypted_identity(&path, &signing, "correct");
let mut config = ClientConfig::default();
config.identity_files.clear();
let err = load_first_native_identity_with_prompt(
&config,
Some(&path),
&SshConfig::default(),
|_| Ok("wrong".to_string()),
)
.unwrap_err();
assert!(err.to_string().contains("no usable ssh-ed25519 identity"));
}
fn write_encrypted_identity(path: &std::path::Path, signing: &SigningKey, passphrase: &str) {
let keypair = ssh_key::private::Ed25519Keypair::from(signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "test").unwrap();
let encrypted = private.encrypt(&mut rand::rngs::OsRng, passphrase).unwrap();
encrypted
.write_openssh_file(path, ssh_key::LineEnding::LF)
.unwrap();
}
fn test_frame(seq: u64, snapshot: bool) -> Frame { fn test_frame(seq: u64, snapshot: bool) -> Frame {
Frame { Frame {
session: "test".to_string(), session: "test".to_string(),
+42 -5
View File
@@ -358,13 +358,24 @@ pub fn derive_native_session_key(
} }
pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> { pub fn load_ed25519_identity(path: &Path) -> Result<SigningKey> {
load_ed25519_identity_with_passphrase(path, None)
}
pub fn load_ed25519_identity_with_passphrase(
path: &Path,
passphrase: Option<&str>,
) -> Result<SigningKey> {
let private_key = ssh_key::PrivateKey::read_openssh_file(path) let private_key = ssh_key::PrivateKey::read_openssh_file(path)
.with_context(|| format!("read OpenSSH identity {}", path.display()))?; .with_context(|| format!("read OpenSSH identity {}", path.display()))?;
anyhow::ensure!( let private_key = if private_key.is_encrypted() {
!private_key.is_encrypted(), let passphrase = passphrase
"OpenSSH identity {} is encrypted; encrypted-key prompts are not wired yet", .ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?;
path.display() private_key
); .decrypt(passphrase)
.with_context(|| format!("decrypt OpenSSH identity {}", path.display()))?
} else {
private_key
};
let ed25519 = private_key let ed25519 = private_key
.key_data() .key_data()
.ed25519() .ed25519()
@@ -929,6 +940,32 @@ mod tests {
); );
} }
#[test]
fn load_ed25519_identity_decrypts_encrypted_openssh_private_key() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("id_ed25519");
let signing = SigningKey::from_bytes(&[12u8; 32]);
let keypair = ssh_key::private::Ed25519Keypair::from(&signing);
let private =
ssh_key::PrivateKey::new(ssh_key::private::KeypairData::from(keypair), "").unwrap();
let encrypted = private
.encrypt(&mut rand::rngs::OsRng, "correct horse battery staple")
.unwrap();
encrypted
.write_openssh_file(&path, ssh_key::LineEnding::LF)
.unwrap();
assert!(load_ed25519_identity(&path).is_err());
assert!(load_ed25519_identity_with_passphrase(&path, Some("wrong")).is_err());
let loaded =
load_ed25519_identity_with_passphrase(&path, Some("correct horse battery staple"))
.unwrap();
assert_eq!(
VerifyingKey::from(&loaded).to_bytes(),
VerifyingKey::from(&signing).to_bytes()
);
}
#[test] #[test]
fn native_session_key_derivation_matches_and_binds_transcript() { fn native_session_key_derivation_matches_and_binds_transcript() {
let host_signing = SigningKey::from_bytes(&[3u8; 32]); let host_signing = SigningKey::from_bytes(&[3u8; 32]);