Support encrypted native identity keys
This commit is contained in:
Generated
+141
@@ -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
@@ -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
@@ -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
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user