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

nordisk/gunnar

crates/gunnar-ssh/tests/adversarial_auth.rs

//! **Adversarial authentication and authorisation over SSH**, the second of
//! the two surfaces both 2026-08-05 sweeps named as never swept.
//!
//! `tests/auth.rs` covers the two states an identity is in *before* a session
//! starts: a key the table knows and a key it does not. Everything here is
//! about the states it can be in **after** — a principal deleted, a key
//! revoked, a grant withdrawn, a repository dropped, all while a connection is
//! already authenticated and holding a channel open. That window is not
//! hypothetical: `auth_publickey` caches the `Principal` on the session
//! (`Handler::principal`), so what is re-read at exec time and what is not is a
//! real question with a real answer, and it had never been asked. It is asked
//! here — and since 2026-09-11 the answer includes the FINGERPRINT, which was
//! the one input on the list that `exec_request` used to take from the cache.
//!
//! # The bar every case here has to clear
//!
//! **A refusal must reach the client, in words.** Not a style point:
//! `report_error` was found on 2026-08-05 with zero call sites, so the server
//! refused pushes and the client saw only `the remote end hung up
//! unexpectedly` while the log held the whole reason. So each case asserts the
//! bytes on the data band, not the exit status — an `ExitStatus(1)` and a
//! silent hang-up look identical to the person holding the terminal.
//!
//! The shape that must arrive is git's own: an `ERR ` pkt-line, which is what
//! `git` prints verbatim after `remote: `. `git_says` below is the reader.
//!
//! # And the difference the wire deliberately hides
//!
//! "No such repository" and "you have no grant" are **one string** to the
//! client, on purpose, so a caller cannot enumerate private repositories by
//! the difference. That is a written position, and it makes the assertions
//! here two-sided: the client-visible message must be the *same* in both
//! cases, and the server log must still tell them apart.
//!
//! # Result, plainly: what was measured, and what was not
//!
//! **The surface came up clean.** Ten adversarial cases, no wrong answer in any
//! of them. What the sweep produced is therefore not a bug list, and saying so
//! is the honest headline:
//!
//! * **one fix, by construction, not a defect**: `DispatchError::client_message`
//!   spelled the "not found or no access" sentence **twice**, once per arm, for
//!   a property whose whole content is that the two are equal. Folded into one
//!   arm (one implementation). Nothing was wrong with the strings; they were one edit away
//!   from being wrong in the direction that leaks which private repositories
//!   exist.
//! * **one documented position, since REVERSED (2026-09-11, GUN-015)**: a
//!   revoked key used to keep the session it had already opened. The note that
//!   recorded the position also recorded its cost — an operator revoking a
//!   compromised key had no verb that would drop the connections it held — and
//!   that cost turned out to be the finding rather than a footnote to it. See
//!   `a_revoked_key_loses_the_session_it_already_opened`.
//! * **everything else measured and clean**, including the two the sweep
//!   named by hand: a principal deleted mid-session, and a namespace that
//!   vanishes between the check and the use.
//!
//! # What holds each of them up, since none of it is in this crate
//!
//! Worth stating, because "it was fine" is not a result and the *reason* it is
//! fine is the thing a future change can break:
//!
//! | case | what refuses it |
//! |---|---|
//! | principal deleted mid-session | `AuthTable::may`'s `principals.contains_key` early return — **load-bearing**, because account ownership is intrinsic and has no row to revoke |
//! | grant downgraded mid-session | `authorise_service` calling `control.access_for` per exec, not per session |
//! | repository or namespace deleted | `authorise_service` calling `control.resolve` per exec |
//! | expired key | `principal_by_fingerprint` delegating to `..._at(now_ms())` |
//! | a borrowed username | `auth_publickey` resolving by fingerprint and never by `user` |
//!
//! Every one of those five was driven **red** by removing exactly that line,
//! and each time exactly one test here failed, naming its own subject. The
//! probe outputs are in the commit message.

mod common;

use gunnar_core::{Level, RepoRef};
use gunnar_ssh::Access;

