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
//! Publickey authentication, over a real SSH handshake, in both directions
//! (the ACL model).
//!
//! Every assertion here goes through `russh`'s own client against gunnar's
//! server on a real socket: a real key exchange, a real signature, and a real
//! `SSH_MSG_USERAUTH_*` exchange. Asserting that `auth_publickey` returned
//! `Auth::Accept` would prove that a function returned a value, not that a key
//! authenticates.
//!
//! Since the merge the key table on the far side is `gunnar-core`'s real
//! [`AuthTable`](gunnar_core::AuthTable), not a `BTreeMap` the test populated,
//! so `a_registered_key_authenticates` now also proves the `ssh-key` crossing:
//! a key registered as an `authorized_keys` line and parsed by
//! `ssh-key 0.6.7` is found again by the fingerprint computed from
//! `ssh-key 0.7.0-rc.10`'s wire blob. See
//! `the_fingerprint_russh_computes_is_the_one_the_auth_table_stored`.

mod common;

use std::time::Duration;

use gunnar_ssh::Access;

use common::{
    authenticate, authorized_keys_line, fingerprint_of, mint_key, plane_with_key, start,
    start_with_key, Plane,
};

/// **THE test the merge and the `ssh-key` crossing exist to make possible.**
///
/// `gunnar-ssh` links `ssh-key 0.7.0-rc.10` (russh pins it exactly) and
/// `gunnar-core` links `0.6.7`. They are semver-incompatible and both are in
/// the graph, so no key *value* can be passed between them. The fingerprint is
/// therefore defined over the wire **blob**, and this asserts the two ends
/// agree on it: computed one way, stored the other, and matched by an actual
/// lookup rather than by a string comparison a test arranged.
#[test]
fn the_fingerprint_russh_computes_is_the_one_the_auth_table_stored() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Read);

    // What `auth_publickey` will compute, from russh's key type.
    let offered = fingerprint_of(&key);

    // What the identity table stored, from the `authorized_keys` line, through
    // `gunnar-core`'s ssh-key.
    let stored = plane.auth().keys().expect("keys");
    assert_eq!(stored.len(), 1);
    assert_eq!(
        stored[0].fingerprint_sha256,
        offered.as_str(),
        "the fingerprint russh's ssh-key 0.7 produces is not the one gunnar-core's 0.6 stored. \
         The version split is not bridged and no key could ever authenticate"
    );

    // And the lookup itself succeeds, which is the part that actually gates a
    // session. A matching string with a broken lookup would still lock everyone
    // out.
    let found = plane
        .control
        .principal_for_key(&offered)
        .expect("the table is readable")
        .expect("the offered key must identify its principal");
    assert_eq!(found.id, "principal-1");

    // The red half: a key that was never registered must not resolve, so the
    // assertion above is about *this* key and not about the table answering
    // everything.
    let stranger = mint_key();
    assert!(
        plane
            .control
            .principal_for_key(&fingerprint_of(&stranger))
            .expect("readable")
            .is_none(),
        "an unregistered key identified somebody"
    );
    assert_ne!(fingerprint_of(&stranger), offered);
}

#[tokio::test]
async fn a_registered_key_authenticates() {
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Write).await;
    let addr = fixture.server.local_addr();

    let session = authenticate(addr, &key, "git")
        .await
        .expect("connect")
        .expect("a registered key must authenticate");
    drop(session);

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

