1
//! Host-key management.
2
//!
3
//! Nomisync-sshd uses an Ed25519 host key stored at a configurable
4
//! path (default `/etc/nomisync/ssh_host_ed25519_key`). On first
5
//! start the key is generated and written with `0600` permissions
6
//! so an operator can record the fingerprint before the daemon
7
//! accepts any connections.
8

            
9
use russh::keys::PrivateKey;
10
use std::fs;
11
use std::io;
12
use std::os::unix::fs::PermissionsExt;
13
use std::path::Path;
14
use thiserror::Error;
15

            
16
#[derive(Debug, Error)]
17
pub enum HostKeyError {
18
    #[error("host-key I/O: {0}")]
19
    Io(#[from] io::Error),
20
    #[error("host-key encode/decode: {0}")]
21
    KeyCodec(String),
22
}
23

            
24
/// Load the Ed25519 host key at `path`. Generates and persists a new
25
/// one with `0600` perms when the file is missing.
26
///
27
/// # Errors
28
///
29
/// Returns `Err` on filesystem or key-serialisation failures.
30
4
pub fn load_or_generate(path: &Path) -> Result<PrivateKey, HostKeyError> {
31
4
    if path.exists() {
32
1
        let pem = fs::read_to_string(path)?;
33
1
        let key =
34
1
            PrivateKey::from_openssh(&pem).map_err(|e| HostKeyError::KeyCodec(e.to_string()))?;
35
1
        return Ok(key);
36
3
    }
37
3
    generate_and_persist(path)
38
4
}
39

            
40
3
fn generate_and_persist(path: &Path) -> Result<PrivateKey, HostKeyError> {
41
    // `PrivateKey::random` requires an infallible `CryptoRng`. The
42
    // OS generator (`SysRng`) is fallible; wrapping it in
43
    // `UnwrapErr` converts read failures into a panic, which is
44
    // the right behaviour on first boot — we'd rather crash loudly
45
    // than silently produce a weak host key.
46
3
    let mut rng = rand_core::UnwrapErr(rand::rngs::SysRng);
47
3
    let key = PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
48
3
        .map_err(|e| HostKeyError::KeyCodec(e.to_string()))?;
49
3
    let pem = key
50
3
        .to_openssh(russh::keys::ssh_key::LineEnding::LF)
51
3
        .map_err(|e| HostKeyError::KeyCodec(e.to_string()))?;
52
3
    if let Some(parent) = path.parent() {
53
3
        fs::create_dir_all(parent)?;
54
    }
55
3
    fs::write(path, pem.as_bytes())?;
56
3
    fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
57
3
    let public = key.public_key();
58
3
    log::info!(
59
        "generated ed25519 host key fingerprint={} path={}",
60
        public.fingerprint(russh::keys::HashAlg::Sha256),
61
        path.display()
62
    );
63
3
    Ok(key)
64
3
}
65

            
66
#[cfg(test)]
67
mod tests {
68
    use super::*;
69
    use tempfile::TempDir;
70

            
71
    #[test]
72
1
    fn first_call_generates_key_with_tight_perms() {
73
1
        let dir = TempDir::new().unwrap();
74
1
        let path = dir.path().join("hostkey");
75
1
        let key = load_or_generate(&path).expect("generate");
76
1
        let perms = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
77
1
        assert_eq!(perms, 0o600, "host-key file must be 0600");
78
1
        assert!(
79
1
            key.fingerprint(russh::keys::HashAlg::Sha256)
80
1
                .to_string()
81
1
                .starts_with("SHA256:")
82
        );
83
1
    }
84

            
85
    #[test]
86
1
    fn second_call_loads_same_key_back() {
87
1
        let dir = TempDir::new().unwrap();
88
1
        let path = dir.path().join("hostkey");
89
1
        let first = load_or_generate(&path).unwrap();
90
1
        let second = load_or_generate(&path).unwrap();
91
1
        assert_eq!(
92
1
            first.fingerprint(russh::keys::HashAlg::Sha256),
93
1
            second.fingerprint(russh::keys::HashAlg::Sha256),
94
            "reloading the file must yield the same key"
95
        );
96
1
    }
97

            
98
    #[test]
99
1
    fn creates_parent_directory_when_missing() {
100
1
        let dir = TempDir::new().unwrap();
101
1
        let nested = dir.path().join("nested/subdir/hostkey");
102
1
        assert!(!nested.parent().unwrap().exists());
103
1
        load_or_generate(&nested).expect("generate");
104
1
        assert!(nested.exists());
105
1
    }
106
}