crates/gunnar-ssh/tests/auth.rs
//! 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(®istered).as_str(),
fingerprint_of(&stranger).as_str()
);
let fixture = start_with_key(®istered, 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");
}