1
//! Authentication handler for the SSH daemon.
2
//!
3
//! Implements the two paths the threat model permits:
4
//!
5
//! - **Publickey** (preferred). Fingerprint the offered key, look
6
//!   it up via `LookupUserBySshKey`; the server command already
7
//!   gates on `ssh_enabled = TRUE`.
8
//! - **Password** (opt-in, rate-limited). Consult the per-IP and
9
//!   per-account limiters, call `verify_user_password`, and require
10
//!   the resolved user has `ssh_enabled = TRUE`.
11
//!
12
//! Every accept / reject decision logs `peer_ip`, `user`, `method`,
13
//! `outcome` at `info` so operators can audit attempts.
14

            
15
use crate::rate_limit::{Clock, Decision, RateLimiter};
16
use server::command::CmdResult;
17
use server::command::ssh_key::{LookupUserBySshKey, UserHasSshKey};
18
use server::command::user::{VerifiedUser, verify_user_password};
19
use sqlx::types::Uuid;
20
use std::net::IpAddr;
21
use std::sync::Arc;
22
use tokio::sync::Mutex;
23

            
24
/// Outcome the SSH server loop acts on — map this to
25
/// `russh::server::Auth::{Accept, reject()}` at the call site.
26
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27
pub enum Outcome {
28
    Accept(Uuid),
29
    Reject,
30
}
31

            
32
/// Public-key authentication. Always respects the `ssh_enabled`
33
/// gate (that's enforced server-side by `LookupUserBySshKey`).
34
/// Consults the per-IP pubkey bucket of the rate limiter so a
35
/// single peer cannot enumerate fingerprints unbounded; the bucket
36
/// is sized for normal clients that offer several identities per
37
/// connection (see [`crate::rate_limit::Config`]).
38
///
39
/// # Errors
40
///
41
/// Returns `Err` on infrastructure failure (DB down).
42
1
pub async fn authenticate_publickey<C: Clock>(
43
1
    user: &str,
44
1
    fingerprint: &str,
45
1
    peer: IpAddr,
46
1
    limiter: &Arc<Mutex<RateLimiter<C>>>,
47
1
) -> Result<Outcome, server::command::CmdError> {
48
    {
49
1
        let mut rl = limiter.lock().await;
50
1
        if rl.check_pubkey_ip(peer) == Decision::Deny {
51
1
            log::info!("auth reject method=publickey user={user} peer={peer} reason=rate-limit");
52
1
            return Ok(Outcome::Reject);
53
        }
54
    }
55
    let result = LookupUserBySshKey::new()
56
        .fingerprint(fingerprint.to_string())
57
        .run()
58
        .await?;
59
    if let Some(CmdResult::Uuid(uid)) = result {
60
        log::info!("auth accept method=publickey user={user} peer={peer} user_id={uid}");
61
        Ok(Outcome::Accept(uid))
62
    } else {
63
        log::info!("auth reject method=publickey user={user} peer={peer}");
64
        Ok(Outcome::Reject)
65
    }
66
1
}
67

            
68
/// Password authentication. Consults both limiters; records a
69
/// failure on wrong-password; resets counters on success.
70
///
71
/// # Errors
72
///
73
/// Returns `Err` on infrastructure failure (DB down). Denied-by-
74
/// rate-limit, wrong password, and `ssh_enabled=false` all resolve
75
/// to `Ok(Outcome::Reject)` so timing / error shape don't leak
76
/// which one triggered.
77
1
pub async fn authenticate_password<C: Clock>(
78
1
    user: &str,
79
1
    password: &str,
80
1
    peer: IpAddr,
81
1
    limiter: &Arc<Mutex<RateLimiter<C>>>,
82
1
) -> Result<Outcome, server::command::CmdError> {
83
    {
84
1
        let mut rl = limiter.lock().await;
85
1
        if rl.check_ip(peer) == Decision::Deny || rl.check_account(user) == Decision::Deny {
86
1
            log::info!("auth reject method=password user={user} peer={peer} reason=rate-limit");
87
1
            return Ok(Outcome::Reject);
88
        }
89
    }
90

            
91
    match verify_user_password(user, password).await {
92
        Ok(Some(VerifiedUser {
93
            user_id,
94
            ssh_enabled: true,
95
        })) => {
96
            // Bootstrap-only: once any key is on file, password auth
97
            // is permanently rejected for this user. Treat the
98
            // attempt as a failure for limiter accounting so a
99
            // determined attacker can't probe the gate cheaply.
100
            if let Ok(Some(CmdResult::Bool(true))) =
101
                UserHasSshKey::new().user_id(user_id).run().await
102
            {
103
                let mut rl = limiter.lock().await;
104
                rl.record_password_failure(user);
105
                log::info!(
106
                    "auth reject method=password user={user} peer={peer} reason=key_already_registered"
107
                );
108
                return Ok(Outcome::Reject);
109
            }
110
            let mut rl = limiter.lock().await;
111
            rl.reset_account(user);
112
            log::info!("auth accept method=password user={user} peer={peer} user_id={user_id}");
113
            Ok(Outcome::Accept(user_id))
114
        }
115
        Ok(Some(_) | None) => {
116
            let mut rl = limiter.lock().await;
117
            rl.record_password_failure(user);
118
            log::info!("auth reject method=password user={user} peer={peer}");
119
            Ok(Outcome::Reject)
120
        }
121
        Err(e) => {
122
            log::error!("auth error method=password user={user} peer={peer} err={e:?}");
123
            Err(server::command::CmdError::Args(e.to_string()))
124
        }
125
    }
126
1
}
127

            
128
#[cfg(test)]
129
mod tests {
130
    //! Auth handler tests focus on the rate-limit + outcome-shaping
131
    //! logic. DB-backed flows are covered by the `server::command::
132
    //! user` and `server::command::ssh_key` test suites. These tests
133
    //! don't hit `verify_user_password` / `LookupUserBySshKey`
134
    //! directly — they exercise [`authenticate_password`]'s limiter
135
    //! path via a rigged limiter that always refuses.
136
    use super::*;
137
    use crate::rate_limit::{Config, InstantClock};
138
    use std::net::Ipv4Addr;
139
    use std::time::Duration;
140

            
141
2
    fn loopback() -> IpAddr {
142
2
        IpAddr::V4(Ipv4Addr::LOCALHOST)
143
2
    }
144

            
145
2
    fn limiter_denying_every_ip() -> Arc<Mutex<RateLimiter<InstantClock>>> {
146
        // Zero IP attempts allowed, so the first check always denies.
147
2
        let cfg = Config {
148
2
            max_attempts_per_ip: 0,
149
2
            max_pubkey_attempts_per_ip: 0,
150
2
            window: Duration::from_mins(1),
151
2
            account_lockout_threshold: 100,
152
2
            account_lockout_duration: Duration::from_mins(1),
153
2
        };
154
2
        Arc::new(Mutex::new(RateLimiter::new(cfg, InstantClock)))
155
2
    }
156

            
157
    #[tokio::test]
158
1
    async fn password_rejected_by_rate_limit_without_hitting_verify() {
159
        // If this test reached `verify_user_password`, we'd need a DB
160
        // and would hang / fail. Rate-limit short-circuits it, so the
161
        // call resolves without I/O.
162
1
        let lim = limiter_denying_every_ip();
163
1
        let outcome = authenticate_password("anyone", "any-pw", loopback(), &lim)
164
1
            .await
165
1
            .unwrap();
166
1
        assert_eq!(outcome, Outcome::Reject);
167
1
    }
168

            
169
    #[tokio::test]
170
1
    async fn publickey_rejected_by_rate_limit_without_hitting_db() {
171
        // Same shape as the password test: a saturated pubkey
172
        // limiter must short-circuit before LookupUserBySshKey would
173
        // need a connection.
174
1
        let lim = limiter_denying_every_ip();
175
1
        let outcome = authenticate_publickey("anyone", "SHA256:never-considered", loopback(), &lim)
176
1
            .await
177
1
            .unwrap();
178
1
        assert_eq!(outcome, Outcome::Reject);
179
1
    }
180
}