Lines
62.03 %
Functions
71.43 %
Branches
100 %
//! Authentication handler for the SSH daemon.
//!
//! Implements the two paths the threat model permits:
//! - **Publickey** (preferred). Fingerprint the offered key, look
//! it up via `LookupUserBySshKey`; the server command already
//! gates on `ssh_enabled = TRUE`.
//! - **Password** (opt-in, rate-limited). Consult the per-IP and
//! per-account limiters, call `verify_user_password`, and require
//! the resolved user has `ssh_enabled = TRUE`.
//! Every accept / reject decision logs `peer_ip`, `user`, `method`,
//! `outcome` at `info` so operators can audit attempts.
use crate::rate_limit::{Clock, Decision, RateLimiter};
use server::command::CmdResult;
use server::command::ssh_key::{LookupUserBySshKey, UserHasSshKey};
use server::command::user::{VerifiedUser, verify_user_password};
use sqlx::types::Uuid;
use std::net::IpAddr;
use std::sync::Arc;
use tokio::sync::Mutex;
/// Outcome the SSH server loop acts on — map this to
/// `russh::server::Auth::{Accept, reject()}` at the call site.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Outcome {
Accept(Uuid),
Reject,
}
/// Public-key authentication. Always respects the `ssh_enabled`
/// gate (that's enforced server-side by `LookupUserBySshKey`).
/// Consults the per-IP pubkey bucket of the rate limiter so a
/// single peer cannot enumerate fingerprints unbounded; the bucket
/// is sized for normal clients that offer several identities per
/// connection (see [`crate::rate_limit::Config`]).
///
/// # Errors
/// Returns `Err` on infrastructure failure (DB down).
pub async fn authenticate_publickey<C: Clock>(
user: &str,
fingerprint: &str,
peer: IpAddr,
limiter: &Arc<Mutex<RateLimiter<C>>>,
) -> Result<Outcome, server::command::CmdError> {
{
let mut rl = limiter.lock().await;
if rl.check_pubkey_ip(peer) == Decision::Deny {
log::info!("auth reject method=publickey user={user} peer={peer} reason=rate-limit");
return Ok(Outcome::Reject);
let result = LookupUserBySshKey::new()
.fingerprint(fingerprint.to_string())
.run()
.await?;
if let Some(CmdResult::Uuid(uid)) = result {
log::info!("auth accept method=publickey user={user} peer={peer} user_id={uid}");
Ok(Outcome::Accept(uid))
} else {
log::info!("auth reject method=publickey user={user} peer={peer}");
Ok(Outcome::Reject)
/// Password authentication. Consults both limiters; records a
/// failure on wrong-password; resets counters on success.
/// Returns `Err` on infrastructure failure (DB down). Denied-by-
/// rate-limit, wrong password, and `ssh_enabled=false` all resolve
/// to `Ok(Outcome::Reject)` so timing / error shape don't leak
/// which one triggered.
pub async fn authenticate_password<C: Clock>(
password: &str,
if rl.check_ip(peer) == Decision::Deny || rl.check_account(user) == Decision::Deny {
log::info!("auth reject method=password user={user} peer={peer} reason=rate-limit");
match verify_user_password(user, password).await {
Ok(Some(VerifiedUser {
user_id,
ssh_enabled: true,
})) => {
// Bootstrap-only: once any key is on file, password auth
// is permanently rejected for this user. Treat the
// attempt as a failure for limiter accounting so a
// determined attacker can't probe the gate cheaply.
if let Ok(Some(CmdResult::Bool(true))) =
UserHasSshKey::new().user_id(user_id).run().await
rl.record_password_failure(user);
log::info!(
"auth reject method=password user={user} peer={peer} reason=key_already_registered"
);
rl.reset_account(user);
log::info!("auth accept method=password user={user} peer={peer} user_id={user_id}");
Ok(Outcome::Accept(user_id))
Ok(Some(_) | None) => {
log::info!("auth reject method=password user={user} peer={peer}");
Err(e) => {
log::error!("auth error method=password user={user} peer={peer} err={e:?}");
Err(server::command::CmdError::Args(e.to_string()))
#[cfg(test)]
mod tests {
//! Auth handler tests focus on the rate-limit + outcome-shaping
//! logic. DB-backed flows are covered by the `server::command::
//! user` and `server::command::ssh_key` test suites. These tests
//! don't hit `verify_user_password` / `LookupUserBySshKey`
//! directly — they exercise [`authenticate_password`]'s limiter
//! path via a rigged limiter that always refuses.
use super::*;
use crate::rate_limit::{Config, InstantClock};
use std::net::Ipv4Addr;
use std::time::Duration;
fn loopback() -> IpAddr {
IpAddr::V4(Ipv4Addr::LOCALHOST)
fn limiter_denying_every_ip() -> Arc<Mutex<RateLimiter<InstantClock>>> {
// Zero IP attempts allowed, so the first check always denies.
let cfg = Config {
max_attempts_per_ip: 0,
max_pubkey_attempts_per_ip: 0,
window: Duration::from_mins(1),
account_lockout_threshold: 100,
account_lockout_duration: Duration::from_mins(1),
};
Arc::new(Mutex::new(RateLimiter::new(cfg, InstantClock)))
#[tokio::test]
async fn password_rejected_by_rate_limit_without_hitting_verify() {
// If this test reached `verify_user_password`, we'd need a DB
// and would hang / fail. Rate-limit short-circuits it, so the
// call resolves without I/O.
let lim = limiter_denying_every_ip();
let outcome = authenticate_password("anyone", "any-pw", loopback(), &lim)
.await
.unwrap();
assert_eq!(outcome, Outcome::Reject);
async fn publickey_rejected_by_rate_limit_without_hitting_db() {
// Same shape as the password test: a saturated pubkey
// limiter must short-circuit before LookupUserBySshKey would
// need a connection.
let outcome = authenticate_publickey("anyone", "SHA256:never-considered", loopback(), &lim)