#[tokio::test]
async fn an_unregistered_key_is_refused() {
    let registered = mint_key();
    let stranger = mint_key();
    assert_ne!(
        fingerprint_of(&registered).as_str(),
        fingerprint_of(&stranger).as_str()
    );

    let fixture = start_with_key(&registered, Access::Write).await;
    let addr = fixture.server.local_addr();

    let outcome = authenticate(addr, &stranger, "git").await.expect("connect");
    assert!(
        outcome.is_none(),
        "an unregistered key must NOT authenticate"
    );

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

/// Measure, never assert: prove the refusal above is the identity table's doing.
///
/// Same server, same client, same *previously unregistered* key; the only
/// change is that the key is now in the table. `an_unregistered_key_is_refused`
/// could be passing for some other reason: a broken client, a server that never
/// completes a handshake, a fingerprint the table can never match. If it
/// were, this would fail too.
///
/// This replaces the `AlwaysAcceptDirectory` double that the deleted seam
/// carried, and is a stronger proof than that was: the accept-everything double
/// could pass while the *real* directory was unusable, which is exactly the
/// failure mode the version split threatened.
#[tokio::test]
async fn registering_that_same_key_turns_the_refusal_green() {
    let stranger = mint_key();
    let plane = Plane::empty()
        .with_the_repo()
        .with_principal("principal-1")
        .with_key("principal-1", &stranger)
        .with_grant("principal-1", Access::Read);

    let fixture = start(plane.control, None).await;
    let outcome = authenticate(fixture.server.local_addr(), &stranger, "git")
        .await
        .expect("connect");
    assert!(
        outcome.is_some(),
        "the very same key must authenticate once it is registered, or the refusal test \
         proves nothing about the identity table"
    );

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

#[tokio::test]
async fn the_ssh_username_is_decorative() {
    // The ACL model: `git@host:ns/repo.git` works and `git` means nothing. The same
    // key authenticates whatever name it claims, and a *different* key does not
    // authenticate by claiming a name that is registered.
    let key = mint_key();
    let fixture = start_with_key(&key, Access::Read).await;
    let addr = fixture.server.local_addr();

    for user in ["git", "alice", "root", "", "principal-1"] {
        let session = authenticate(addr, &key, user).await.expect("connect");
        assert!(
            session.is_some(),
            "the username {user:?} should have made no difference"
        );
    }

    let stranger = mint_key();
    assert!(
        authenticate(addr, &stranger, "principal-1")
            .await
            .expect("connect")
            .is_none(),
        "claiming a registered principal's name must not authenticate an unregistered key"
    );

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

#[tokio::test]
async fn revoking_a_key_stops_it_authenticating() {
    // Through `AuthTable::revoke_key` (the real thing `Access.RevokeKey` will
    // call) against a live listener, rather than by building a second table
    // without the key in it.
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Read);
    let auth = plane.auth().clone();
    let fixture = start(plane.control, None).await;
    let addr = fixture.server.local_addr();

    assert!(
        authenticate(addr, &key, "git")
            .await
            .expect("connect")
            .is_some(),
        "a registered key must authenticate before it is revoked"
    );

    let revoked = auth
        .revoke_key(&fingerprint_of(&key))
        .expect("revoke")
        .expect("the key was there to revoke");
    assert_eq!(revoked.openssh.trim(), authorized_keys_line(&key).trim());

    assert!(
        authenticate(addr, &key, "git")
            .await
            .expect("connect")
            .is_none(),
        "a revoked key must not authenticate, on the SAME running listener"
    );

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

/// A key whose principal was removed authenticates nobody, even though the
/// listener never learned about the removal.
#[tokio::test]
async fn removing_the_principal_stops_its_key_authenticating() {
    let key = mint_key();
    let plane = plane_with_key(&key, Access::Read);
    let auth = plane.auth().clone();
    let fixture = start(plane.control, None).await;
    let addr = fixture.server.local_addr();

    assert!(authenticate(addr, &key, "git")
        .await
        .expect("connect")
        .is_some());

    auth.remove_principal("principal-1").expect("remove");

    assert!(
        authenticate(addr, &key, "git")
            .await
            .expect("connect")
            .is_none(),
        "a key that outlives its principal is an account that still authenticates"
    );

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

#[tokio::test]
async fn an_authenticated_session_that_never_execs_is_disconnected() {
    // The leak the inactivity timeout does not catch: an authenticated session
    // that opens a channel and then sits there.
    let key = mint_key();
    let fixture = start(
        plane_with_key(&key, Access::Read).control,
        Some(Duration::from_millis(300)),
    )
    .await;

    let session = authenticate(fixture.server.local_addr(), &key, "git")
        .await
        .expect("connect")
        .expect("authenticate");
    let mut channel = session
        .channel_open_session()
        .await
        .expect("open a session channel");

    // Never exec. The watchdog must end the session; `wait()` returning `None`
    // is the channel going away with it.
    let ended = tokio::time::timeout(Duration::from_secs(5), async {
        while channel.wait().await.is_some() {}
    })
    .await;
    assert!(
        ended.is_ok(),
        "the session was still alive well after the exec grace expired"
    );

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