crates/gunnar-ssh/tests/host_key.rs
//! 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");
}