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
+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(),