Lines
94.44 %
Functions
38.46 %
Branches
100 %
//! Host-key management.
//!
//! Nomisync-sshd uses an Ed25519 host key stored at a configurable
//! path (default `/etc/nomisync/ssh_host_ed25519_key`). On first
//! start the key is generated and written with `0600` permissions
//! so an operator can record the fingerprint before the daemon
//! accepts any connections.
use russh::keys::PrivateKey;
use std::fs;
use std::io;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum HostKeyError {
#[error("host-key I/O: {0}")]
Io(#[from] io::Error),
#[error("host-key encode/decode: {0}")]
KeyCodec(String),
}
/// Load the Ed25519 host key at `path`. Generates and persists a new
/// one with `0600` perms when the file is missing.
///
/// # Errors
/// Returns `Err` on filesystem or key-serialisation failures.
pub fn load_or_generate(path: &Path) -> Result<PrivateKey, HostKeyError> {
if path.exists() {
let pem = fs::read_to_string(path)?;
let key =
PrivateKey::from_openssh(&pem).map_err(|e| HostKeyError::KeyCodec(e.to_string()))?;
return Ok(key);
generate_and_persist(path)
fn generate_and_persist(path: &Path) -> Result<PrivateKey, HostKeyError> {
// `PrivateKey::random` requires an infallible `CryptoRng`. The
// OS generator (`SysRng`) is fallible; wrapping it in
// `UnwrapErr` converts read failures into a panic, which is
// the right behaviour on first boot — we'd rather crash loudly
// than silently produce a weak host key.
let mut rng = rand_core::UnwrapErr(rand::rngs::SysRng);
let key = PrivateKey::random(&mut rng, russh::keys::Algorithm::Ed25519)
.map_err(|e| HostKeyError::KeyCodec(e.to_string()))?;
let pem = key
.to_openssh(russh::keys::ssh_key::LineEnding::LF)
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
fs::write(path, pem.as_bytes())?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
let public = key.public_key();
log::info!(
"generated ed25519 host key fingerprint={} path={}",
public.fingerprint(russh::keys::HashAlg::Sha256),
path.display()
);
Ok(key)
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn first_call_generates_key_with_tight_perms() {
let dir = TempDir::new().unwrap();
let path = dir.path().join("hostkey");
let key = load_or_generate(&path).expect("generate");
let perms = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(perms, 0o600, "host-key file must be 0600");
assert!(
key.fingerprint(russh::keys::HashAlg::Sha256)
.to_string()
.starts_with("SHA256:")
fn second_call_loads_same_key_back() {
let first = load_or_generate(&path).unwrap();
let second = load_or_generate(&path).unwrap();
assert_eq!(
first.fingerprint(russh::keys::HashAlg::Sha256),
second.fingerprint(russh::keys::HashAlg::Sha256),
"reloading the file must yield the same key"
fn creates_parent_directory_when_missing() {
let nested = dir.path().join("nested/subdir/hostkey");
assert!(!nested.parent().unwrap().exists());
load_or_generate(&nested).expect("generate");
assert!(nested.exists());