Skip to main content

coven_ssh/
key.rs

1// ABOUTME: SSH key loading and generation utilities.
2// ABOUTME: Handles ed25519 key pair creation and persistence to filesystem.
3
4use crate::error::{Result, SshError};
5use ssh_key::{Algorithm, LineEnding, PrivateKey};
6use std::path::{Path, PathBuf};
7
8/// Get XDG-style config directory (~/.config/coven).
9///
10/// Uses `XDG_CONFIG_HOME` if set, otherwise falls back to `~/.config`.
11pub fn xdg_config_dir() -> Option<PathBuf> {
12    std::env::var_os("XDG_CONFIG_HOME")
13        .map(PathBuf::from)
14        .or_else(|| dirs::home_dir().map(|h| h.join(".config")))
15        .map(|p| p.join("coven"))
16}
17
18/// Get the default SSH key path for coven-agent (~/.config/coven/agent_key).
19pub fn default_agent_key_path() -> Option<PathBuf> {
20    xdg_config_dir().map(|p| p.join("agent_key"))
21}
22
23/// Get the default SSH key path for coven-tui/clients (~/.config/coven/client_key).
24pub fn default_client_key_path() -> Option<PathBuf> {
25    xdg_config_dir().map(|p| p.join("client_key"))
26}
27
28/// Get the default SSH key path for coven-swarm (~/.config/coven/coven-swarm/agent_key).
29pub fn default_swarm_key_path() -> Option<PathBuf> {
30    xdg_config_dir().map(|p| p.join("coven-swarm").join("agent_key"))
31}
32
33/// Load an existing SSH private key from disk.
34///
35/// # Errors
36/// Returns an error if the file cannot be read or parsed.
37pub fn load_key(key_path: &Path) -> Result<PrivateKey> {
38    let key_data = std::fs::read_to_string(key_path).map_err(|e| SshError::ReadKey {
39        path: key_path.to_path_buf(),
40        source: e,
41    })?;
42
43    PrivateKey::from_openssh(&key_data).map_err(|e| SshError::ParseKey {
44        path: key_path.to_path_buf(),
45        source: e,
46    })
47}
48
49/// Generate a new ed25519 SSH key pair and save to disk.
50///
51/// Creates the parent directory if needed. Sets Unix permissions to 0600
52/// on the private key. Also writes the public key with `.pub` extension.
53///
54/// # Errors
55/// Returns an error if directory creation, key generation, or file writing fails.
56pub fn generate_key(key_path: &Path) -> Result<PrivateKey> {
57    eprintln!("Generating new SSH key at {}...", key_path.display());
58
59    // Ensure parent directory exists
60    if let Some(parent) = key_path.parent() {
61        std::fs::create_dir_all(parent).map_err(|e| SshError::CreateDirectory {
62            path: parent.to_path_buf(),
63            source: e,
64        })?;
65    }
66
67    // Generate ed25519 key
68    let private_key = PrivateKey::random(&mut rand::thread_rng(), Algorithm::Ed25519)
69        .map_err(SshError::GenerateKey)?;
70
71    // Write private key in OpenSSH format
72    let private_key_str = private_key
73        .to_openssh(LineEnding::LF)
74        .map_err(SshError::SerializeKey)?;
75
76    std::fs::write(key_path, private_key_str.as_bytes()).map_err(|e| SshError::WriteKey {
77        path: key_path.to_path_buf(),
78        source: e,
79    })?;
80
81    // Set restrictive permissions on Unix (0600 = rw-------)
82    #[cfg(unix)]
83    {
84        use std::os::unix::fs::PermissionsExt;
85        std::fs::set_permissions(key_path, std::fs::Permissions::from_mode(0o600)).map_err(
86            |e| SshError::SetPermissions {
87                path: key_path.to_path_buf(),
88                source: e,
89            },
90        )?;
91    }
92
93    // Write public key with .pub extension
94    let pub_key_path = key_path.with_extension("pub");
95    let public_key = private_key.public_key();
96    let public_key_str = public_key.to_openssh().map_err(SshError::SerializeKey)?;
97
98    std::fs::write(&pub_key_path, public_key_str.as_bytes()).map_err(|e| SshError::WriteKey {
99        path: pub_key_path.clone(),
100        source: e,
101    })?;
102
103    eprintln!("SSH key generated!");
104    eprintln!("  Private: {}", key_path.display());
105    eprintln!("  Public:  {}", pub_key_path.display());
106
107    Ok(private_key)
108}
109
110/// Load an existing SSH key or generate a new one if it doesn't exist.
111///
112/// This is the primary entry point for obtaining an SSH key. If the key file
113/// exists, it will be loaded. Otherwise, a new ed25519 key pair is generated.
114///
115/// # Errors
116/// Returns an error if key loading fails (for existing keys) or generation fails.
117pub fn load_or_generate_key(key_path: &Path) -> Result<PrivateKey> {
118    if key_path.exists() {
119        load_key(key_path)
120    } else {
121        generate_key(key_path)
122    }
123}
124
125#[cfg(test)]
126mod tests {
127    use super::*;
128    use tempfile::TempDir;
129
130    #[test]
131    fn test_xdg_config_dir_returns_some() {
132        // Should return Some path on most systems
133        let dir = xdg_config_dir();
134        assert!(dir.is_some() || std::env::var_os("HOME").is_none());
135    }
136
137    #[test]
138    fn test_default_agent_key_path_ends_with_agent_key() {
139        if let Some(path) = default_agent_key_path() {
140            assert!(path.ends_with("agent_key"));
141            assert!(path.to_string_lossy().contains("coven"));
142        }
143    }
144
145    #[test]
146    fn test_default_swarm_key_path_ends_with_agent_key() {
147        if let Some(path) = default_swarm_key_path() {
148            assert!(path.ends_with("agent_key"));
149            assert!(path.to_string_lossy().contains("coven-swarm"));
150        }
151    }
152
153    #[test]
154    fn test_generate_and_load_key() {
155        let temp_dir = TempDir::new().expect("should create temp dir");
156        let key_path = temp_dir.path().join("test_key");
157
158        // Generate key
159        let generated = generate_key(&key_path).expect("should generate key");
160
161        // Verify files exist
162        assert!(key_path.exists(), "private key should exist");
163        assert!(
164            key_path.with_extension("pub").exists(),
165            "public key should exist"
166        );
167
168        // Load key
169        let loaded = load_key(&key_path).expect("should load key");
170
171        // Verify same key
172        assert_eq!(
173            generated.public_key().to_openssh().unwrap(),
174            loaded.public_key().to_openssh().unwrap(),
175            "loaded key should match generated key"
176        );
177    }
178
179    #[test]
180    fn test_load_or_generate_generates_when_missing() {
181        let temp_dir = TempDir::new().expect("should create temp dir");
182        let key_path = temp_dir.path().join("new_key");
183
184        assert!(!key_path.exists(), "key should not exist initially");
185
186        let key = load_or_generate_key(&key_path).expect("should generate key");
187        assert!(key_path.exists(), "key should exist after generation");
188        assert!(key.public_key().key_data().is_ed25519());
189    }
190
191    #[test]
192    fn test_load_or_generate_loads_when_exists() {
193        let temp_dir = TempDir::new().expect("should create temp dir");
194        let key_path = temp_dir.path().join("existing_key");
195
196        // Generate first
197        let original = generate_key(&key_path).expect("should generate key");
198
199        // Load via load_or_generate
200        let loaded = load_or_generate_key(&key_path).expect("should load key");
201
202        assert_eq!(
203            original.public_key().to_openssh().unwrap(),
204            loaded.public_key().to_openssh().unwrap(),
205            "should load existing key, not generate new"
206        );
207    }
208
209    #[test]
210    fn test_generated_key_is_ed25519() {
211        let temp_dir = TempDir::new().expect("should create temp dir");
212        let key_path = temp_dir.path().join("ed25519_key");
213
214        let key = generate_key(&key_path).expect("should generate key");
215        assert!(key.public_key().key_data().is_ed25519());
216    }
217
218    #[cfg(unix)]
219    #[test]
220    fn test_private_key_has_restrictive_permissions() {
221        use std::os::unix::fs::PermissionsExt;
222
223        let temp_dir = TempDir::new().expect("should create temp dir");
224        let key_path = temp_dir.path().join("secure_key");
225
226        generate_key(&key_path).expect("should generate key");
227
228        let metadata = std::fs::metadata(&key_path).expect("should read metadata");
229        let mode = metadata.permissions().mode() & 0o777;
230        assert_eq!(mode, 0o600, "private key should have 0600 permissions");
231    }
232
233    #[test]
234    fn test_load_key_file_not_found() {
235        let temp_dir = TempDir::new().expect("should create temp dir");
236        let nonexistent_path = temp_dir.path().join("nonexistent_key");
237
238        let result = load_key(&nonexistent_path);
239        assert!(result.is_err());
240
241        let err = result.unwrap_err();
242        assert!(matches!(err, crate::error::SshError::ReadKey { .. }));
243    }
244
245    #[test]
246    fn test_load_key_invalid_format() {
247        let temp_dir = TempDir::new().expect("should create temp dir");
248        let invalid_key_path = temp_dir.path().join("invalid_key");
249
250        // Write invalid content
251        std::fs::write(&invalid_key_path, "not a valid ssh key").expect("should write file");
252
253        let result = load_key(&invalid_key_path);
254        assert!(result.is_err());
255
256        let err = result.unwrap_err();
257        assert!(matches!(err, crate::error::SshError::ParseKey { .. }));
258    }
259
260    #[test]
261    fn test_xdg_config_home_override() {
262        // Save original value
263        let original = std::env::var_os("XDG_CONFIG_HOME");
264
265        // Set custom XDG_CONFIG_HOME
266        std::env::set_var("XDG_CONFIG_HOME", "/custom/config");
267
268        let dir = xdg_config_dir();
269        assert!(dir.is_some());
270        assert_eq!(dir.unwrap(), PathBuf::from("/custom/config/coven"));
271
272        // Restore original value
273        match original {
274            Some(val) => std::env::set_var("XDG_CONFIG_HOME", val),
275            None => std::env::remove_var("XDG_CONFIG_HOME"),
276        }
277    }
278}