Skip to main content

gunnar_core/
host_key.rs

1//! The SSH host key (the secret-store seam).
2//!
3//! # Not a file on disk
4//!
5//! An appliance has no home directory and no `/etc/ssh`, so the host key lives
6//! in the [`SecretStore`] like anything else. And it must be **stable across
7//! reboots**: a server that mints a new host key on every boot gives every
8//! client the `REMOTE HOST IDENTIFICATION HAS CHANGED` warning, which on a
9//! product whose whole pitch is "leave the forge you do not trust" is exactly
10//! the wrong first impression.
11//!
12//! [`SecretStore::get_or_create`] is what makes that impossible to get wrong:
13//! it is atomic, so two connections racing at first boot cannot mint two keys,
14//! and the second caller gets the first caller's bytes.
15//!
16//! # No `ssh-keygen`
17//!
18//! Zero shell, both halves (the no-OS server rule). The key is minted in-process with
19//! `ssh-key` and `OsRng` and encoded in OpenSSH's own private-key format, so
20//! the stored bytes are the same bytes `ssh-keygen -t ed25519` would have
21//! produced: readable by any tool, and re-loadable by us.
22
23use gunnar_store::{SecretStore, SecretStoreExt, SSH_HOST_KEY};
24use ssh_key::private::PrivateKey;
25use ssh_key::{Algorithm, LineEnding};
26
27use crate::auth::Fingerprint;
28use crate::error::{Error, Result};
29
30/// The server's persistent SSH host key.
31///
32/// `Debug` prints the fingerprint only. Private key material must not be one
33/// `{:?}` away from a log file.
34#[derive(Clone)]
35pub struct HostKey {
36    key: PrivateKey,
37    openssh: Vec<u8>,
38}
39
40impl HostKey {
41    /// The OpenSSH-format private key bytes, exactly as stored.
42    ///
43    /// This is what the byte-identical-across-restart assertion compares.
44    pub fn private_openssh(&self) -> &[u8] {
45        &self.openssh
46    }
47
48    /// The parsed key, for russh.
49    pub fn private_key(&self) -> &PrivateKey {
50        &self.key
51    }
52
53    /// The public half, as an `authorized_keys`/`known_hosts` line.
54    pub fn public_openssh(&self) -> Result<String> {
55        self.key.public_key().to_openssh().map_err(Error::from)
56    }
57
58    /// The fingerprint an operator sees on first connection.
59    pub fn fingerprint(&self) -> Fingerprint {
60        Fingerprint::of_key(self.key.public_key())
61    }
62
63    /// Parse stored bytes back into a key.
64    /// **`pub` since 2026-08-30**, for replication's deploy key.
65    ///
66    /// This type is not "the SSH host key" — it is an ed25519 OpenSSH keypair
67    /// with the two accessors anything holding one needs
68    /// ([`private_openssh`](Self::private_openssh) for a signer,
69    /// [`public_openssh`](Self::public_openssh) for the line you authorise).
70    /// The appliance's replication key is exactly that, minted by the same
71    /// [`mint_ed25519`] and read back through the same parser, and a second
72    /// OpenSSH decoder in `gunnar-server` to avoid making one function public
73    /// would be a twin (one implementation).
74    pub fn from_openssh(bytes: Vec<u8>) -> Result<Self> {
75        let key = PrivateKey::from_openssh(&bytes)
76            .map_err(|err| Error::Key(format!("the stored SSH host key is not usable: {err}")))?;
77        Ok(HostKey {
78            key,
79            openssh: bytes,
80        })
81    }
82}
83
84impl std::fmt::Debug for HostKey {
85    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86        f.debug_struct("HostKey")
87            .field("algorithm", &self.key.algorithm().as_str())
88            .field("fingerprint", &self.fingerprint().as_str())
89            .finish_non_exhaustive()
90    }
91}
92
93/// **The operating system's generator**, wearing `rand_core 0.10`'s
94/// `CryptoRng` — what ssh-key 0.7 and ed25519-dalek 3 take. `SysRng` is
95/// getrandom's interface to the OS CSPRNG; `UnwrapErr` panics if the OS refuses
96/// to supply entropy, which is what `rand_core 0.6`'s `OsRng` did before the
97/// bump (2026-10-03) and the only safe answer for key material.
98///
99/// Here so a crate that already links gunnar-core needs no RNG dependency of its
100/// own to mint a key or a nonce.
101pub fn os_rng() -> rand::rand_core::UnwrapErr<rand::rngs::SysRng> {
102    rand::rand_core::UnwrapErr(rand::rngs::SysRng)
103}
104
105/// Mint a fresh ed25519 host key, in process.
106///
107/// Every call produces a different key: OpenSSH's private-key envelope carries
108/// a random `checkint` and random padding on top of the random key itself. That
109/// is what makes the stability test of [`ensure_host_key`] mean something:
110/// two mints differing is the red half that proves "identical after a restart"
111/// is not the identity value.
112pub fn mint_ed25519() -> Result<Vec<u8>> {
113    let key = PrivateKey::random(&mut os_rng(), Algorithm::Ed25519)
114        .map_err(|err| Error::Key(format!("could not mint an ed25519 host key: {err}")))?;
115    let encoded = key
116        .to_openssh(LineEnding::LF)
117        .map_err(|err| Error::Key(format!("could not encode the host key: {err}")))?;
118    Ok(encoded.as_bytes().to_vec())
119}
120
121/// Return the server's host key, minting and storing one on first boot.
122///
123/// Atomic: concurrent callers agree on one key, and every later boot gets the
124/// same bytes back.
125pub fn ensure_host_key(secrets: &dyn SecretStore) -> Result<HostKey> {
126    let bytes = secrets
127        .get_or_create_with(SSH_HOST_KEY, || {
128            tracing::info!(key = SSH_HOST_KEY, "no SSH host key stored; minting one");
129            mint_ed25519().map_err(gunnar_store::Error::backend)
130        })
131        .map_err(Error::from)?;
132    HostKey::from_openssh(bytes)
133}