crates/gunnar-core/src/host_key.rs
//! The SSH host key (the secret-store seam).
//!
//! # Not a file on disk
//!
//! An appliance has no home directory and no `/etc/ssh`, so the host key lives
//! in the [`SecretStore`] like anything else. And it must be **stable across
//! reboots**: a server that mints a new host key on every boot gives every
//! client the `REMOTE HOST IDENTIFICATION HAS CHANGED` warning, which on a
//! product whose whole pitch is "leave the forge you do not trust" is exactly
//! the wrong first impression.
//!
//! [`SecretStore::get_or_create`] is what makes that impossible to get wrong:
//! it is atomic, so two connections racing at first boot cannot mint two keys,
//! and the second caller gets the first caller's bytes.
//!
//! # No `ssh-keygen`
//!
//! Zero shell, both halves (the no-OS server rule). The key is minted in-process with
//! `ssh-key` and `OsRng` and encoded in OpenSSH's own private-key format, so
//! the stored bytes are the same bytes `ssh-keygen -t ed25519` would have
//! produced: readable by any tool, and re-loadable by us.
use gunnar_store::{SecretStore, SecretStoreExt, SSH_HOST_KEY};
use ssh_key::private::PrivateKey;
use ssh_key::{Algorithm, LineEnding};
use crate::auth::Fingerprint;
use crate::error::{Error, Result};
/// The server's persistent SSH host key.
///
/// `Debug` prints the fingerprint only. Private key material must not be one
/// `{:?}` away from a log file.
#[derive(Clone)]
pub struct HostKey {
key: PrivateKey,
openssh: Vec<u8>,
}
impl HostKey {
/// The OpenSSH-format private key bytes, exactly as stored.
///
/// This is what the byte-identical-across-restart assertion compares.
pub fn private_openssh(&self) -> &[u8] {
&self.openssh
}
/// The parsed key, for russh.
pub fn private_key(&self) -> &PrivateKey {
&self.key
}
/// The public half, as an `authorized_keys`/`known_hosts` line.
pub fn public_openssh(&self) -> Result<String> {
self.key.public_key().to_openssh().map_err(Error::from)
}
/// The fingerprint an operator sees on first connection.
pub fn fingerprint(&self) -> Fingerprint {
Fingerprint::of_key(self.key.public_key())
}
/// Parse stored bytes back into a key.
/// **`pub` since 2026-08-30**, for replication's deploy key.
///
/// This type is not "the SSH host key" — it is an ed25519 OpenSSH keypair
/// with the two accessors anything holding one needs
/// ([`private_openssh`](Self::private_openssh) for a signer,
/// [`public_openssh`](Self::public_openssh) for the line you authorise).
/// The appliance's replication key is exactly that, minted by the same
/// [`mint_ed25519`] and read back through the same parser, and a second
/// OpenSSH decoder in `gunnar-server` to avoid making one function public
/// would be a twin (one implementation).
pub fn from_openssh(bytes: Vec<u8>) -> Result<Self> {
let key = PrivateKey::from_openssh(&bytes)
.map_err(|err| Error::Key(format!("the stored SSH host key is not usable: {err}")))?;
Ok(HostKey {
key,
openssh: bytes,
})
}
}
impl std::fmt::Debug for HostKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("HostKey")
.field("algorithm", &self.key.algorithm().as_str())
.field("fingerprint", &self.fingerprint().as_str())
.finish_non_exhaustive()
}
}
/// **The operating system's generator**, wearing `rand_core 0.10`'s
/// `CryptoRng` — what ssh-key 0.7 and ed25519-dalek 3 take. `SysRng` is
/// getrandom's interface to the OS CSPRNG; `UnwrapErr` panics if the OS refuses
/// to supply entropy, which is what `rand_core 0.6`'s `OsRng` did before the
/// bump (2026-10-03) and the only safe answer for key material.
///
/// Here so a crate that already links gunnar-core needs no RNG dependency of its
/// own to mint a key or a nonce.
pub fn os_rng() -> rand::rand_core::UnwrapErr<rand::rngs::SysRng> {
rand::rand_core::UnwrapErr(rand::rngs::SysRng)
}
/// Mint a fresh ed25519 host key, in process.
///
/// Every call produces a different key: OpenSSH's private-key envelope carries
/// a random `checkint` and random padding on top of the random key itself. That
/// is what makes the stability test of [`ensure_host_key`] mean something:
/// two mints differing is the red half that proves "identical after a restart"
/// is not the identity value.
pub fn mint_ed25519() -> Result<Vec<u8>> {
let key = PrivateKey::random(&mut os_rng(), Algorithm::Ed25519)
.map_err(|err| Error::Key(format!("could not mint an ed25519 host key: {err}")))?;
let encoded = key
.to_openssh(LineEnding::LF)
.map_err(|err| Error::Key(format!("could not encode the host key: {err}")))?;
Ok(encoded.as_bytes().to_vec())
}
/// Return the server's host key, minting and storing one on first boot.
///
/// Atomic: concurrent callers agree on one key, and every later boot gets the
/// same bytes back.
pub fn ensure_host_key(secrets: &dyn SecretStore) -> Result<HostKey> {
let bytes = secrets
.get_or_create_with(SSH_HOST_KEY, || {
tracing::info!(key = SSH_HOST_KEY, "no SSH host key stored; minting one");
mint_ed25519().map_err(gunnar_store::Error::backend)
})
.map_err(Error::from)?;
HostKey::from_openssh(bytes)
}