Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar

crates/gunnar-cli/tests/clone_checks_the_host_key.rs

#![cfg(feature = "server")]

//! **GUN-002: `gunnar clone` checks the host key, like every other verb.**
//!
//! `SshOptions` carries `host_key` and `known_hosts`, and `SshOptions::auth()`
//! is the one place that expands them. `push`, `fetch`, `ls-remote`,
//! `mirror sync` and `clone --depth/--filter` all go through it and stop hard
//! on a changed key. `clone` — the verb a user runs first, and the one whose
//! output they then build — did not: `clone_or_fetch_with` took the key path
//! alone and handed it to `nornir_git::gitio::clone_or_fetch`, which builds its
//! own `SshAuth::key(path)`, and `SshAuth::key` sets
//! `HostKeyPolicy::AcceptAny`. No warning, no error, no log line — a full MITM
//! on the clone path, materialising attacker-chosen source straight into a
//! working tree.
//!
//! The asymmetry was the dangerous part, so the asymmetry is what this file
//! measures: the same poisoned `known_hosts` is put in front of BOTH verbs and
//! both must refuse, and the same clean one in front of both and both must
//! work.
//!
//! Nothing here is simulated. The harness runs gunnar's real SSH listener with
//! a real host key; the wrong key is a real ed25519 key minted in-process.

mod common;

use std::path::{Path, PathBuf};

use common::{tools_present, Harness};
use gunnar_client::ssh::{HostKeyPolicy, SshOptions};
use gunnar_core::HashKind;

const NS: &str = "nordisk";
const REPO: &str = "gunnar";

/// A `known_hosts` naming `127.0.0.1:<port>` under a key that is NOT this
/// server's — the on-path attacker's position, from the client's side.
fn poisoned_known_hosts(dir: &Path, port: u16) -> PathBuf {
    let other = gunnar_core::mint_ed25519().expect("mint a key");
    let other = gunnar_core::ssh_key::PrivateKey::from_openssh(&other).expect("parse");
    let line = other.public_key().to_openssh().expect("an openssh line");
    let path = dir.join("known_hosts");
    // The `[host]:port` form, which is what russh's `known_hosts` writer and
    // reader both use for a non-22 port.
    std::fs::write(&path, format!("[127.0.0.1]:{port} {line}\n")).expect("write known_hosts");
    path
}

/// One commit on `main`, through stock git over this harness's SSH listener.
fn push_one_commit(harness: &Harness, url: &str) {
    let work = harness.dir.path().join("seed");
    harness
        .git(
            harness.dir.path(),
            &["init", "--quiet", "-b", "main", work.to_str().unwrap()],
        )
        .expect_ok("git init");
    std::fs::write(work.join("a.txt"), "base\n").expect("write");
    harness.git(&work, &["add", "."]).expect_ok("add");
    harness
        .git(&work, &["commit", "--quiet", "-m", "base"])
        .expect_ok("commit");
    harness
        .git(&work, &["push", "--quiet", url, "main"])
        .expect_ok("push main");
}

fn options(harness: &Harness, known_hosts: PathBuf) -> SshOptions {
    let mut opts = SshOptions::new()
        .with_key(&harness.key_path)
        .with_host_key_policy(HostKeyPolicy::TofuKnownHosts);
    // Never `~/.ssh/known_hosts`: a test must not write to the developer's own.
    opts.known_hosts = Some(known_hosts);
    // The agent holds other keys and this is about the HOST key, not ours.
    opts.use_agent = false;
    opts
}

/// **The finding.** A server whose key does not match `known_hosts` does not
/// get to put bytes in a working tree.
///
/// Mutation-proven 2026-09-11: drop the `verify_host_key` call from
/// `clone_or_fetch_with` and this goes green on the clone — `dest/.git` exists
/// and `HEAD` is the attacker-served commit, with nothing printed.
#[test]
fn a_changed_host_key_stops_the_clone_the_way_it_stops_a_fetch() {
    assert!(tools_present(), "this test requires git and ssh");
    let mut harness = Harness::start(NS, REPO, HashKind::Sha1);
    let url = harness.url(NS, REPO);
    // **The repository must have a commit in it.** An unborn remote takes
    // `clone_or_fetch_with`'s `init_empty_clone` branch, which always went
    // through `opts` and therefore always checked the host key — so a fixture
    // with an empty repository would pass this file without ever reaching the
    // path the finding is about.
    push_one_commit(&harness, &url);
    let poisoned = poisoned_known_hosts(harness.dir.path(), harness.ssh_port);
    let opts = options(&harness, poisoned.clone());

    let dest = harness.dir.path().join("clone-refused");
    let err = gunnar_client::clone_or_fetch_with(&url, &dest, &opts)
        .expect_err("a clone from a server whose host key changed was served");
    // The chain, not the top line: `Error::Ssh`'s own Display is the one word
    // `ssh`, and the sentence an operator has to act on is underneath it.
    let message = gunnar_client::chain(&err);
    assert!(
        message.contains("host key") || message.contains("authenticate"),
        "the refusal must be about the connection, not about git: {message}"
    );
    assert!(
        !dest.join(".git").exists(),
        "the clone was refused and a repository was written anyway: {}",
        dest.display()
    );

    // The verb that always checked, against the same poisoned file, so this
    // test measures the ASYMMETRY the finding is about rather than one verb in
    // isolation. Both refuse now.
    assert!(
        gunnar_client::ls_remote_with(&url, &opts).is_err(),
        "ls-remote has always refused a changed host key; if it stopped, this \
         test would be proving nothing"
    );

    // The other direction, on the same server and the same key: an EMPTY
    // known_hosts is trust-on-first-use, the clone goes through, and the key
    // that was learned is the one this server actually holds. Without this the
    // refusal above would be satisfied by a clone path that refused
    // everything.
    let clean = harness.dir.path().join("known_hosts.clean");
    std::fs::write(&clean, "").expect("write an empty known_hosts");
    let opts = options(&harness, clean.clone());
    let dest = harness.dir.path().join("clone-allowed");
    gunnar_client::clone_or_fetch_with(&url, &dest, &opts)
        .expect("a clone from a server whose key is unknown must be learned and served");
    assert!(
        dest.join(".git").exists(),
        "the allowed clone wrote nothing"
    );
    let learned = std::fs::read_to_string(&clean).expect("read known_hosts back");
    assert!(
        learned.contains(&format!("[127.0.0.1]:{}", harness.ssh_port)),
        "trust-on-first-use must have recorded this server: {learned:?}"
    );

    // …and the recorded key is now enforced: a second clone against the
    // POISONED file still refuses, so what was learned is a real constraint
    // and not a file nothing reads.
    let opts = options(&harness, poisoned);
    let dest = harness.dir.path().join("clone-refused-again");
    assert!(
        gunnar_client::clone_or_fetch_with(&url, &dest, &opts).is_err(),
        "a recorded-but-different host key must still stop the clone"
    );
    assert!(!dest.join(".git").exists());

    harness.stop();
}