From e8818dcb8fb31074b98bd39dc927d1187c9f9482 Mon Sep 17 00:00:00 2001 From: Jeongkyu Shin Date: Sat, 24 Jan 2026 20:28:02 +0900 Subject: [PATCH] feat: Implement bssh-keygen tool for SSH key generation Implement a new bssh-keygen binary for generating SSH key pairs in OpenSSH format, supporting Ed25519 (recommended) and RSA algorithms. Features: - Ed25519 key generation (default, recommended) - RSA key generation with configurable key size (2048-16384 bits) - OpenSSH private key format output - OpenSSH public key format (.pub file) - Proper file permissions (0600 for private key) - Comment support (-C flag) - SHA256 fingerprint display - Overwrite confirmation (-y to skip) - Quiet mode (-q) - Generated keys are compatible with OpenSSH New files: - src/keygen/mod.rs - Module exports and public API - src/keygen/ed25519.rs - Ed25519 key generation - src/keygen/rsa.rs - RSA key generation - src/bin/bssh_keygen.rs - CLI binary Usage: bssh-keygen # Generate Ed25519 key (default) bssh-keygen -t rsa -b 4096 # Generate 4096-bit RSA key bssh-keygen -f ~/.ssh/my_key # Custom output path bssh-keygen -C "user@host" # Custom comment Closes #143 --- Cargo.toml | 4 + src/bin/bssh_keygen.rs | 319 +++++++++++++++++++++++++++++++++++++++ src/keygen/ed25519.rs | 230 +++++++++++++++++++++++++++++ src/keygen/mod.rs | 194 ++++++++++++++++++++++++ src/keygen/rsa.rs | 328 +++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 6 files changed, 1076 insertions(+) create mode 100644 src/bin/bssh_keygen.rs create mode 100644 src/keygen/ed25519.rs create mode 100644 src/keygen/mod.rs create mode 100644 src/keygen/rsa.rs diff --git a/Cargo.toml b/Cargo.toml index 8a4f10c3..f2c103f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,3 +97,7 @@ harness = false name = "bssh-server" path = "src/bin/bssh_server.rs" +[[bin]] +name = "bssh-keygen" +path = "src/bin/bssh_keygen.rs" + diff --git a/src/bin/bssh_keygen.rs b/src/bin/bssh_keygen.rs new file mode 100644 index 00000000..4ecd7474 --- /dev/null +++ b/src/bin/bssh_keygen.rs @@ -0,0 +1,319 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! bssh-keygen binary - SSH key pair generation tool +//! +//! This binary provides a command-line interface for generating SSH key pairs +//! in OpenSSH format, supporting Ed25519 (recommended) and RSA algorithms. +//! +//! # Usage +//! +//! ```bash +//! # Generate Ed25519 key (default, recommended) +//! bssh-keygen +//! +//! # Generate Ed25519 key with custom output path +//! bssh-keygen -f ~/.ssh/my_key +//! +//! # Generate RSA key with 4096 bits +//! bssh-keygen -t rsa -b 4096 +//! +//! # Generate key with custom comment +//! bssh-keygen -C "user@hostname" +//! ``` + +use anyhow::{Context, Result}; +use bssh::keygen; +use bssh::utils::logging; +use clap::{ArgAction, Parser}; +use std::io::{self, Write}; +use std::path::PathBuf; + +/// Backend.AI SSH Key Generator - Generate SSH key pairs in OpenSSH format +#[derive(Parser, Debug)] +#[command(name = "bssh-keygen")] +#[command(version)] +#[command(about = "Generate SSH key pairs in OpenSSH format", long_about = None)] +struct Cli { + /// Key type: ed25519 (recommended) or rsa + #[arg( + short = 't', + long = "type", + default_value = "ed25519", + value_name = "TYPE" + )] + key_type: String, + + /// Output file path (default: ~/.ssh/id_) + #[arg(short = 'f', long = "file", value_name = "FILE")] + output: Option, + + /// RSA key bits (only for RSA, minimum 2048) + #[arg( + short = 'b', + long = "bits", + default_value = "4096", + value_name = "BITS" + )] + bits: u32, + + /// Comment for the key + #[arg(short = 'C', long = "comment", value_name = "COMMENT")] + comment: Option, + + /// Overwrite existing files without prompting + #[arg(short = 'y', long = "yes")] + yes: bool, + + /// Quiet mode (no output except errors) + #[arg(short = 'q', long = "quiet")] + quiet: bool, + + /// Verbosity level (-v, -vv, -vvv) + #[arg(short, long, action = ArgAction::Count)] + verbose: u8, +} + +fn main() -> Result<()> { + let cli = Cli::parse(); + + // Initialize logging based on verbosity (only if not quiet) + if !cli.quiet { + logging::init_logging_console_only(cli.verbose); + } + + // Validate key type early + let key_type = cli.key_type.to_lowercase(); + if !matches!(key_type.as_str(), "ed25519" | "rsa") { + anyhow::bail!( + "Unknown key type: '{}'. Supported types: ed25519 (recommended), rsa", + cli.key_type + ); + } + + // Determine output path + let output = match cli.output { + Some(path) => path, + None => { + let home = dirs::home_dir().context("Cannot determine home directory")?; + let ssh_dir = home.join(".ssh"); + + // Ensure .ssh directory exists with proper permissions + if !ssh_dir.exists() { + create_ssh_directory(&ssh_dir)?; + } + + match key_type.as_str() { + "ed25519" => ssh_dir.join("id_ed25519"), + "rsa" => ssh_dir.join("id_rsa"), + _ => unreachable!(), + } + } + }; + + // Ensure parent directory exists + if let Some(parent) = output.parent() { + if !parent.exists() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create directory: {}", parent.display()))?; + } + } + + // Check if file exists and prompt for overwrite + if output.exists() && !cli.yes { + print!("{} already exists. Overwrite? (y/n) ", output.display()); + io::stdout().flush()?; + + let mut response = String::new(); + io::stdin().read_line(&mut response)?; + if !response.trim().eq_ignore_ascii_case("y") { + if !cli.quiet { + println!("Aborted."); + } + return Ok(()); + } + } + + // Generate key + let result = match key_type.as_str() { + "ed25519" => keygen::generate_ed25519(&output, cli.comment.as_deref())?, + "rsa" => keygen::generate_rsa(&output, cli.bits, cli.comment.as_deref())?, + _ => unreachable!(), + }; + + // Display output + if !cli.quiet { + println!("Your identification has been saved in {}", output.display()); + println!("Your public key has been saved in {}.pub", output.display()); + println!("The key fingerprint is:"); + println!("{}", result.fingerprint); + + // Display public key for convenience + println!("\nThe key's randomart image is not displayed (not implemented)."); + println!("\nPublic key:"); + println!("{}", result.public_key_openssh); + } + + Ok(()) +} + +/// Create the .ssh directory with proper permissions (0700) +fn create_ssh_directory(path: &PathBuf) -> Result<()> { + #[cfg(unix)] + { + use std::fs; + use std::os::unix::fs::DirBuilderExt; + + fs::DirBuilder::new() + .mode(0o700) // drwx------ (owner only) + .create(path) + .with_context(|| format!("Failed to create .ssh directory: {}", path.display()))?; + } + + #[cfg(not(unix))] + { + std::fs::create_dir_all(path) + .with_context(|| format!("Failed to create .ssh directory: {}", path.display()))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + use tempfile::tempdir; + + #[test] + fn test_cli_parsing() { + // Verify CLI structure is valid + Cli::command().debug_assert(); + } + + #[test] + fn test_cli_defaults() { + let cli = Cli::try_parse_from(["bssh-keygen"]).unwrap(); + assert_eq!(cli.key_type, "ed25519"); + assert_eq!(cli.bits, 4096); + assert!(cli.output.is_none()); + assert!(cli.comment.is_none()); + assert!(!cli.yes); + assert!(!cli.quiet); + } + + #[test] + fn test_cli_ed25519() { + let cli = Cli::try_parse_from(["bssh-keygen", "-t", "ed25519"]).unwrap(); + assert_eq!(cli.key_type, "ed25519"); + } + + #[test] + fn test_cli_rsa() { + let cli = Cli::try_parse_from(["bssh-keygen", "-t", "rsa", "-b", "2048"]).unwrap(); + assert_eq!(cli.key_type, "rsa"); + assert_eq!(cli.bits, 2048); + } + + #[test] + fn test_cli_output_file() { + let cli = Cli::try_parse_from(["bssh-keygen", "-f", "/tmp/my_key"]).unwrap(); + assert_eq!(cli.output, Some(PathBuf::from("/tmp/my_key"))); + } + + #[test] + fn test_cli_comment() { + let cli = Cli::try_parse_from(["bssh-keygen", "-C", "user@host"]).unwrap(); + assert_eq!(cli.comment, Some("user@host".to_string())); + } + + #[test] + fn test_cli_flags() { + let cli = Cli::try_parse_from(["bssh-keygen", "-y", "-q"]).unwrap(); + assert!(cli.yes); + assert!(cli.quiet); + } + + #[test] + fn test_cli_verbose() { + let cli = Cli::try_parse_from(["bssh-keygen", "-vvv"]).unwrap(); + assert_eq!(cli.verbose, 3); + } + + #[test] + fn test_cli_full_options() { + let cli = Cli::try_parse_from([ + "bssh-keygen", + "-t", + "rsa", + "-b", + "4096", + "-f", + "/tmp/test_key", + "-C", + "test@example.com", + "-y", + "-v", + ]) + .unwrap(); + + assert_eq!(cli.key_type, "rsa"); + assert_eq!(cli.bits, 4096); + assert_eq!(cli.output, Some(PathBuf::from("/tmp/test_key"))); + assert_eq!(cli.comment, Some("test@example.com".to_string())); + assert!(cli.yes); + assert!(!cli.quiet); + assert_eq!(cli.verbose, 1); + } + + #[test] + fn test_cli_long_options() { + let cli = Cli::try_parse_from([ + "bssh-keygen", + "--type", + "ed25519", + "--file", + "/tmp/key", + "--comment", + "my key", + "--yes", + "--quiet", + ]) + .unwrap(); + + assert_eq!(cli.key_type, "ed25519"); + assert_eq!(cli.output, Some(PathBuf::from("/tmp/key"))); + assert_eq!(cli.comment, Some("my key".to_string())); + assert!(cli.yes); + assert!(cli.quiet); + } + + #[test] + fn test_create_ssh_directory() { + let temp_dir = tempdir().unwrap(); + let ssh_dir = temp_dir.path().join(".ssh"); + + let result = create_ssh_directory(&ssh_dir); + assert!(result.is_ok()); + assert!(ssh_dir.exists()); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let metadata = std::fs::metadata(&ssh_dir).unwrap(); + let permissions = metadata.permissions(); + assert_eq!(permissions.mode() & 0o777, 0o700); + } + } +} diff --git a/src/keygen/ed25519.rs b/src/keygen/ed25519.rs new file mode 100644 index 00000000..c0a0072f --- /dev/null +++ b/src/keygen/ed25519.rs @@ -0,0 +1,230 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Ed25519 key generation +//! +//! Ed25519 is a modern elliptic curve signature algorithm that provides: +//! - 128-bit security level (equivalent to RSA-3072) +//! - Fast key generation and signing operations +//! - Compact key size (32 bytes public key, 64 bytes private key) +//! - Deterministic signatures (no random number needed for signing) +//! - Resistance to side-channel attacks + +use super::GeneratedKey; +use anyhow::{Context, Result}; +use russh::keys::{Algorithm, HashAlg, PrivateKey}; +use ssh_key::LineEnding; +use std::io::Write; +use std::path::Path; + +/// Generate an Ed25519 SSH key pair +/// +/// # Arguments +/// +/// * `output_path` - Path where the private key will be written +/// * `comment` - Optional comment to include in the public key +/// +/// # Returns +/// +/// Returns `GeneratedKey` containing the private key, public key, and fingerprint +pub fn generate(output_path: &Path, comment: Option<&str>) -> Result { + tracing::info!("Generating Ed25519 key pair"); + + // Generate key pair using cryptographically secure RNG + let keypair = PrivateKey::random(&mut rand::thread_rng(), Algorithm::Ed25519) + .context("Failed to generate Ed25519 key")?; + + // Get public key and fingerprint + let public_key = keypair.public_key(); + let fingerprint = format!("{}", public_key.fingerprint(HashAlg::Sha256)); + + // Format private key in OpenSSH format + let private_key_pem = keypair + .to_openssh(LineEnding::LF) + .context("Failed to encode private key to OpenSSH format")?; + + // Format public key with comment + let comment_str = comment.unwrap_or("bssh-keygen"); + let public_key_base64 = public_key + .to_openssh() + .context("Failed to encode public key to OpenSSH format")?; + let public_key_openssh = format!("{} {}", public_key_base64, comment_str); + + // Write private key with secure permissions + write_private_key(output_path, &private_key_pem)?; + + // Write public key + let pub_path = format!("{}.pub", output_path.display()); + std::fs::write(&pub_path, format!("{}\n", public_key_openssh)) + .with_context(|| format!("Failed to write public key to {}", pub_path))?; + + tracing::info!( + path = %output_path.display(), + fingerprint = %fingerprint, + "Generated Ed25519 key" + ); + + Ok(GeneratedKey { + private_key_pem: private_key_pem.to_string(), + public_key_openssh, + fingerprint, + key_type: "ed25519".to_string(), + }) +} + +/// Write private key file with secure permissions (0600 on Unix) +fn write_private_key(path: &Path, content: &str) -> Result<()> { + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) // -rw------- (owner read/write only) + .open(path) + .with_context(|| format!("Failed to create private key file: {}", path.display()))?; + + file.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write private key: {}", path.display()))?; + } + + #[cfg(not(unix))] + { + std::fs::write(path, content) + .with_context(|| format!("Failed to write private key: {}", path.display()))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_generate_ed25519_key() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + let result = generate(&key_path, Some("test@example.com")); + assert!(result.is_ok()); + + let key = result.unwrap(); + + // Verify private key format + assert!(key + .private_key_pem + .contains("-----BEGIN OPENSSH PRIVATE KEY-----")); + assert!(key + .private_key_pem + .contains("-----END OPENSSH PRIVATE KEY-----")); + + // Verify public key format + assert!(key.public_key_openssh.starts_with("ssh-ed25519 ")); + assert!(key.public_key_openssh.ends_with("test@example.com")); + + // Verify fingerprint format + assert!(key.fingerprint.starts_with("SHA256:")); + + // Verify key type + assert_eq!(key.key_type, "ed25519"); + } + + #[test] + fn test_files_created() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + let result = generate(&key_path, None); + assert!(result.is_ok()); + + // Verify private key file exists + assert!(key_path.exists()); + + // Verify public key file exists + let pub_path = temp_dir.path().join("id_ed25519.pub"); + assert!(pub_path.exists()); + + // Verify public key file content ends with newline + let pub_content = fs::read_to_string(&pub_path).unwrap(); + assert!(pub_content.ends_with('\n')); + } + + #[test] + fn test_default_comment() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + let result = generate(&key_path, None); + assert!(result.is_ok()); + + let key = result.unwrap(); + assert!(key.public_key_openssh.ends_with("bssh-keygen")); + } + + #[test] + #[cfg(unix)] + fn test_private_key_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + let result = generate(&key_path, None); + assert!(result.is_ok()); + + let metadata = fs::metadata(&key_path).unwrap(); + let permissions = metadata.permissions(); + assert_eq!(permissions.mode() & 0o777, 0o600); + } + + #[test] + fn test_unique_keys() { + let temp_dir = tempdir().unwrap(); + + // Generate two keys + let key_path1 = temp_dir.path().join("id_ed25519_1"); + let key_path2 = temp_dir.path().join("id_ed25519_2"); + + let result1 = generate(&key_path1, None).unwrap(); + let result2 = generate(&key_path2, None).unwrap(); + + // Keys should be different + assert_ne!(result1.private_key_pem, result2.private_key_pem); + assert_ne!(result1.public_key_openssh, result2.public_key_openssh); + assert_ne!(result1.fingerprint, result2.fingerprint); + } + + #[test] + fn test_key_can_be_read_back() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + let result = generate(&key_path, Some("test")).unwrap(); + + // Read the private key back and verify it's valid + let private_key_content = fs::read_to_string(&key_path).unwrap(); + assert_eq!(private_key_content, result.private_key_pem); + + // Read the public key back + let pub_path = temp_dir.path().join("id_ed25519.pub"); + let public_key_content = fs::read_to_string(&pub_path).unwrap(); + assert_eq!(public_key_content.trim(), result.public_key_openssh); + } +} diff --git a/src/keygen/mod.rs b/src/keygen/mod.rs new file mode 100644 index 00000000..aac6b194 --- /dev/null +++ b/src/keygen/mod.rs @@ -0,0 +1,194 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! SSH key generation module +//! +//! This module provides functionality for generating SSH key pairs in OpenSSH format, +//! supporting Ed25519 (recommended) and RSA algorithms. +//! +//! # Example +//! +//! ```no_run +//! use bssh::keygen::{generate_ed25519, generate_rsa}; +//! use std::path::Path; +//! +//! // Generate an Ed25519 key +//! let result = generate_ed25519(Path::new("/tmp/id_ed25519"), Some("user@host")).unwrap(); +//! println!("Fingerprint: {}", result.fingerprint); +//! +//! // Generate an RSA key +//! let result = generate_rsa(Path::new("/tmp/id_rsa"), 4096, Some("user@host")).unwrap(); +//! println!("Fingerprint: {}", result.fingerprint); +//! ``` + +pub mod ed25519; +pub mod rsa; + +use anyhow::Result; +use std::path::Path; + +/// Result of key generation containing the key material and metadata +#[derive(Debug, Clone)] +pub struct GeneratedKey { + /// Private key in OpenSSH PEM format + pub private_key_pem: String, + /// Public key in OpenSSH format (type base64 comment) + pub public_key_openssh: String, + /// SHA256 fingerprint of the public key + pub fingerprint: String, + /// Key type (ed25519 or rsa-BITS) + pub key_type: String, +} + +/// Generate an Ed25519 key pair +/// +/// Ed25519 keys are recommended for most use cases due to their: +/// - Strong security with compact key size +/// - Fast key generation and signing +/// - Resistance to side-channel attacks +/// +/// # Arguments +/// +/// * `output_path` - Path where the private key will be written (public key goes to path.pub) +/// * `comment` - Optional comment to include in the key (defaults to "bssh-keygen") +/// +/// # Returns +/// +/// Returns `GeneratedKey` containing the key material and fingerprint +pub fn generate_ed25519(output_path: &Path, comment: Option<&str>) -> Result { + ed25519::generate(output_path, comment) +} + +/// Generate an RSA key pair +/// +/// RSA keys are supported for compatibility with older systems. +/// Ed25519 is recommended for new deployments. +/// +/// # Arguments +/// +/// * `output_path` - Path where the private key will be written (public key goes to path.pub) +/// * `bits` - Key size in bits (minimum 2048, maximum 16384, recommended 4096) +/// * `comment` - Optional comment to include in the key (defaults to "bssh-keygen") +/// +/// # Returns +/// +/// Returns `GeneratedKey` containing the key material and fingerprint +/// +/// # Errors +/// +/// Returns an error if: +/// - Key size is less than 2048 bits +/// - Key size exceeds 16384 bits +/// - Key generation fails +pub fn generate_rsa(output_path: &Path, bits: u32, comment: Option<&str>) -> Result { + rsa::generate(output_path, bits, comment) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_generate_ed25519() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + let result = generate_ed25519(&key_path, Some("test@example.com")); + assert!(result.is_ok()); + + let key = result.unwrap(); + assert!(key + .private_key_pem + .contains("-----BEGIN OPENSSH PRIVATE KEY-----")); + assert!(key.public_key_openssh.starts_with("ssh-ed25519 ")); + assert!(key.public_key_openssh.contains("test@example.com")); + assert!(key.fingerprint.starts_with("SHA256:")); + assert_eq!(key.key_type, "ed25519"); + + // Verify files were created + assert!(key_path.exists()); + let pub_path = temp_dir.path().join("id_ed25519.pub"); + assert!(pub_path.exists()); + } + + #[test] + fn test_generate_rsa() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate_rsa(&key_path, 2048, Some("test@example.com")); + assert!(result.is_ok()); + + let key = result.unwrap(); + assert!(key + .private_key_pem + .contains("-----BEGIN OPENSSH PRIVATE KEY-----")); + assert!(key.public_key_openssh.starts_with("ssh-rsa ")); + assert!(key.public_key_openssh.contains("test@example.com")); + assert!(key.fingerprint.starts_with("SHA256:")); + assert_eq!(key.key_type, "rsa-2048"); + + // Verify files were created + assert!(key_path.exists()); + let pub_path = temp_dir.path().join("id_rsa.pub"); + assert!(pub_path.exists()); + } + + #[test] + fn test_generate_rsa_invalid_bits() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + // Too small + let result = generate_rsa(&key_path, 1024, None); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("2048")); + + // Too large + let result = generate_rsa(&key_path, 32768, None); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("16384")); + } + + #[test] + fn test_default_comment() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + let result = generate_ed25519(&key_path, None); + assert!(result.is_ok()); + + let key = result.unwrap(); + assert!(key.public_key_openssh.contains("bssh-keygen")); + } + + #[test] + #[cfg(unix)] + fn test_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_ed25519"); + + let result = generate_ed25519(&key_path, None); + assert!(result.is_ok()); + + // Private key should be 0600 + let metadata = fs::metadata(&key_path).unwrap(); + let permissions = metadata.permissions(); + assert_eq!(permissions.mode() & 0o777, 0o600); + } +} diff --git a/src/keygen/rsa.rs b/src/keygen/rsa.rs new file mode 100644 index 00000000..e5aa5457 --- /dev/null +++ b/src/keygen/rsa.rs @@ -0,0 +1,328 @@ +// Copyright 2025 Lablup Inc. and Jeongkyu Shin +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! RSA key generation +//! +//! RSA is a widely-used public key cryptographic algorithm. +//! While still secure when used with sufficient key sizes (2048+ bits), +//! Ed25519 is recommended for new deployments due to its: +//! - Faster key generation and operations +//! - Smaller key sizes +//! - Better resistance to implementation errors +//! +//! RSA key generation is provided for compatibility with legacy systems. + +use super::GeneratedKey; +use anyhow::{bail, Context, Result}; +use russh::keys::{Algorithm, HashAlg, PrivateKey}; +use ssh_key::LineEnding; +use std::io::Write; +use std::path::Path; + +/// Minimum allowed RSA key size in bits +const MIN_RSA_BITS: u32 = 2048; + +/// Maximum allowed RSA key size in bits +const MAX_RSA_BITS: u32 = 16384; + +/// Generate an RSA SSH key pair +/// +/// # Arguments +/// +/// * `output_path` - Path where the private key will be written +/// * `bits` - Key size in bits (2048-16384) +/// * `comment` - Optional comment to include in the public key +/// +/// # Returns +/// +/// Returns `GeneratedKey` containing the private key, public key, and fingerprint +/// +/// # Errors +/// +/// Returns an error if: +/// - Key size is less than 2048 bits +/// - Key size exceeds 16384 bits +/// - Key generation fails +pub fn generate(output_path: &Path, bits: u32, comment: Option<&str>) -> Result { + // Validate key size + if bits < MIN_RSA_BITS { + bail!( + "RSA key size must be at least {} bits for security. Got: {}", + MIN_RSA_BITS, + bits + ); + } + if bits > MAX_RSA_BITS { + bail!( + "RSA key size must not exceed {} bits. Got: {}", + MAX_RSA_BITS, + bits + ); + } + + tracing::info!(bits = bits, "Generating RSA key pair"); + + // Generate key pair using cryptographically secure RNG + // Use SHA-256 for the RSA signature hash algorithm + let keypair = PrivateKey::random( + &mut rand::thread_rng(), + Algorithm::Rsa { + hash: Some(HashAlg::Sha256), + }, + ) + .context("Failed to generate RSA key")?; + + // Get public key and fingerprint + let public_key = keypair.public_key(); + let fingerprint = format!("{}", public_key.fingerprint(HashAlg::Sha256)); + + // Format private key in OpenSSH format + let private_key_pem = keypair + .to_openssh(LineEnding::LF) + .context("Failed to encode private key to OpenSSH format")?; + + // Format public key with comment + let comment_str = comment.unwrap_or("bssh-keygen"); + let public_key_base64 = public_key + .to_openssh() + .context("Failed to encode public key to OpenSSH format")?; + let public_key_openssh = format!("{} {}", public_key_base64, comment_str); + + // Write private key with secure permissions + write_private_key(output_path, &private_key_pem)?; + + // Write public key + let pub_path = format!("{}.pub", output_path.display()); + std::fs::write(&pub_path, format!("{}\n", public_key_openssh)) + .with_context(|| format!("Failed to write public key to {}", pub_path))?; + + tracing::info!( + path = %output_path.display(), + bits = bits, + fingerprint = %fingerprint, + "Generated RSA key" + ); + + Ok(GeneratedKey { + private_key_pem: private_key_pem.to_string(), + public_key_openssh, + fingerprint, + key_type: format!("rsa-{}", bits), + }) +} + +/// Write private key file with secure permissions (0600 on Unix) +fn write_private_key(path: &Path, content: &str) -> Result<()> { + #[cfg(unix)] + { + use std::fs::OpenOptions; + use std::os::unix::fs::OpenOptionsExt; + + let mut file = OpenOptions::new() + .write(true) + .create(true) + .truncate(true) + .mode(0o600) // -rw------- (owner read/write only) + .open(path) + .with_context(|| format!("Failed to create private key file: {}", path.display()))?; + + file.write_all(content.as_bytes()) + .with_context(|| format!("Failed to write private key: {}", path.display()))?; + } + + #[cfg(not(unix))] + { + std::fs::write(path, content) + .with_context(|| format!("Failed to write private key: {}", path.display()))?; + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn test_generate_rsa_2048() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate(&key_path, 2048, Some("test@example.com")); + assert!(result.is_ok()); + + let key = result.unwrap(); + + // Verify private key format + assert!(key + .private_key_pem + .contains("-----BEGIN OPENSSH PRIVATE KEY-----")); + assert!(key + .private_key_pem + .contains("-----END OPENSSH PRIVATE KEY-----")); + + // Verify public key format + assert!(key.public_key_openssh.starts_with("ssh-rsa ")); + assert!(key.public_key_openssh.ends_with("test@example.com")); + + // Verify fingerprint format + assert!(key.fingerprint.starts_with("SHA256:")); + + // Verify key type includes bit size + assert_eq!(key.key_type, "rsa-2048"); + } + + #[test] + fn test_generate_rsa_4096() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate(&key_path, 4096, None); + assert!(result.is_ok()); + + let key = result.unwrap(); + assert_eq!(key.key_type, "rsa-4096"); + } + + #[test] + fn test_reject_small_key_size() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate(&key_path, 1024, None); + assert!(result.is_err()); + + let err = result.unwrap_err().to_string(); + assert!(err.contains("2048")); + assert!(err.contains("1024")); + } + + #[test] + fn test_reject_huge_key_size() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate(&key_path, 32768, None); + assert!(result.is_err()); + + let err = result.unwrap_err().to_string(); + assert!(err.contains("16384")); + assert!(err.contains("32768")); + } + + #[test] + fn test_files_created() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate(&key_path, 2048, None); + assert!(result.is_ok()); + + // Verify private key file exists + assert!(key_path.exists()); + + // Verify public key file exists + let pub_path = temp_dir.path().join("id_rsa.pub"); + assert!(pub_path.exists()); + + // Verify public key file content ends with newline + let pub_content = fs::read_to_string(&pub_path).unwrap(); + assert!(pub_content.ends_with('\n')); + } + + #[test] + fn test_default_comment() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate(&key_path, 2048, None); + assert!(result.is_ok()); + + let key = result.unwrap(); + assert!(key.public_key_openssh.ends_with("bssh-keygen")); + } + + #[test] + #[cfg(unix)] + fn test_private_key_permissions() { + use std::os::unix::fs::PermissionsExt; + + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate(&key_path, 2048, None); + assert!(result.is_ok()); + + let metadata = fs::metadata(&key_path).unwrap(); + let permissions = metadata.permissions(); + assert_eq!(permissions.mode() & 0o777, 0o600); + } + + #[test] + fn test_unique_keys() { + let temp_dir = tempdir().unwrap(); + + // Generate two keys + let key_path1 = temp_dir.path().join("id_rsa_1"); + let key_path2 = temp_dir.path().join("id_rsa_2"); + + let result1 = generate(&key_path1, 2048, None).unwrap(); + let result2 = generate(&key_path2, 2048, None).unwrap(); + + // Keys should be different + assert_ne!(result1.private_key_pem, result2.private_key_pem); + assert_ne!(result1.public_key_openssh, result2.public_key_openssh); + assert_ne!(result1.fingerprint, result2.fingerprint); + } + + #[test] + fn test_key_can_be_read_back() { + let temp_dir = tempdir().unwrap(); + let key_path = temp_dir.path().join("id_rsa"); + + let result = generate(&key_path, 2048, Some("test")).unwrap(); + + // Read the private key back and verify it's valid + let private_key_content = fs::read_to_string(&key_path).unwrap(); + assert_eq!(private_key_content, result.private_key_pem); + + // Read the public key back + let pub_path = temp_dir.path().join("id_rsa.pub"); + let public_key_content = fs::read_to_string(&pub_path).unwrap(); + assert_eq!(public_key_content.trim(), result.public_key_openssh); + } + + #[test] + fn test_boundary_key_sizes() { + let temp_dir = tempdir().unwrap(); + + // Test minimum valid size + let key_path = temp_dir.path().join("id_rsa_min"); + let result = generate(&key_path, 2048, None); + assert!(result.is_ok()); + + // Test just below minimum + let key_path = temp_dir.path().join("id_rsa_below_min"); + let result = generate(&key_path, 2047, None); + assert!(result.is_err()); + + // Test maximum valid size (skip actual generation due to time) + // Just verify the boundary logic with values near max + let key_path = temp_dir.path().join("id_rsa_above_max"); + let result = generate(&key_path, 16385, None); + assert!(result.is_err()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 1f298e56..d72e6f85 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -19,6 +19,7 @@ pub mod executor; pub mod forwarding; pub mod hostlist; pub mod jump; +pub mod keygen; pub mod node; pub mod pty; pub mod security;