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;
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");
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);
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"
);
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"
);
let for_russh = as_russh_key(&first);
assert_eq!(
for_russh.algorithm(),
russh::keys::ssh_key::Algorithm::Ed25519
);
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() {
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() {
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");
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;
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");
}