gunnar-ssh 3.0.0

gunnar's SSH server: a russh listener with publickey identity, a strict git exec dispatch, and the repository path sandbox.
Documentation
//! The host key must survive a restart (the secret-store seam).
//!
//! The assertion is on the **stored bytes and the fingerprint the client sees**,
//! not on "a key came back". A `load_or_create` that minted a fresh key every
//! time would satisfy every weaker form of this test, and the symptom (a
//! host-key-changed warning on every client, on every boot) would only show up
//! in front of a user.

//!
//! # This is `gunnar-core`'s host key now
//!
//! This crate carried its own `hostkey::load_or_create_host_key`, a second
//! `get_or_create(SSH_HOST_KEY)` beside `gunnar_core::host_key::ensure_host_key`.
//! It is deleted. The one that survived returns a
//! [`HostKey`](gunnar_core::HostKey), which holds the stored bytes as well as
//! the parsed key, so the "byte-identical across a restart" assertion below no
//! longer has to reach back into the store to make it.
//!
//! The one thing this crate still has to prove for itself is that the key
//! `gunnar-core` produces is the key **russh serves**, since russh needs a
//! `PrivateKey` from *its* `ssh-key 0.7`, and `gunnar-core` parses one with
//! `ssh-key 0.6`. The bridge is the OpenSSH private-key encoding, and
//! `the_key_the_store_holds_is_the_key_the_client_sees` is where that crossing
//! is asserted against a real handshake.

mod common;

use gunnar_core::arrow_log::ArrowLogStore;
use gunnar_core::host_key::ensure_host_key;
use gunnar_store::{SecretStore, SSH_HOST_KEY};
use russh::keys::ssh_key::{HashAlg, PrivateKey};

use common::mint_key;

/// Hand `gunnar-core`'s host key to russh.
///
/// The two crates cannot exchange a `PrivateKey` value, because their `ssh-key`
/// copies are semver-incompatible, so the stored OpenSSH bytes cross. This
/// is the same encoding `ssh-keygen -t ed25519` writes, and asserting that
/// russh reparses it to the same fingerprint is asserting the bridge holds.
fn as_russh_key(key: &gunnar_core::HostKey) -> PrivateKey {
    PrivateKey::from_openssh(key.private_openssh())
        .expect("gunnar-core stores the host key in OpenSSH's own private-key format")
}

#[test]
fn the_host_key_is_byte_identical_across_a_simulated_restart() {
    let dir = tempfile::tempdir().expect("scratch directory");

    // ── first boot ────────────────────────────────────────────────────────
    let store = ArrowLogStore::open(dir.path(), gunnar_store::gix_hash::Kind::Sha256)
        .expect("open the secret log");
    let first = ensure_host_key(&store.secrets()).expect("mint on first boot");
    let first_bytes = store
        .secrets()
        .get(SSH_HOST_KEY)
        .expect("read back")
        .expect("the key must have been persisted, not just returned");
    assert_eq!(
        first.private_openssh(),
        first_bytes.as_slice(),
        "the bytes returned are not the bytes stored"
    );
    drop(store);

    // ── restart: a new process, a new store, the same directory ───────────
    let store = ArrowLogStore::open(dir.path(), gunnar_store::gix_hash::Kind::Sha256)
        .expect("reopen the secret log");
    let second = ensure_host_key(&store.secrets()).expect("load on second boot");
    let second_bytes = store
        .secrets()
        .get(SSH_HOST_KEY)
        .expect("read back")
        .expect("still there");

    assert_eq!(
        first_bytes, second_bytes,
        "the stored host key changed across a restart"
    );
    assert_eq!(
        first.fingerprint(),
        second.fingerprint(),
        "the fingerprint a client pins changed across a restart"
    );

    // The bytes really are an OpenSSH private key an operator could read.
    assert!(
        String::from_utf8_lossy(&first_bytes).starts_with("-----BEGIN OPENSSH PRIVATE KEY-----"),
        "the host key should be stored in the format ssh-keygen writes"
    );
    // …and it is an ed25519 key, not whatever the default happened to be.
    let for_russh = as_russh_key(&first);
    assert_eq!(
        for_russh.algorithm(),
        russh::keys::ssh_key::Algorithm::Ed25519
    );
    // The `ssh-key` crossing, at rest: `gunnar-core`'s `ssh-key 0.6` and russh's
    // `ssh-key 0.7-rc` agree on what key those bytes are.
    assert_eq!(
        for_russh
            .public_key()
            .fingerprint(HashAlg::Sha256)
            .to_string(),
        first.fingerprint().as_str(),
        "russh and gunnar-core disagree about the identity of the same stored host key"
    );
}

