Skip to main content

gitway_lib/
keygen.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Rust guideline compliant 2026-04-21
3//! OpenSSH key generation, loading, and fingerprinting.
4//!
5//! Pure-Rust via the [`ssh-key`] crate. Generated keys are written in the
6//! standard OpenSSH private-key format (PEM-armored, PKCS#8-style) and the
7//! accompanying public key in the single-line `authorized_keys` format.
8//!
9//! # Examples
10//!
11//! ```no_run
12//! use std::path::Path;
13//! use gitway_lib::keygen::{KeyType, generate, write_keypair};
14//!
15//! let key = generate(KeyType::Ed25519, None, "user@host").unwrap();
16//! write_keypair(&key, Path::new("/tmp/id_ed25519"), None).unwrap();
17//! ```
18//!
19//! # Errors
20//!
21//! All operations return [`GitwayError`]. Cryptographic failures (RNG,
22//! encryption) and I/O failures are both folded into that type; the caller
23//! distinguishes via the `is_*` predicates.
24//!
25//! # Zeroization
26//!
27//! `ssh_key::PrivateKey` holds its secret scalar inside a type that
28//! zeroes itself on drop. Passphrase material supplied to
29//! [`write_keypair`] and [`change_passphrase`] is passed by reference
30//! wrapped in [`Zeroizing`] so the caller retains ownership of the
31//! zeroization lifecycle.
32
33use std::fs;
34use std::io::Write as _;
35use std::path::{Path, PathBuf};
36
37use rand_core::OsRng;
38use ssh_key::{Algorithm, EcdsaCurve, HashAlg, LineEnding, PrivateKey, PublicKey};
39use zeroize::Zeroizing;
40
41use crate::GitwayError;
42
43// ── Public types ──────────────────────────────────────────────────────────────
44
45/// The set of key algorithms `gitway keygen` can produce.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum KeyType {
48    /// Ed25519 (default; fixed 256-bit).
49    Ed25519,
50    /// ECDSA over NIST P-256.
51    EcdsaP256,
52    /// ECDSA over NIST P-384.
53    EcdsaP384,
54    /// ECDSA over NIST P-521.
55    EcdsaP521,
56    /// RSA. Bit length is selected by the `bits` argument to [`generate`].
57    Rsa,
58}
59
60impl KeyType {
61    /// Returns the canonical textual name used on the `ssh-keygen -t` CLI.
62    #[must_use]
63    pub fn cli_name(self) -> &'static str {
64        match self {
65            Self::Ed25519 => "ed25519",
66            Self::EcdsaP256 | Self::EcdsaP384 | Self::EcdsaP521 => "ecdsa",
67            Self::Rsa => "rsa",
68        }
69    }
70}
71
72// ── Generation ────────────────────────────────────────────────────────────────
73
74/// Generates a new keypair of the requested type.
75///
76/// For ECDSA, the curve is selected by the `KeyType` variant; `bits` is
77/// ignored. For RSA, `bits` defaults to 3072 (the OpenSSH minimum
78/// recommended value as of 2025). Ed25519 always produces a 256-bit key.
79///
80/// # Errors
81///
82/// Returns [`GitwayError::signing`] on RNG failure or on an invalid
83/// `bits` value (for RSA: below 2048 or above 16384).
84pub fn generate(
85    kind: KeyType,
86    bits: Option<u32>,
87    comment: &str,
88) -> Result<PrivateKey, GitwayError> {
89    let algorithm = match kind {
90        KeyType::Ed25519 => Algorithm::Ed25519,
91        KeyType::EcdsaP256 => Algorithm::Ecdsa {
92            curve: EcdsaCurve::NistP256,
93        },
94        KeyType::EcdsaP384 => Algorithm::Ecdsa {
95            curve: EcdsaCurve::NistP384,
96        },
97        KeyType::EcdsaP521 => Algorithm::Ecdsa {
98            curve: EcdsaCurve::NistP521,
99        },
100        KeyType::Rsa => {
101            let b = bits.unwrap_or(DEFAULT_RSA_BITS);
102            if !(MIN_RSA_BITS..=MAX_RSA_BITS).contains(&b) {
103                return Err(GitwayError::invalid_config(format!(
104                    "RSA key size {b} is out of range ({MIN_RSA_BITS}-{MAX_RSA_BITS})"
105                )));
106            }
107            return generate_rsa(b, comment);
108        }
109    };
110
111    let mut rng = OsRng;
112    let mut key = PrivateKey::random(&mut rng, algorithm)
113        .map_err(|e| GitwayError::signing(format!("key generation failed: {e}")))?;
114    key.set_comment(comment);
115    Ok(key)
116}
117
118/// Generates an RSA private key of the requested size.
119fn generate_rsa(bits: u32, comment: &str) -> Result<PrivateKey, GitwayError> {
120    // `ssh_key::PrivateKey::random` does not support RSA directly; build it
121    // via ssh_key::private::RsaKeypair::random and wrap. This path only
122    // compiles with the `rsa` feature on `ssh-key`.
123    let mut rng = OsRng;
124    let usize_bits = usize::try_from(bits)
125        .map_err(|_e| GitwayError::invalid_config(format!("RSA bit count {bits} is too large")))?;
126    let rsa_key = ssh_key::private::RsaKeypair::random(&mut rng, usize_bits)
127        .map_err(|e| GitwayError::signing(format!("RSA key generation failed: {e}")))?;
128    let mut key = PrivateKey::from(rsa_key);
129    key.set_comment(comment);
130    Ok(key)
131}
132
133/// The default RSA modulus size for new keys.
134const DEFAULT_RSA_BITS: u32 = 3072;
135/// Minimum RSA modulus size accepted by `gitway keygen`.
136///
137/// OpenSSH's `ssh-keygen` allows 1024, but NIST SP 800-131A deprecates it and
138/// GitHub's key-upload endpoint rejects it.
139const MIN_RSA_BITS: u32 = 2048;
140/// Upper bound chosen to match OpenSSH's `ssh-keygen` behaviour (16384).
141const MAX_RSA_BITS: u32 = 16384;
142
143// ── Writing ───────────────────────────────────────────────────────────────────
144
145/// Writes a keypair to disk.
146///
147/// Two files are created:
148///
149/// | Path | Contents | Unix mode |
150/// |------|----------|-----------|
151/// | `path` | OpenSSH private key (optionally encrypted) | 0600 |
152/// | `path.pub` | OpenSSH public key (`authorized_keys` line) | 0644 |
153///
154/// If `passphrase` is `Some`, the private key is encrypted before writing.
155/// Passing `Some(empty_string)` is rejected — use `None` for an unencrypted
156/// key.
157///
158/// # Errors
159///
160/// Returns [`GitwayError`] on I/O failure, encryption failure, or when the
161/// output parent directory does not exist.
162pub fn write_keypair(
163    key: &PrivateKey,
164    path: &Path,
165    passphrase: Option<&Zeroizing<String>>,
166) -> Result<(), GitwayError> {
167    let key_to_write = match passphrase {
168        Some(pp) if pp.is_empty() => {
169            return Err(GitwayError::invalid_config(
170                "empty passphrase is not allowed — pass `None` to leave the key unencrypted",
171            ));
172        }
173        Some(pp) => {
174            let mut rng = OsRng;
175            key.encrypt(&mut rng, pp.as_bytes())
176                .map_err(|e| GitwayError::signing(format!("failed to encrypt private key: {e}")))?
177        }
178        None => key.clone(),
179    };
180
181    let private_pem = key_to_write
182        .to_openssh(LineEnding::LF)
183        .map_err(|e| GitwayError::signing(format!("failed to serialize private key: {e}")))?;
184    write_private_file(path, private_pem.as_bytes())?;
185
186    let public = key.public_key();
187    let public_line = public
188        .to_openssh()
189        .map_err(|e| GitwayError::signing(format!("failed to serialize public key: {e}")))?;
190    let pub_path = pub_path_for(path);
191    let mut out = String::with_capacity(public_line.len() + 1);
192    out.push_str(&public_line);
193    out.push('\n');
194    fs::write(&pub_path, out.as_bytes())?;
195    Ok(())
196}
197
198/// Writes the private-key bytes to `path` with a restrictive permission mode.
199///
200/// On Unix the file mode is set to `0o600` (owner read/write only). On other
201/// platforms this is a plain write — file-system access controls are the
202/// user's responsibility.
203#[cfg(unix)]
204fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), GitwayError> {
205    use std::os::unix::fs::OpenOptionsExt as _;
206    let mut f = fs::OpenOptions::new()
207        .create(true)
208        .truncate(true)
209        .write(true)
210        // `mode` is honored only on create; permissions on an existing file
211        // are left alone. The 0o600 constant matches OpenSSH's ssh-keygen.
212        .mode(0o600)
213        .open(path)?;
214    f.write_all(bytes)?;
215    Ok(())
216}
217
218#[cfg(not(unix))]
219fn write_private_file(path: &Path, bytes: &[u8]) -> Result<(), GitwayError> {
220    fs::write(path, bytes)?;
221    Ok(())
222}
223
224/// Returns the companion `.pub` path for a private key at `path`.
225fn pub_path_for(path: &Path) -> PathBuf {
226    let mut os = path.as_os_str().to_owned();
227    os.push(".pub");
228    PathBuf::from(os)
229}
230
231// ── Passphrase management ─────────────────────────────────────────────────────
232
233/// Changes (or adds, or removes) the passphrase on an existing OpenSSH private key.
234///
235/// - `old`: the current passphrase, or `None` if the key is unencrypted.
236/// - `new`: the target passphrase, or `None` to remove encryption.
237///
238/// # Errors
239///
240/// Returns [`GitwayError`] if the old passphrase is wrong, the key cannot be
241/// read, or the new key cannot be written.
242pub fn change_passphrase(
243    path: &Path,
244    old: Option<&Zeroizing<String>>,
245    new: Option<&Zeroizing<String>>,
246) -> Result<(), GitwayError> {
247    let pem = fs::read_to_string(path)?;
248    let loaded = PrivateKey::from_openssh(&pem)
249        .map_err(|e| GitwayError::signing(format!("failed to parse existing key: {e}")))?;
250
251    let decrypted = if loaded.is_encrypted() {
252        let pp = old.ok_or_else(|| {
253            GitwayError::invalid_config(
254                "existing key is encrypted but no old passphrase was provided",
255            )
256        })?;
257        loaded
258            .decrypt(pp.as_bytes())
259            .map_err(|e| GitwayError::signing(format!("old passphrase is wrong: {e}")))?
260    } else {
261        loaded
262    };
263
264    write_keypair(&decrypted, path, new)
265}
266
267// ── Fingerprinting ────────────────────────────────────────────────────────────
268
269/// Returns the OpenSSH-style fingerprint string for a public key.
270///
271/// Uses `SHA256:<base64>` — the format OpenSSH has emitted by default since
272/// version 6.8 (2015).
273#[must_use]
274pub fn fingerprint(public: &PublicKey, hash: HashAlg) -> String {
275    public.fingerprint(hash).to_string()
276}
277
278// ── Public-key extraction ─────────────────────────────────────────────────────
279
280/// Extracts the public key from a private-key file and writes it to `out`.
281///
282/// If `out` is `None`, the public key is written to `<path>.pub`.
283/// Passphrase handling: if the private key is encrypted, `passphrase` must
284/// be supplied. Public-key extraction does not strictly require decryption
285/// (the public part is stored alongside the private), so an unencrypted
286/// `.pub` is still produced.
287///
288/// # Errors
289///
290/// Returns [`GitwayError`] on I/O or parsing failure.
291pub fn extract_public(path: &Path, out: Option<&Path>) -> Result<(), GitwayError> {
292    let pem = fs::read_to_string(path)?;
293    let key = PrivateKey::from_openssh(&pem)
294        .map_err(|e| GitwayError::signing(format!("failed to parse private key: {e}")))?;
295    let public_line = key
296        .public_key()
297        .to_openssh()
298        .map_err(|e| GitwayError::signing(format!("failed to serialize public key: {e}")))?;
299    let target = match out {
300        Some(p) => p.to_owned(),
301        None => pub_path_for(path),
302    };
303    let mut buf = String::with_capacity(public_line.len() + 1);
304    buf.push_str(&public_line);
305    buf.push('\n');
306    fs::write(&target, buf.as_bytes())?;
307    Ok(())
308}
309
310// ── Tests ─────────────────────────────────────────────────────────────────────
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use tempfile::tempdir;
316
317    #[test]
318    fn generate_ed25519_has_expected_algorithm() {
319        let key = generate(KeyType::Ed25519, None, "test").unwrap();
320        assert_eq!(key.algorithm(), Algorithm::Ed25519);
321        assert_eq!(key.comment(), "test");
322    }
323
324    #[test]
325    fn generate_ecdsa_p256_has_expected_curve() {
326        let key = generate(KeyType::EcdsaP256, None, "test").unwrap();
327        assert_eq!(
328            key.algorithm(),
329            Algorithm::Ecdsa {
330                curve: EcdsaCurve::NistP256
331            }
332        );
333    }
334
335    #[test]
336    fn write_and_read_roundtrip_unencrypted() {
337        let dir = tempdir().unwrap();
338        let path = dir.path().join("id_ed25519");
339        let key = generate(KeyType::Ed25519, None, "roundtrip@test").unwrap();
340        write_keypair(&key, &path, None).unwrap();
341
342        let pem = fs::read_to_string(&path).unwrap();
343        let loaded = PrivateKey::from_openssh(&pem).unwrap();
344        assert!(!loaded.is_encrypted());
345        assert_eq!(
346            loaded.public_key().fingerprint(HashAlg::Sha256),
347            key.public_key().fingerprint(HashAlg::Sha256)
348        );
349
350        let pub_path = path.with_extension("pub");
351        assert!(pub_path.exists(), "expected companion .pub file");
352        let pub_content = fs::read_to_string(&pub_path).unwrap();
353        assert!(pub_content.starts_with("ssh-ed25519 "));
354    }
355
356    #[test]
357    fn write_and_read_roundtrip_encrypted() {
358        let dir = tempdir().unwrap();
359        let path = dir.path().join("id_ed25519");
360        let key = generate(KeyType::Ed25519, None, "enc@test").unwrap();
361        let pp = Zeroizing::new(String::from("correcthorse"));
362        write_keypair(&key, &path, Some(&pp)).unwrap();
363
364        let pem = fs::read_to_string(&path).unwrap();
365        let loaded = PrivateKey::from_openssh(&pem).unwrap();
366        assert!(loaded.is_encrypted());
367        let decrypted = loaded.decrypt(pp.as_bytes()).unwrap();
368        assert_eq!(decrypted.comment(), "enc@test");
369    }
370
371    #[test]
372    fn rejects_empty_passphrase() {
373        let dir = tempdir().unwrap();
374        let path = dir.path().join("id_ed25519");
375        let key = generate(KeyType::Ed25519, None, "empty@test").unwrap();
376        let pp = Zeroizing::new(String::new());
377        let err = write_keypair(&key, &path, Some(&pp)).unwrap_err();
378        assert!(err.to_string().contains("empty passphrase"));
379    }
380
381    #[test]
382    fn change_passphrase_roundtrip() {
383        let dir = tempdir().unwrap();
384        let path = dir.path().join("id_ed25519");
385        let key = generate(KeyType::Ed25519, None, "change@test").unwrap();
386        let pp1 = Zeroizing::new(String::from("one"));
387        write_keypair(&key, &path, Some(&pp1)).unwrap();
388
389        let pp2 = Zeroizing::new(String::from("two"));
390        change_passphrase(&path, Some(&pp1), Some(&pp2)).unwrap();
391
392        // Wrong old-passphrase should now fail.
393        let err = change_passphrase(&path, Some(&pp1), Some(&pp2)).unwrap_err();
394        assert!(err.to_string().contains("passphrase"));
395
396        // Right one works.
397        change_passphrase(&path, Some(&pp2), None).unwrap();
398        let pem = fs::read_to_string(&path).unwrap();
399        let loaded = PrivateKey::from_openssh(&pem).unwrap();
400        assert!(!loaded.is_encrypted());
401    }
402
403    #[test]
404    fn fingerprint_format_is_sha256() {
405        let key = generate(KeyType::Ed25519, None, "fp@test").unwrap();
406        let fp = fingerprint(key.public_key(), HashAlg::Sha256);
407        assert!(fp.starts_with("SHA256:"));
408    }
409
410    #[test]
411    fn extract_public_matches_companion_file() {
412        let dir = tempdir().unwrap();
413        let path = dir.path().join("id_ed25519");
414        let key = generate(KeyType::Ed25519, None, "ext@test").unwrap();
415        write_keypair(&key, &path, None).unwrap();
416
417        let pub_path_side = dir.path().join("side.pub");
418        extract_public(&path, Some(&pub_path_side)).unwrap();
419
420        let pub_from_generate = fs::read_to_string(path.with_extension("pub")).unwrap();
421        let pub_from_extract = fs::read_to_string(&pub_path_side).unwrap();
422        assert_eq!(
423            pub_from_generate.split_whitespace().nth(1),
424            pub_from_extract.split_whitespace().nth(1),
425            "base64 key body should match"
426        );
427    }
428
429    #[test]
430    fn rsa_size_bounds_are_enforced() {
431        let err = generate(KeyType::Rsa, Some(1024), "rsa@test").unwrap_err();
432        assert!(err.to_string().contains("out of range"));
433    }
434}