gunnar-core 3.0.0

gunnar's repository model, identity table, ACL and path sandbox. gunnar-ssh, gunnar-grpc and gunnar-cli all sit on it.
Documentation
//! 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)
}