diff --git a/Cargo.lock b/Cargo.lock index 36dcdc3..b00bafe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,31 @@ dependencies = [ "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]] name = "anstream" version = "1.0.0" @@ -98,6 +123,17 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "bincode" version = "1.3.3" @@ -128,12 +164,46 @@ dependencies = [ "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]] name = "bytes" version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -284,6 +354,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "ctr" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +dependencies = [ + "cipher", +] + [[package]] name = "curve25519-dalek" version = "4.1.3" @@ -371,6 +450,7 @@ dependencies = [ "hmac", "portable-pty", "rand", + "rpassword", "serde", "sha2", "ssh-key", @@ -534,6 +614,16 @@ dependencies = [ "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]] name = "group" version = "0.13.0" @@ -608,6 +698,7 @@ version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ + "block-padding", "generic-array", ] @@ -867,6 +958,15 @@ dependencies = [ "windows-link", ] +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -920,6 +1020,18 @@ dependencies = [ "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]] name = "portable-pty" version = "0.8.1" @@ -1053,6 +1165,17 @@ dependencies = [ "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]] name = "rsa" version = "0.9.10" @@ -1074,6 +1197,16 @@ dependencies = [ "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]] name = "rustc_version" version = "0.4.1" @@ -1335,8 +1468,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "caac132742f0d33c3af65bfcde7f6aa8f62f0e991d80db99149eb9d44708784f" dependencies = [ + "aes", + "aes-gcm", + "cbc", + "chacha20", "cipher", + "ctr", + "poly1305", "ssh-encoding", + "subtle", ] [[package]] @@ -1356,6 +1496,7 @@ version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b86f5297f0f04d08cabaa0f6bff7cb6aec4d9c3b49d87990d63da9d9156a8c3" dependencies = [ + "bcrypt-pbkdf", "ed25519-dalek", "p256", "p384", diff --git a/Cargo.toml b/Cargo.toml index aefccd1..90f80f5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,9 +18,10 @@ hkdf = "0.12" hmac = "0.12" portable-pty = "0.8" rand = "0.8" +rpassword = "7.5.4" serde = { version = "1.0", features = ["derive"] } 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"] } toml = "0.8" vt100 = "0.15" diff --git a/src/bin/dosh-client.rs b/src/bin/dosh-client.rs index 35d0e6d..5a1a8e6 100644 --- a/src/bin/dosh-client.rs +++ b/src/bin/dosh-client.rs @@ -10,8 +10,9 @@ use dosh::config::{expand_tilde, load_client_config, load_hosts_config, load_ser use dosh::crypto; use dosh::native::{ KnownHostStatus, TrustResult, derive_native_session_key, generate_native_ephemeral, - host_fingerprint, load_ed25519_identity, parse_host_public_key_line, remove_trusted_host, - sign_user_auth, trust_host, verify_known_host, verify_server_hello, + host_fingerprint, load_ed25519_identity, load_ed25519_identity_with_passphrase, + parse_host_public_key_line, remove_trusted_host, sign_user_auth, trust_host, verify_known_host, + verify_server_hello, }; use dosh::protocol::{ self, AttachOk, AttachReject, BootstrapAttachRequest, CLIENT_TO_SERVER, Frame, Input, @@ -991,19 +992,33 @@ fn load_first_native_identity( cli_identity: Option<&Path>, ssh_config: &SshConfig, ) -> Result { + load_first_native_identity_with_prompt( + config, + cli_identity, + ssh_config, + prompt_identity_passphrase, + ) +} + +fn load_first_native_identity_with_prompt( + config: &dosh::config::ClientConfig, + cli_identity: Option<&Path>, + ssh_config: &SshConfig, + mut prompt: F, +) -> Result +where + F: FnMut(&Path) -> Result, +{ let mut paths = Vec::new(); 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(); for path in paths { @@ -1012,6 +1027,14 @@ fn load_first_native_identity( } match load_ed25519_identity(&path) { 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())), } } @@ -1025,6 +1048,17 @@ fn load_first_native_identity( } } +fn push_identity_path(paths: &mut Vec, path: PathBuf) { + if !paths.iter().any(|existing| existing == &path) { + paths.push(path); + } +} + +fn prompt_identity_passphrase(path: &Path) -> Result { + rpassword::prompt_password(format!("Enter passphrase for {}: ", path.display())) + .with_context(|| format!("read passphrase for {}", path.display())) +} + async fn bootstrap_attach( socket: &UdpSocket, server_name: &str, @@ -1729,10 +1763,13 @@ const TERMINAL_CLEANUP: &[u8] = concat!( #[cfg(test)] mod tests { use super::{ - FrameBuffer, Predictor, auth_allows, parse_ssh_config, raw_contains_host_table, - ssh_destination_host, ssh_username, startup_command, toml_bare_key_or_quoted, + FrameBuffer, Predictor, SshConfig, auth_allows, load_first_native_identity_with_prompt, + 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 ed25519_dalek::{SigningKey, VerifyingKey}; #[test] fn parses_ssh_config_hostname() { @@ -1801,6 +1838,57 @@ mod tests { 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 { Frame { session: "test".to_string(), diff --git a/src/native.rs b/src/native.rs index 227898f..981af2e 100644 --- a/src/native.rs +++ b/src/native.rs @@ -358,13 +358,24 @@ pub fn derive_native_session_key( } pub fn load_ed25519_identity(path: &Path) -> Result { + load_ed25519_identity_with_passphrase(path, None) +} + +pub fn load_ed25519_identity_with_passphrase( + path: &Path, + passphrase: Option<&str>, +) -> Result { let private_key = ssh_key::PrivateKey::read_openssh_file(path) .with_context(|| format!("read OpenSSH identity {}", path.display()))?; - anyhow::ensure!( - !private_key.is_encrypted(), - "OpenSSH identity {} is encrypted; encrypted-key prompts are not wired yet", - path.display() - ); + let private_key = if private_key.is_encrypted() { + let passphrase = passphrase + .ok_or_else(|| anyhow::anyhow!("OpenSSH identity {} is encrypted", path.display()))?; + private_key + .decrypt(passphrase) + .with_context(|| format!("decrypt OpenSSH identity {}", path.display()))? + } else { + private_key + }; let ed25519 = private_key .key_data() .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] fn native_session_key_derivation_matches_and_binds_transcript() { let host_signing = SigningKey::from_bytes(&[3u8; 32]);