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
2
pub async fn authenticate_publickey<C: Clock>(
43
2
    user: &str,
44
2
    fingerprint: &str,
45
2
    peer: IpAddr,
46
2
    limiter: &Arc<Mutex<RateLimiter<C>>>,
47
2
) -> Result<Outcome, server::command::CmdError> {
48
    {
49
2
        let mut rl = limiter.lock().await;
50
2
        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
1
        }
54
    }
55
    // Logged here because the handler reports this failure as a plain
56
    // `Disconnect`, which is indistinguishable from a probe hanging up and is
57
    // logged at debug: without this line a directory outage that fails every
58
    // key login leaves no trace. The password path logs the same way.
59
1
    let result = LookupUserBySshKey::new()
60
1
        .fingerprint(fingerprint.to_string())
61
1
        .run()
62
1
        .await
63
1
        .inspect_err(|e| {
64
            log::error!("auth error method=publickey user={user} peer={peer} err={e:?}");
65
        })?;
66
1
    if let Some(CmdResult::Uuid(uid)) = result {
67
1
        log::info!("auth accept method=publickey user={user} peer={peer} user_id={uid}");
68
1
        Ok(Outcome::Accept(uid))
69
    } else {
70
        log::info!("auth reject method=publickey user={user} peer={peer}");
71
        Ok(Outcome::Reject)
72
    }
73
2
}
74

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

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

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

            
149
2
    fn loopback() -> IpAddr {
150
2
        IpAddr::V4(Ipv4Addr::LOCALHOST)
151
2
    }
152

            
153
2
    fn limiter_denying_every_ip() -> Arc<Mutex<RateLimiter<InstantClock>>> {
154
        // Zero IP attempts allowed, so the first check always denies.
155
2
        let cfg = Config {
156
2
            max_attempts_per_ip: 0,
157
2
            max_pubkey_attempts_per_ip: 0,
158
2
            window: Duration::from_mins(1),
159
2
            account_lockout_threshold: 100,
160
2
            account_lockout_duration: Duration::from_mins(1),
161
2
        };
162
2
        Arc::new(Mutex::new(RateLimiter::new(cfg, InstantClock)))
163
2
    }
164

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

            
177
    #[tokio::test]
178
1
    async fn publickey_rejected_by_rate_limit_without_hitting_db() {
179
        // Same shape as the password test: a saturated pubkey
180
        // limiter must short-circuit before LookupUserBySshKey would
181
        // need a connection.
182
1
        let lim = limiter_denying_every_ip();
183
1
        let outcome = authenticate_publickey("anyone", "SHA256:never-considered", loopback(), &lim)
184
1
            .await
185
1
            .unwrap();
186
1
        assert_eq!(outcome, Outcome::Reject);
187
1
    }
188
}