use base64::Engine;
use crate::get_permissions;
use toride_ssh_core::SshPaths;
use toride_ssh_core::{
CliRunner, Error, Fingerprint, KeyCreateParams, KeyFormat, KeySource, KeyType, Result, SshKey,
};
const MIN_RSA_BITS: u32 = 1024;
const RECOMMENDED_RSA_BITS: u32 = 3072;
const SSH_ADD_TIMEOUT_SECS: u64 = 30;
fn key_type_to_cli_arg(kt: KeyType) -> &'static str {
match kt {
KeyType::Ed25519 => "ed25519",
KeyType::Rsa { .. } => "rsa",
KeyType::EcdsaP256 | KeyType::EcdsaP384 | KeyType::EcdsaP521 => "ecdsa",
KeyType::Dsa => "dsa",
KeyType::SkEd25519 => "sk-ssh-ed25519@openssh.com",
KeyType::SkEcdsaP256 => "sk-ecdsa-sha2-nistp256@openssh.com",
}
}
fn build_keygen_args(params: &KeyCreateParams, private_path_str: &str) -> Vec<String> {
let key_type_str = key_type_to_cli_arg(params.key_type);
let passphrase_nonempty = params.passphrase.as_deref().is_some_and(|p| !p.is_empty());
let mut args: Vec<String> = vec![
"-t".to_owned(),
key_type_str.to_owned(),
"-f".to_owned(),
private_path_str.to_owned(),
];
if !passphrase_nonempty {
args.push("-N".to_owned());
args.push(String::new());
}
if let KeyType::Rsa { bits } = params.key_type
&& bits > 0
{
args.extend(["-b".to_owned(), bits.to_string()]);
}
if params.key_type == KeyType::EcdsaP256 {
args.extend(["-b".to_owned(), "256".to_owned()]);
}
if params.key_type == KeyType::EcdsaP384 {
args.extend(["-b".to_owned(), "384".to_owned()]);
}
if params.key_type == KeyType::EcdsaP521 {
args.extend(["-b".to_owned(), "521".to_owned()]);
}
if let Some(ref comment) = params.comment {
args.extend(["-C".to_owned(), comment.clone()]);
}
if let Some(rounds) = params.kdf_rounds {
args.extend(["-a".to_owned(), rounds.to_string()]);
}
if params.touch_required {
args.extend(["-O".to_owned(), "touch-required".to_owned()]);
}
if params.verify_required {
args.extend(["-O".to_owned(), "verify-required".to_owned()]);
}
args
}
#[expect(
clippy::too_many_lines,
reason = "orchestrates generation, permissions, agent, config"
)]
pub async fn generate_key(
paths: &SshPaths,
params: KeyCreateParams,
runner: &dyn CliRunner,
) -> Result<SshKey> {
let private_path = paths.ssh_dir().join(¶ms.name);
let public_path = private_path.with_extension("pub");
if private_path.exists() {
return Err(Error::KeyExists(params.name.clone()));
}
if !runner.tool_exists("ssh-keygen") {
return Err(Error::ToolNotFound("ssh-keygen".to_owned()));
}
if let KeyType::Rsa { bits } = params.key_type
&& bits > 0
&& bits < MIN_RSA_BITS
{
return Err(Error::KeyGenerationFailed(format!(
"RSA bit size {bits} is below minimum {MIN_RSA_BITS}"
)));
}
if let KeyType::Rsa { bits } = params.key_type
&& bits > 0
&& bits < RECOMMENDED_RSA_BITS
{
tracing::warn!(
"RSA key size {bits} is below the recommended {RECOMMENDED_RSA_BITS} bits; \
consider using at least {RECOMMENDED_RSA_BITS} bits or switching to Ed25519"
);
}
let passphrase_nonempty = params.passphrase.as_deref().is_some_and(|p| !p.is_empty());
let private_path_str = private_path
.to_str()
.ok_or_else(|| Error::KeyGenerationFailed("invalid key path".to_owned()))?;
let args = build_keygen_args(¶ms, private_path_str);
if passphrase_nonempty {
let askpass =
toride_ssh_agent::AskpassHandler::new(params.passphrase.as_deref().unwrap_or(""))?;
let askpass_env = vec![
(
"SSH_ASKPASS".to_owned(),
askpass.script_path().to_string_lossy().into_owned(),
),
("SSH_ASKPASS_REQUIRE".to_owned(), "force".to_owned()),
("DISPLAY".to_owned(), ":0".to_owned()),
];
runner.run_with_env("ssh-keygen", args, askpass_env).await?;
} else {
runner.run("ssh-keygen", args).await?;
}
let private_path_clone = private_path.clone();
#[cfg(unix)]
let ssh_dir = paths.ssh_dir().to_path_buf();
let result = tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
if let Err(e) =
std::fs::set_permissions(&ssh_dir, std::fs::Permissions::from_mode(0o700))
{
tracing::warn!("failed to set permissions on {}: {e}", ssh_dir.display());
}
if let Err(e) = std::fs::set_permissions(
&private_path_clone,
std::fs::Permissions::from_mode(0o600),
) {
tracing::warn!(
"failed to set permissions on {}: {e}",
private_path_clone.display()
);
}
if let Err(e) =
std::fs::set_permissions(&public_path, std::fs::Permissions::from_mode(0o644))
{
tracing::warn!(
"failed to set permissions on {}: {e}",
public_path.display()
);
}
}
let permissions = get_permissions(&private_path_clone);
let private_key_data = std::fs::read_to_string(&private_path_clone)
.map_err(|e| Error::KeyParseFailed(format!("failed to read generated key: {e}")))?;
let pk = ssh_key::PrivateKey::from_openssh(&private_key_data)
.map_err(|e| Error::KeyParseFailed(format!("failed to parse generated key: {e}")))?;
Ok::<_, Error>((permissions, pk, public_path.exists()))
})
.await
.map_err(|e| Error::TaskFailed(format!("post-generation task failed: {e}")))??;
let (permissions, pk, has_public_pair) = result;
if params.add_to_agent {
add_key_to_agent(&private_path, runner).await?;
}
if params.add_to_config {
let host_alias = params
.config_host
.as_deref()
.unwrap_or(¶ms.name)
.to_owned();
let identity_value = format!("~/.ssh/{}", params.name);
let config_service = toride_ssh_config::ConfigService::new(paths);
config_service
.edit(|ast| {
toride_ssh_config::ConfigService::add_host(
ast,
&host_alias,
vec![
("HostName".to_owned(), host_alias.clone()),
("IdentityFile".to_owned(), identity_value),
],
)
})
.await?;
}
let public_key = pk.public_key();
let fp = public_key.fingerprint(ssh_key::HashAlg::Sha256);
let fingerprint = Some(Fingerprint {
hash: base64::engine::general_purpose::STANDARD_NO_PAD.encode(fp.as_bytes()),
key_type: params.key_type,
});
let comment_str = pk.comment().to_string();
let comment = if comment_str.is_empty() {
None
} else {
Some(comment_str)
};
Ok(SshKey {
path: private_path,
key_type: params.key_type,
fingerprint,
comment,
encrypted: passphrase_nonempty,
source: KeySource::Filesystem,
permissions,
has_public_pair,
has_certificate: false,
last_modified: None,
used_by_hosts: Vec::new(),
key_format: Some(KeyFormat::OpenSSH),
})
}
async fn add_key_to_agent(private_path: &std::path::Path, runner: &dyn CliRunner) -> Result<()> {
let path_str = private_path
.to_str()
.ok_or_else(|| Error::CommandFailed("key path is not valid UTF-8".to_owned()))?
.to_owned();
let args = vec![path_str];
tokio::time::timeout(
std::time::Duration::from_secs(SSH_ADD_TIMEOUT_SECS),
runner.run("ssh-add", args),
)
.await
.map_err(|_| {
Error::CommandFailed(format!(
"ssh-add timed out after {SSH_ADD_TIMEOUT_SECS} seconds"
))
})??;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn key_type_to_cli_arg_all_types() {
assert_eq!(key_type_to_cli_arg(KeyType::Ed25519), "ed25519");
assert_eq!(key_type_to_cli_arg(KeyType::Rsa { bits: 4096 }), "rsa");
assert_eq!(key_type_to_cli_arg(KeyType::EcdsaP256), "ecdsa");
assert_eq!(key_type_to_cli_arg(KeyType::EcdsaP384), "ecdsa");
assert_eq!(key_type_to_cli_arg(KeyType::EcdsaP521), "ecdsa");
assert_eq!(key_type_to_cli_arg(KeyType::Dsa), "dsa");
assert_eq!(
key_type_to_cli_arg(KeyType::SkEd25519),
"sk-ssh-ed25519@openssh.com"
);
assert_eq!(
key_type_to_cli_arg(KeyType::SkEcdsaP256),
"sk-ecdsa-sha2-nistp256@openssh.com"
);
}
#[test]
fn build_keygen_args_ed25519_basic() {
let params = KeyCreateParams {
name: "id_ed25519".to_owned(),
key_type: KeyType::Ed25519,
comment: None,
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/home/user/.ssh/id_ed25519");
assert_eq!(
args[0..4],
["-t", "ed25519", "-f", "/home/user/.ssh/id_ed25519"]
);
assert_eq!(args[4..6], ["-N", ""]);
assert!(!args.contains(&"-b".to_owned()));
assert!(!args.contains(&"-C".to_owned()));
}
#[test]
fn build_keygen_args_rsa_with_bits() {
let params = KeyCreateParams {
name: "id_rsa".to_owned(),
key_type: KeyType::Rsa { bits: 4096 },
comment: None,
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(args.contains(&"-b".to_owned()));
assert!(args.contains(&"4096".to_owned()));
}
#[test]
fn build_keygen_args_with_comment() {
let params = KeyCreateParams {
name: "id_ed25519".to_owned(),
key_type: KeyType::Ed25519,
comment: Some("user@host".to_owned()),
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(args.contains(&"-C".to_owned()));
assert!(args.contains(&"user@host".to_owned()));
}
#[test]
fn build_keygen_args_with_passphrase() {
let params = KeyCreateParams {
name: "id_ed25519".to_owned(),
key_type: KeyType::Ed25519,
comment: None,
passphrase: Some("secret".to_owned()),
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(
!args.contains(&"-N".to_owned()),
"-N must be omitted when a passphrase is requested: {args:?}"
);
assert!(
!args.iter().any(|a| a == "secret"),
"passphrase leaked into ssh-keygen argv: {args:?}"
);
}
#[test]
fn askpass_keygen_is_openssh_compatible() {
let ssh_keygen_present = std::process::Command::new("ssh-keygen")
.arg("--help")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.is_ok();
if !ssh_keygen_present {
eprintln!("skipping: ssh-keygen not available");
return;
}
let dir = tempfile::TempDir::new().expect("temp dir");
let keypath = dir.path().join("askpass_key");
let keypath_str = keypath.to_str().expect("utf-8 path");
let askpass_script = dir.path().join("askpass.sh");
std::fs::write(&askpass_script, "#!/bin/sh\necho testpass\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&askpass_script, std::fs::Permissions::from_mode(0o700))
.unwrap();
}
let generate = std::process::Command::new("ssh-keygen")
.args(["-t", "ed25519", "-f", keypath_str, "-C", "askpass-test"])
.env("SSH_ASKPASS", &askpass_script)
.env("SSH_ASKPASS_REQUIRE", "force")
.env("DISPLAY", ":0")
.stdin(std::process::Stdio::null())
.output()
.expect("spawn ssh-keygen");
assert!(
generate.status.success(),
"ssh-keygen askpass generate failed: {}",
String::from_utf8_lossy(&generate.stderr)
);
let verify = std::process::Command::new("ssh-keygen")
.args(["-y", "-P", "testpass", "-f", keypath_str])
.output()
.expect("spawn ssh-keygen verify");
assert!(
verify.status.success(),
"ssh-keygen could not read askpass-generated key: {}",
String::from_utf8_lossy(&verify.stderr)
);
assert!(
String::from_utf8_lossy(&verify.stdout).starts_with("ssh-ed25519 "),
"unexpected public key output"
);
let wrong = std::process::Command::new("ssh-keygen")
.args(["-y", "-P", "wrongpass", "-f", keypath_str])
.output()
.expect("spawn ssh-keygen wrong");
assert!(
!wrong.status.success(),
"wrong passphrase unexpectedly accepted"
);
}
#[test]
fn build_keygen_args_with_kdf_rounds() {
let params = KeyCreateParams {
name: "id_ed25519".to_owned(),
key_type: KeyType::Ed25519,
comment: None,
passphrase: Some("pass".to_owned()),
kdf_rounds: Some(64),
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(args.contains(&"-a".to_owned()));
assert!(args.contains(&"64".to_owned()));
}
#[test]
fn build_keygen_args_ecdsa_p384() {
let params = KeyCreateParams {
name: "id_ecdsa".to_owned(),
key_type: KeyType::EcdsaP384,
comment: None,
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(args.contains(&"-b".to_owned()));
assert!(args.contains(&"384".to_owned()));
}
#[test]
fn build_keygen_args_ecdsa_p521() {
let params = KeyCreateParams {
name: "id_ecdsa".to_owned(),
key_type: KeyType::EcdsaP521,
comment: None,
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(args.contains(&"-b".to_owned()));
assert!(args.contains(&"521".to_owned()));
}
#[test]
fn build_keygen_args_touch_required() {
let params = KeyCreateParams {
name: "id_sk".to_owned(),
key_type: KeyType::SkEd25519,
comment: None,
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: true,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(args.contains(&"-O".to_owned()));
assert!(args.contains(&"touch-required".to_owned()));
assert!(!args.contains(&"verify-required".to_owned()));
}
#[test]
fn build_keygen_args_verify_required() {
let params = KeyCreateParams {
name: "id_sk".to_owned(),
key_type: KeyType::SkEd25519,
comment: None,
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: true,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(args.contains(&"-O".to_owned()));
assert!(args.contains(&"verify-required".to_owned()));
assert!(!args.contains(&"touch-required".to_owned()));
}
#[test]
fn build_keygen_args_touch_and_verify_required() {
let params = KeyCreateParams {
name: "id_sk".to_owned(),
key_type: KeyType::SkEcdsaP256,
comment: None,
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: true,
verify_required: true,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(args.contains(&"touch-required".to_owned()));
assert!(args.contains(&"verify-required".to_owned()));
}
#[test]
fn build_keygen_args_no_fido_options_when_false() {
let params = KeyCreateParams {
name: "id_ed25519".to_owned(),
key_type: KeyType::Ed25519,
comment: None,
passphrase: None,
kdf_rounds: None,
add_to_agent: false,
add_to_config: false,
config_host: None,
touch_required: false,
verify_required: false,
};
let args = build_keygen_args(¶ms, "/tmp/key");
assert!(!args.contains(&"touch-required".to_owned()));
assert!(!args.contains(&"verify-required".to_owned()));
}
}