#[test]
fn a_second_directory_gets_a_different_key() {
    // The identity-value guard: if the "restart" test above were passing because
    // minting is deterministic, every gunnar in the world would share a host key
    // and the test above would still be green.
    let a = tempfile::tempdir().expect("scratch");
    let b = tempfile::tempdir().expect("scratch");
    let store_a = ArrowLogStore::open(a.path(), gunnar_store::gix_hash::Kind::Sha256).unwrap();
    let store_b = ArrowLogStore::open(b.path(), gunnar_store::gix_hash::Kind::Sha256).unwrap();
    assert_ne!(
        ensure_host_key(&store_a.secrets()).unwrap().fingerprint(),
        ensure_host_key(&store_b.secrets()).unwrap().fingerprint()
    );
}

#[tokio::test]
async fn the_key_the_store_holds_is_the_key_the_client_sees() {
    // Ties the two halves together: what `load_or_create_host_key` returns is
    // what a client actually pins over the wire. Without this, the store could
    // be perfectly stable and the listener could still be serving something
    // else.
    use std::sync::Mutex;

    let dir = tempfile::tempdir().expect("scratch");
    let store = ArrowLogStore::open(dir.path(), gunnar_store::gix_hash::Kind::Sha256).unwrap();
    let host_key = ensure_host_key(&store.secrets()).expect("mint");
    // The expected value comes from `gunnar-core`'s side of the version split.
    let expected = host_key.fingerprint().as_str().to_string();

    let client_key = mint_key();
    let plane = common::plane_with_key(&client_key, gunnar_ssh::Access::Read);
    let server = gunnar_ssh::GunnarSsh::new(
        gunnar_ssh::SshConfig::ephemeral_loopback(),
        plane.control,
        std::sync::Arc::new(gunnar_ssh::seam::BannerTransport::new()),
    )
    .bind(as_russh_key(&host_key))
    .await
    .expect("bind");

    static SEEN: Mutex<Option<String>> = Mutex::new(None);

    struct Recording;
    impl russh::client::Handler for Recording {
        type Error = russh::Error;
        // russh 0.63: the server's key arrives as key-or-certificate; this server
        // presents a plain host key, and that key is what is recorded.
        async fn check_server_key(
            &mut self,
            server_key: &russh::keys::PublicKeyOrCertificate,
        ) -> Result<bool, Self::Error> {
            let russh::keys::PublicKeyOrCertificate::PublicKey {
                key: server_public_key,
                ..
            } = server_key
            else {
                return Ok(false);
            };
            *SEEN.lock().unwrap() =
                Some(server_public_key.fingerprint(HashAlg::Sha256).to_string());
            Ok(true)
        }
    }

    let config = std::sync::Arc::new(russh::client::Config::default());
    let mut session = russh::client::connect(config, server.local_addr(), Recording)
        .await
        .expect("connect");
    let auth = session
        .authenticate_publickey(
            "git",
            russh::keys::PrivateKeyWithHashAlg::new(std::sync::Arc::new(client_key.clone()), None),
        )
        .await
        .expect("authenticate");
    assert!(auth.success());

    assert_eq!(
        SEEN.lock().unwrap().clone(),
        Some(expected),
        "the client pinned a different host key than the one in the secret store"
    );

    server.shutdown("test over").await.expect("shutdown");
}