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",
]
[[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",
+2 -1
View File
@@ -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"
+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::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<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();
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<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(
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(),
+42 -5
View File
@@ -358,13 +358,24 @@ pub fn derive_native_session_key(
}
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)
.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]);