use common::{authenticate, exec, mint_key, plane_with_key, start, the_repo, ExecOutcome, Plane};

/// The message a client can actually see, or `None` if the server said nothing.
///
/// Reads the `ERR ` pkt-line out of the data band the way `git` does: a 4-digit
/// hex length, then `ERR `, then the sentence. Anything else — an empty band, a
/// bare exit status, a length header with no payload — is `None`, and `None` is
/// what every assertion here is guarding against.
///
/// # This reader is itself checked
///
/// `the_reader_can_tell_silence_from_a_message` drives both directions through
/// it, because a `git_says` that answered `Some("")` to everything would make
/// every case below pass while the client heard nothing.
fn git_says(outcome: &ExecOutcome) -> Option<String> {
    let ExecOutcome::Accepted { stdout, .. } = outcome else {
        return None;
    };
    let bytes = stdout.as_bytes();
    if bytes.len() < 8 {
        return None;
    }
    let len = usize::from_str_radix(std::str::from_utf8(&bytes[..4]).ok()?, 16).ok()?;
    if len < 8 || len > bytes.len() {
        return None;
    }
    let body = &bytes[4..len];
    let text = std::str::from_utf8(body).ok()?;
    let message = text.strip_prefix("ERR ")?.trim_end().to_owned();
    (!message.is_empty()).then_some(message)
}

/// The reader is not blind, in both directions.
///
/// A `git_says` that answered `Some(..)` to a silent channel would make every
/// refusal below pass over the exact defect they exist to catch, and one that
/// answered `None` to everything would make them all fail for the wrong reason.
#[test]
fn the_reader_can_tell_silence_from_a_message() {
    assert_eq!(git_says(&ExecOutcome::Refused), None);
    assert_eq!(
        git_says(&ExecOutcome::Accepted {
            stdout: String::new(),
            exit_status: Some(1)
        }),
        None,
        "a channel that carried no bytes must read as silence, or every case here passes \
         over a server that says nothing"
    );
    assert_eq!(
        git_says(&ExecOutcome::Accepted {
            stdout: "0000".into(),
            exit_status: Some(1)
        }),
        None,
        "a flush packet is not a message"
    );
    // A real `ERR` line: `0016` = 22 bytes total, `ERR ` + 14 characters.
    assert_eq!(
        git_says(&ExecOutcome::Accepted {
            stdout: "0016ERR no such thing\n".into(),
            exit_status: Some(1)
        })
        .as_deref(),
        Some("no such thing"),
    );
}

/// `git-upload-pack` on [`the_repo`], quoted the way `git` quotes it.
fn upload(path: &RepoRef) -> String {
    format!("git-upload-pack '{path}'")
}

/// `git-receive-pack`, which needs `Write` where the above needs `Read`.
fn receive(path: &RepoRef) -> String {
    format!("git-receive-pack '{path}'")
}

// ─────────────────────────────────────────────────────────────────────────────
// 1. A valid key, the wrong repository
// ─────────────────────────────────────────────────────────────────────────────

