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}