/// **A key that authenticates perfectly and holds nothing on the repository it
/// asks for**, refused in git's own words.
///
/// The most ordinary adversarial case there is, and the one the wire's
/// deliberate ambiguity is built around: this refusal and
/// `a_repository_that_does_not_exist_refuses_in_the_same_words` must be
/// **indistinguishable to the client**, so a caller cannot walk a namespace by
/// reading the difference. Both halves are asserted, in one test each, and the
/// third test below compares them.
///
/// # Seen red
///
/// With `session.data(channel, err_packet(&message)?)` removed from
/// `exec_request`'s denial arm, this fails on `git_says` being `None` — which
/// is the exact production shape that lane found this morning, reproduced.
#[tokio::test]
async fn a_key_with_no_grant_on_this_repository_is_refused_in_words() {
    let key = mint_key();
    // A real principal with a real, registered key — and a grant on a
    // *different* repository, so the refusal is about this repository and not
    // about an identity the server does not know.
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("stranger")
        .with_key("stranger", &key);
    let other = RepoRef::new("nordisk", "somewhere-else").expect("a legal path");
    plane
        .catalog()
        .create(gunnar_core::NewRepo::new(other.clone()))
        .expect("a second repository");
    plane
        .auth()
        .grant(gunnar_core::Grant::repo(
            "stranger",
            other.clone(),
            Level::Admin,
        ))
        .expect("Admin, but on the other repository");
    let control = plane.control.clone();
    let fixture = start(control, None).await;

    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("the key is registered, so authentication must succeed");

    // The control: the same key on the repository it DOES hold Admin on. Without
    // it, a server that refused everything would pass the refusal below.
    let allowed = exec(&session, &upload(&other)).await.expect("exec");
    assert!(
        matches!(allowed, ExecOutcome::Accepted { .. }) && git_says(&allowed).is_none(),
        "the key was refused on the repository it holds Admin on, so the refusal below is \
         not about the grant: {allowed:?}"
    );

    let refused = exec(&session, &upload(&the_repo())).await.expect("exec");
    let said = git_says(&refused).unwrap_or_else(|| {
        panic!(
            "a client refused for want of a grant heard NOTHING. It will print `the remote \
             end hung up unexpectedly` and the reason will be in a log it cannot read: \
             {refused:?}"
        )
    });
    assert!(
        said.contains(&the_repo().to_string()),
        "the refusal does not name the repository that was refused: {said:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **A repository that does not exist refuses in exactly the same words.**
///
/// The other half of the ambiguity, and the reason the two are separate tests
/// with a third comparing them: an assertion that merely said "both are
/// refused" would be satisfied by two different messages, which is the leak.
#[tokio::test]
async fn a_repository_that_does_not_exist_refuses_in_the_same_words() {
    let key = mint_key();
    let fixture = start(plane_with_key(&key, Access::Admin).control, None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let ghost = RepoRef::new("nordisk", "never-created").expect("a legal path");
    let refused = exec(&session, &upload(&ghost)).await.expect("exec");
    let said = git_says(&refused).unwrap_or_else(|| {
        panic!("a client asking for a missing repository heard nothing: {refused:?}")
    });
    assert!(
        said.contains(&ghost.to_string()),
        "the refusal does not name what was asked for: {said:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **The two refusals above are the same sentence with the path swapped.**
///
/// This is the assertion the other two cannot make on their own, and it is the
/// one that actually pins the position: a client must not be able to tell "this
/// repository does not exist" from "you may not have it", because the
/// difference is a directory listing of every private repository on the server.
///
/// # Seen red
///
/// Change `DispatchError::NoSuchRepo`'s client message to anything that is not
/// the denial's, and this fails naming both strings, while both tests above
/// stay green.
#[tokio::test]
async fn a_missing_repository_and_a_forbidden_one_are_one_string_to_the_client() {
    let key = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("stranger")
        .with_key("stranger", &key);
    let fixture = start(plane.control.clone(), None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    // Exists, no grant.
    let forbidden = exec(&session, &upload(&the_repo())).await.expect("exec");
    // Does not exist. Same namespace, so even the namespace is not a tell.
    let missing_path = RepoRef::new("nordisk", "not-a-repository").expect("a legal path");
    let missing = exec(&session, &upload(&missing_path)).await.expect("exec");

    let forbidden_said = git_says(&forbidden).expect("the forbidden repository answered");
    let missing_said = git_says(&missing).expect("the missing repository answered");

    // Normalised by swapping the path out, because the path is the one thing
    // that legitimately differs — the client asked for it and already knows it.
    let a = forbidden_said.replace(&the_repo().to_string(), "<path>");
    let b = missing_said.replace(&missing_path.to_string(), "<path>");
    assert_eq!(
        a, b,
        "a client can tell a repository it may not have from one that does not exist:\n  \
         forbidden: {forbidden_said}\n  missing:   {missing_said}\n\
         That difference is a directory listing of every private repository on the server."
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

// ─────────────────────────────────────────────────────────────────────────────
// 2. The identity moving underneath a live session
// ─────────────────────────────────────────────────────────────────────────────

/// **A principal deleted mid-session cannot exec again.**
///
/// `auth_publickey` caches the `Principal` on the session and `exec_request`
/// authorises from that cached value, so nothing in the SSH layer notices the
/// deletion. What saves it is one line in `AuthTable::may`, which checks
/// `doc.principals.contains_key` before it consults ownership or any grant —
/// and that check is load-bearing precisely because **account ownership is
/// intrinsic**: `AuthTable::owns_account` is a string comparison between a
/// principal id and a namespace, with no row anywhere to revoke. Without the
/// existence check, a deleted principal would keep `Admin` on its own
/// namespace forever, out of a rule that has nothing to look up.
///
/// So this is a guard on `gunnar-core`'s invariant, asserted from the far side
/// of a real SSH session, which is the only place the caching matters.
///
/// # What changed on 2026-09-11, and why the assertion moved rather than went
///
/// `exec_request` re-reads the fingerprint now (GUN-015), and
/// `remove_principal` takes the principal's keys with it — so a deleted
/// principal is now stopped one step EARLIER, by its credential no longer
/// resolving, and the client is told that instead of being told about the
/// repository. Better, and it costs this test its subject: the wire no longer
/// reaches `AuthTable::may` at all in this scenario, so an assertion about what
/// the wire says would silently stop being about the `contains_key` early
/// return.
///
/// So the test asserts BOTH, at the level each one lives on: the session is
/// stopped (over a real handshake, in words the client can read), and `may`
/// itself still refuses the deleted owner on its own namespace (called
/// directly, where the intrinsic-ownership rule is). Neither half can be
/// removed without the other noticing.
///
/// # Seen red
///
/// Remove the `contains_key` early return from `AuthTable::may` and the second
/// half fails: the deleted principal still holding `Admin` on its own
/// namespace, out of a rule that has nothing to look up.
#[tokio::test]
async fn a_principal_deleted_mid_session_cannot_exec_again() {
    let key = mint_key();
    // The principal OWNS its namespace, so the intrinsic rule is the one under
    // test. A grant-based fixture would be saved by the grant going away with
    // the principal, which is a different and weaker guarantee.
    let owned = RepoRef::new("alice", "diary").expect("a legal path");
    let plane = Plane::empty()
        .with_principal("alice")
        .with_key("alice", &key);
    plane
        .catalog()
        .create_namespace("alice")
        .expect("namespace");
    plane
        .catalog()
        .create(
            gunnar_core::NewRepo::new(owned.clone()).visibility(gunnar_core::Visibility::Private),
        )
        .expect("repository");
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;

    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    // Before: intrinsic ownership serves it, with no grant anywhere.
    let before = exec(&session, &receive(&owned)).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "the owner could not reach its own account repository, so the deletion below \
         proves nothing: {before:?}"
    );
    assert!(
        control
            .auth()
            .grants(Some("alice"))
            .expect("grants")
            .is_empty(),
        "the fixture handed out a grant, so this test is about a grant and not about \
         intrinsic ownership"
    );

    // The session stays open. Only the table changes.
    control
        .auth()
        .remove_principal("alice")
        .expect("remove the principal");

    let after = exec(&session, &receive(&owned)).await.expect("exec");
    let said = git_says(&after).unwrap_or_else(|| {
        panic!(
            "a deleted principal was still served on its own namespace, or was refused in \
             silence: {after:?}"
        )
    });
    assert_eq!(
        said,
        gunnar_ssh::server::REVOKED_MESSAGE,
        "the deleted principal was stopped, but not by its credential ceasing to resolve. \
         Something else caught it, and the second half below is then the only thing \
         standing between a deleted owner and its namespace: {said:?}"
    );

    // The other half, at the level it lives on. Ownership is intrinsic —
    // `owns_account` is a string comparison with no row anywhere to revoke — so
    // without `may`'s existence check a deleted principal keeps `Admin` on its
    // own namespace forever. The wire cannot see this any more; the table can.
    let repo = control
        .catalog()
        .get(&owned)
        .expect("the catalog is readable")
        .expect("the repository is still there");
    for level in [Level::Read, Level::Write, Level::Admin] {
        assert!(
            !control
                .auth()
                .may("alice", &repo, level)
                .expect("the identity table is readable"),
            "a deleted principal still holds {level:?} on its own namespace"
        );
    }

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **A grant revoked mid-session stops the next exec.**
///
/// The grant is re-read at every exec, not cached with the principal: that is
/// what `Dispatch::authorise_service` calling `control.access_for` does, and it
/// is worth pinning because the principal beside it *is* cached and the two
/// look alike at the call site.
///
/// The downgrade is asserted in the sharp direction — `Write` withdrawn to
/// `Read` — rather than by removing access entirely, because a session that
/// re-read nothing and a session that re-read only "is there any grant at all"
/// answer the removal case identically.
#[tokio::test]
async fn a_grant_downgraded_mid_session_stops_the_next_push() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Write);
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let before = exec(&session, &receive(&the_repo())).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "a Write grant could not push, so the downgrade below proves nothing: {before:?}"
    );

    // Withdraw Write, leave Read. The session is untouched.
    assert!(
        control
            .auth()
            .revoke_grant("principal-1", &gunnar_core::Scope::Repo(the_repo()))
            .expect("revoke"),
        "the fixture's grant was not where this test revoked from"
    );
    control
        .auth()
        .grant(gunnar_core::Grant::repo(
            "principal-1",
            the_repo(),
            Level::Read,
        ))
        .expect("re-grant at Read");

    // The push must now be refused…
    let push = exec(&session, &receive(&the_repo())).await.expect("exec");
    let said = git_says(&push).unwrap_or_else(|| {
        panic!("a downgraded session pushed, or was refused in silence: {push:?}")
    });
    assert!(
        said.contains(&the_repo().to_string()),
        "the refusal does not name the repository: {said:?}"
    );

    // …and the fetch must not be, or this is measuring the connection dying
    // rather than the grant being re-read.
    let fetch = exec(&session, &upload(&the_repo())).await.expect("exec");
    assert!(
        matches!(fetch, ExecOutcome::Accepted { .. }) && git_says(&fetch).is_none(),
        "the Read grant that survived the downgrade cannot fetch either, so the refusal \
         above is the session breaking and not the ACL answering: {fetch:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **The repository deleted mid-session, between the check and the use.**
///
/// The catalog is re-resolved at every exec — `authorise_service` calls
/// `control.resolve` before it asks the ACL anything — so a repository that
/// vanished answers as one that never existed, in the same words, which is the
/// right answer and is asserted here rather than assumed.
#[tokio::test]
async fn a_repository_deleted_mid_session_refuses_like_one_that_never_existed() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Admin);
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let before = exec(&session, &upload(&the_repo())).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "an Admin grant could not fetch, so the deletion below proves nothing: {before:?}"
    );

    control
        .catalog()
        .delete(&the_repo())
        .expect("delete the repository");

    let after = exec(&session, &upload(&the_repo())).await.expect("exec");
    let said = git_says(&after).unwrap_or_else(|| {
        panic!("a session holding Admin on a deleted repository heard nothing: {after:?}")
    });
    assert!(
        said.contains(&the_repo().to_string()),
        "the refusal does not name the repository: {said:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **A whole namespace deleted mid-session**, which is the same seam one level
/// up and the one the sweep named by hand.
///
/// `delete_namespace(.., true)` takes the repositories with it, so the exec
/// that follows resolves nothing. Separate from the repository case because the
/// two go through different catalog writes and only one of them was reachable
/// from the test suite.
#[tokio::test]
async fn a_namespace_deleted_mid_session_takes_its_repositories_with_it() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Admin);
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;
    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");

    let before = exec(&session, &upload(&the_repo())).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "the fixture cannot fetch before the namespace goes, so nothing below is measured: \
         {before:?}"
    );

    let taken = control
        .catalog()
        .delete_namespace(the_repo().namespace(), true)
        .expect("delete the namespace and its repositories");
    assert_eq!(
        taken.len(),
        1,
        "the namespace delete took {} repositories with it, so the fixture is not the \
         shape this test describes",
        taken.len()
    );

    let after = exec(&session, &upload(&the_repo())).await.expect("exec");
    let said = git_says(&after).unwrap_or_else(|| {
        panic!("a session whose namespace was deleted under it heard nothing: {after:?}")
    });
    assert!(
        said.contains(&the_repo().to_string()),
        "the refusal does not name what was asked for: {said:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

// ─────────────────────────────────────────────────────────────────────────────
// 3. The key itself
// ─────────────────────────────────────────────────────────────────────────────

/// **A key revoked mid-session loses the session it already had.**
///
/// This was the opposite position until 2026-09-11, written down here at
/// length and honestly: `auth_publickey` runs once per *connection*, cached the
/// `Principal` on the session, and every later `exec_request` authorised out of
/// that cache, so revoking a key did not stop the channels it had opened. The
/// note ended by naming exactly what it cost — *"revoking a key does not evict
/// a session that key opened. An operator revoking a compromised key must also
/// drop the connections — gunnar has no verb for that today"* — and that is the
/// gap, not a nuance of it. git over SSH holds one connection open for as long
/// as a client wants to keep pushing, and an operator who revokes a leaked key
/// and watches it stop working on their own next connection has every reason to
/// believe it stopped working everywhere.
///
/// The appeal to OpenSSH does not carry the weight it looks like it does.
/// Removing a line from `authorized_keys` indeed disconnects nobody — but
/// OpenSSH hands the session to a shell and has nothing left to re-check at,
/// while gunnar authorises **every exec** against a live control plane and was
/// already re-reading the grant, the repository and the namespace there. The
/// fingerprint was the one input on that list that it chose to remember.
/// `DocRef::read` caches nothing, so the table was current the whole time;
/// nothing asked it.
///
/// Both halves are still asserted, for the reason the old note gave and which
/// did not change with the position:
///
/// * the exec BEFORE the revocation is accepted — so a refusal afterwards is
///   caused by the revocation and not by a fixture that never worked, and it is
///   the same `live_principal` path answering yes to a live key;
/// * a **new** connection with the revoked key is refused — so the revocation
///   is real rather than something the first half could be mistaking for one.
#[tokio::test]
async fn a_revoked_key_loses_the_session_it_already_opened() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Write);
    let control = plane.control.clone();
    let fixture = start(control.clone(), None).await;
    let addr = fixture.server.local_addr();

    let session = authenticate(addr, &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");
    let before = exec(&session, &receive(&the_repo())).await.expect("exec");
    assert!(
        matches!(before, ExecOutcome::Accepted { .. }) && git_says(&before).is_none(),
        "the fixture cannot push before the revocation: {before:?}"
    );

    let fingerprint = common::fingerprint_of(&key);
    assert!(
        control
            .auth()
            .revoke_key(&fingerprint)
            .expect("revoke")
            .is_some(),
        "the fingerprint this test revoked is not the one the table holds, so nothing was \
         revoked and both halves below would be vacuous"
    );

    // Half one: the open session stops. THE POSITION.
    //
    // Asserted as the sentence the client can SEE, not as the exit status: an
    // `ExitStatus(1)` and a silent hang-up look identical to the person holding
    // the terminal, which is the bar every case in this file clears.
    let after = exec(&session, &receive(&the_repo())).await.expect("exec");
    let said = git_says(&after).unwrap_or_else(|| {
        panic!(
            "an exec on a session whose key was revoked was answered without a word the \
             client can read: {after:?}"
        )
    });
    assert_eq!(
        said,
        gunnar_ssh::server::REVOKED_MESSAGE,
        "the refusal reached the client, but not as the one sentence the server has for \
         this — so what a user is told is decided at the refusal site and not in one place"
    );

    // Half two: a new connection is refused. Without this the first half is
    // just a revocation that does nothing.
    assert!(
        authenticate(addr, &key, "git")
            .await
            .expect("the connection itself must succeed")
            .is_none(),
        "a revoked key opened a NEW session. The revocation is a no-op and the position \
         above is a hole"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **An expired key does not open an SSH session**, over a real handshake.
///
/// `gunnar-core`'s own tests cover `is_valid_at`; this covers the path that
/// uses it, and the reason it is worth a separate test is that the same gap was
/// **real on the neighbouring path this morning**: `key_directory` built the
/// ssh-sig gate's key material from `AuthTable::keys()`, which filters nothing,
/// so one credential had two lifetimes and the longer one was written down
/// nowhere. `Control::principal_for_key` goes through
/// `principal_by_fingerprint`, which delegates to `..._at(now_ms())` and checks
/// the window — so this path was already right, and this test is what says so
/// rather than assuming it.
///
/// Both directions, because a server that refused every key would satisfy the
/// expired arm alone.
#[tokio::test]
async fn an_expired_key_does_not_open_a_session() {
    let live = mint_key();
    let dead = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("principal-1")
        .with_key("principal-1", &live)
        .with_grant("principal-1", Access::Write);
    // Expired an hour ago. Registered through the same door an operator uses,
    // so nothing here reaches past the API into the document.
    let an_hour_ago = gunnar_core::now_ms() - 3_600_000;
    plane
        .auth()
        .register_key(
            "principal-1",
            &common::authorized_keys_line(&dead),
            Some(an_hour_ago),
        )
        .expect("register an already-expired key");
    let fixture = start(plane.control.clone(), None).await;
    let addr = fixture.server.local_addr();

    assert!(
        authenticate(addr, &dead, "git")
            .await
            .expect("the connection itself must succeed")
            .is_none(),
        "an expired key opened an SSH session. Its principal, its grant and its \
         registration are all intact; the only thing wrong with it is the clock"
    );
    // The control. Same principal, same grant, a key that has not expired.
    assert!(
        authenticate(addr, &live, "git")
            .await
            .expect("connect")
            .is_some(),
        "the unexpired key was refused too, so the refusal above is not about expiry"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}

/// **A key that belongs to another principal authenticates as that other
/// principal, never as the one holding the grant.**
///
/// The SSH username is decorative (`the_ssh_username_is_decorative` in
/// `tests/auth.rs` pins that from the other side); this pins the consequence.
/// Two principals, one with the grant and one without, and the key of the one
/// without: the session must be refused however the username is spelled,
/// **including when it is spelled as the principal that does hold the grant**.
///
/// That last spelling is the whole point. A server that let the username select
/// the identity would serve this, and every other test in the suite would stay
/// green because they all connect as `git`.
///
/// # Seen red
///
/// Make `auth_publickey` resolve the principal by the offered username rather
/// than by the fingerprint and this fails on the `holder` arm being served.
#[tokio::test]
async fn a_key_cannot_borrow_the_identity_its_username_names() {
    let holder_key = mint_key();
    let stranger_key = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("holder")
        .with_key("holder", &holder_key)
        .with_grant("holder", Access::Admin)
        .with_principal("stranger")
        .with_key("stranger", &stranger_key);
    let fixture = start(plane.control.clone(), None).await;
    let addr = fixture.server.local_addr();

    // The control: the holder's own key works, so the grant is real.
    let holder = authenticate(addr, &holder_key, "git")
        .await
        .expect("connect")
        .expect("the holder authenticates");
    let served = exec(&holder, &upload(&the_repo())).await.expect("exec");
    assert!(
        matches!(served, ExecOutcome::Accepted { .. }) && git_says(&served).is_none(),
        "the holder cannot reach the repository it holds Admin on: {served:?}"
    );

    // Every username a stranger might try, including the holder's id.
    for user in ["git", "holder", "root", "stranger"] {
        let session = authenticate(addr, &stranger_key, user)
            .await
            .expect("connect")
            .unwrap_or_else(|| {
                panic!("the stranger's key is registered; it must authenticate as {user:?}")
            });
        let outcome = exec(&session, &upload(&the_repo())).await.expect("exec");
        let said = git_says(&outcome).unwrap_or_else(|| {
            panic!(
                "connecting as {user:?} with the stranger's key was SERVED, or refused in \
                 silence. The SSH username must not reach an authorisation decision: \
                 {outcome:?}"
            )
        });
        assert!(
            said.contains("stranger") || said.contains(&the_repo().to_string()),
            "the refusal for user {user:?} names neither the principal nor the repository: \
             {said:?}"
        );
        assert!(
            !said.contains("holds admin"),
            "the refusal for user {user:?} reports the HOLDER's level, so the username \
             selected the identity: {said:?}"
        );
    }

    fixture.server.shutdown("done").await.expect("shutdown");
}

// ─────────────────────────────────────────────────────────────────────────────
// 3. The connection itself
// ─────────────────────────────────────────────────────────────────────────────

/// **A connection may not offer keys without end.**
///
/// `SshConfig::max_auth_attempts` was set, handed to russh's `Config`, and read
/// by nobody: russh 0.61.2 declares the field, defaults it to 10 and
/// `Debug`-prints it, and the three occurrences in that crate's source are
/// exactly those three. So the number in gunnar's config was documentation. One
/// TCP connection could walk a dictionary of public keys past the identity
/// table at `auth_rejection_time` apiece, without ever re-establishing
/// anything — which is also the cheapest way to learn WHICH keys a server
/// knows, since an accepted offer is distinguishable from a refused one by
/// definition.
///
/// Three things are asserted, and the third is what makes the first two mean
/// something:
///
/// * the server stops answering at the limit — the connection is dropped, not
///   merely refused, because a refusal leaves the next offer available;
/// * a client that stays UNDER the limit is answered every time — so the limit
///   is a limit and not a server that hangs up on strangers;
/// * a good key inside the budget still authenticates — so nothing here made
///   the server refuse the traffic it exists for.
///
/// `auth_rejection_time` is zeroed because the fixture would otherwise pay
/// russh's real constant-time delay per offer, and this test says nothing about
/// that delay.
#[tokio::test]
async fn a_connection_may_not_offer_keys_without_end() {
    const LIMIT: usize = 3;

    let good = common::mint_key();
    let plane = plane_with_key(&good, Access::Write);
    let mut config = gunnar_ssh::SshConfig::ephemeral_loopback();
    config.max_auth_attempts = LIMIT;
    config.auth_rejection_time = std::time::Duration::ZERO;
    let fixture = common::start_with_config(
        plane.control,
        config,
        gunnar_ssh::seam::BannerTransport::new(),
    )
    .await;
    let addr = fixture.server.local_addr();

    // Over the limit: strangers, one connection.
    let strangers: Vec<_> = (0..LIMIT + 4).map(|_| common::mint_key()).collect();
    let (answered, accepted, error) = common::offer_keys(addr, &strangers, "git").await;
    assert!(!accepted, "a key nobody registered authenticated");
    assert!(
        error.is_some(),
        "the server answered all {answered} offers and kept the connection open. \
         `max_auth_attempts` is {LIMIT} and nothing is enforcing it"
    );
    assert!(
        answered <= LIMIT,
        "the server answered {answered} offers before dropping the connection, and the \
         limit is {LIMIT}"
    );

    // Under the limit: the same strangers, fewer of them, all answered.
    let (answered, accepted, error) =
        common::offer_keys(addr, &strangers[..LIMIT - 1], "git").await;
    assert_eq!(
        (answered, accepted, error.is_none()),
        (LIMIT - 1, false, true),
        "a client that stayed inside the budget was cut off anyway, so the limit above is \
         a server that hangs up on strangers rather than a limit"
    );

    // And the traffic the server exists for still works, with refusals ahead of
    // it in the same connection.
    let mut mixed = strangers[..LIMIT - 1].to_vec();
    mixed.push(good);
    let (answered, accepted, error) = common::offer_keys(addr, &mixed, "git").await;
    assert!(
        accepted && error.is_none(),
        "a registered key offered as attempt {answered} of {LIMIT} was not accepted: \
         {error:?}"
    );

    fixture.server.shutdown("done").await.expect("shutdown");
}