Skip to main content

obby_client/
sasl.rs

1//! SASL authentication.
2//!
3//! The exchange runs inside capability negotiation: `CAP END` must not be sent while it is in
4//! flight, because a server that sees it aborts with 906 and registers the connection
5//! unauthenticated.
6
7use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use base64::Engine as _;
10use base64::engine::general_purpose::STANDARD as BASE64;
11
12/// The most base64 one `AUTHENTICATE` line may carry.
13const CHUNK: usize = 400;
14
15/// What to authenticate with.
16#[derive(Clone, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
19#[cfg_attr(feature = "ts", ts(rename = "SaslCredentials"))]
20#[cfg_attr(feature = "serde", serde(tag = "mechanism", rename_all = "snake_case"))]
21#[non_exhaustive]
22pub enum Credentials {
23    /// A username and password in the clear, so only over TLS.
24    Plain {
25        /// The account to log in as.
26        username: String,
27        /// Its password.
28        password: String,
29    },
30    /// Authenticate with the TLS client certificate the host already presented.
31    External,
32    /// Prove we know the password without sending it, per RFC 7677.
33    ///
34    /// Preferred over [`Credentials::Plain`] wherever the server offers it: the password never
35    /// crosses the wire, a recording of the exchange cannot be replayed, and the server has to
36    /// prove it knows the password too.
37    Scram {
38        /// The account to log in as.
39        username: String,
40        /// Its password.
41        password: String,
42        /// Unpredictable bytes, never reused. The core has no entropy source, so the host supplies
43        /// this, and reusing one destroys the replay protection the mechanism exists for.
44        nonce: String,
45    },
46}
47
48impl core::fmt::Debug for Credentials {
49    /// Deliberately hand-written: a derived one would print the password, and this type is reachable
50    /// from `Config`, which a host is likely to log while working out why a connection failed.
51    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52        match self {
53            Self::Plain { username, .. } => f
54                .debug_struct("Plain")
55                .field("username", username)
56                .field("password", &"<redacted>")
57                .finish(),
58            Self::External => f.write_str("External"),
59            Self::Scram { username, .. } => f
60                .debug_struct("Scram")
61                .field("username", username)
62                .field("password", &"<redacted>")
63                .finish_non_exhaustive(),
64        }
65    }
66}
67
68impl Credentials {
69    /// The mechanism name to send in the opening `AUTHENTICATE`.
70    pub fn mechanism(&self) -> &'static str {
71        match self {
72            Self::Plain { .. } => "PLAIN",
73            Self::External => "EXTERNAL",
74            Self::Scram { .. } => "SCRAM-SHA-256",
75        }
76    }
77
78    /// The response payload, before base64 and chunking.
79    ///
80    /// Both mechanisms leave the authorisation identity empty, which asks the server to authorise as
81    /// whoever we authenticated as.
82    pub fn response(&self) -> Vec<u8> {
83        match self {
84            Self::Plain { username, password } => {
85                let mut out = Vec::new();
86                out.push(0);
87                out.extend_from_slice(username.as_bytes());
88                out.push(0);
89                out.extend_from_slice(password.as_bytes());
90                out
91            }
92            Self::External | Self::Scram { .. } => Vec::new(),
93        }
94    }
95
96    /// True when this mechanism needs more than one round trip.
97    pub fn is_challenge_response(&self) -> bool {
98        matches!(self, Self::Scram { .. })
99    }
100}
101
102/// Split a response into the payloads of consecutive `AUTHENTICATE` lines.
103///
104/// An empty response is a single `+`. A response whose base64 is an exact multiple of the chunk size
105/// is followed by a `+`, because otherwise the server cannot tell the last full chunk from a
106/// continuation and waits forever.
107pub(crate) fn encode_response(payload: &[u8]) -> Vec<String> {
108    if payload.is_empty() {
109        return alloc::vec!["+".to_string()];
110    }
111    let encoded = BASE64.encode(payload);
112    let mut lines: Vec<String> = encoded
113        .as_bytes()
114        .chunks(CHUNK)
115        .map(|chunk| String::from_utf8_lossy(chunk).into_owned())
116        .collect();
117    if encoded.len().is_multiple_of(CHUNK) {
118        lines.push("+".to_string());
119    }
120    lines
121}
122
123/// Decode a server challenge. `+` means an empty challenge.
124pub(crate) fn decode_challenge(payload: &str) -> Option<Vec<u8>> {
125    if payload == "+" {
126        return Some(Vec::new());
127    }
128    BASE64.decode(payload).ok()
129}
130
131/// True when the server's advertised mechanism list contains this one.
132///
133/// An absent or empty list means the server did not say, in which case we try and let it answer.
134pub(crate) fn offers(advertised: Option<&str>, mechanism: &str) -> bool {
135    match advertised {
136        None | Some("") => true,
137        Some(list) => list.split(',').any(|m| m.eq_ignore_ascii_case(mechanism)),
138    }
139}
140
141/// Why authentication ended without succeeding.
142#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
144#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
145#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
146#[non_exhaustive]
147pub enum SaslFailure {
148    /// 904: the credentials were rejected.
149    Rejected,
150    /// 905: the response was longer than the server accepts.
151    TooLong,
152    /// 906: the exchange was aborted before it finished.
153    Aborted,
154    /// 907: this connection already authenticated.
155    AlreadyAuthenticated,
156    /// The server offers no mechanism we can speak.
157    NoSharedMechanism,
158    /// The server could not prove it knows the password, so it is not the server it claims.
159    ServerNotVerified,
160}
161
162/// How far authentication has got.
163#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub(crate) enum SaslState {
165    /// Not started, either because there are no credentials or the server offers no `sasl`.
166    #[default]
167    Idle,
168    /// `AUTHENTICATE <mech>` is sent, waiting for the server to invite the response.
169    Offered,
170    /// The response is sent, waiting for a verdict.
171    Responded,
172    /// SCRAM: our first message is sent, waiting for the salt and the server's nonce.
173    ScramChallenged,
174    /// SCRAM: our proof is sent, waiting for the server to prove itself in return.
175    ScramProved,
176    /// Finished, one way or the other. Negotiation may now end.
177    Settled,
178}
179
180impl SaslState {
181    /// True while `CAP END` must be held back.
182    pub(crate) fn in_flight(self) -> bool {
183        matches!(
184            self,
185            Self::Offered | Self::Responded | Self::ScramChallenged | Self::ScramProved
186        )
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193
194    #[test]
195    fn plain_frames_the_response_with_null_separators() {
196        let creds = Credentials::Plain {
197            username: "alice".to_string(),
198            password: "hunter2".to_string(),
199        };
200        assert_eq!(creds.mechanism(), "PLAIN");
201        assert_eq!(creds.response(), b"\0alice\0hunter2");
202    }
203
204    #[test]
205    fn external_sends_an_empty_response() {
206        assert_eq!(Credentials::External.mechanism(), "EXTERNAL");
207        assert_eq!(encode_response(&Credentials::External.response()), ["+"]);
208    }
209
210    #[test]
211    fn a_short_response_is_one_line() {
212        let lines = encode_response(b"\0alice\0hunter2");
213        assert_eq!(lines.len(), 1);
214        assert_eq!(
215            decode_challenge(&lines[0]).as_deref(),
216            Some(&b"\0alice\0hunter2"[..])
217        );
218    }
219
220    #[test]
221    fn a_long_response_splits_at_four_hundred() {
222        // 300 bytes encode to exactly 400 base64 characters, so one more byte spills to a second line
223        let lines = encode_response(&[b'x'; 301]);
224        assert_eq!(lines.len(), 2);
225        assert_eq!(lines[0].len(), CHUNK);
226        assert!(lines[1].len() < CHUNK);
227    }
228
229    #[test]
230    fn an_exact_multiple_gets_a_trailing_plus() {
231        // without this the server cannot tell a final full chunk from a continuation
232        let lines = encode_response(&[b'x'; 300]);
233        assert_eq!(lines.len(), 2);
234        assert_eq!(lines[0].len(), CHUNK);
235        assert_eq!(lines[1], "+");
236    }
237
238    #[test]
239    fn the_chunks_rejoin_into_the_original() {
240        let payload: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
241        let joined: String = encode_response(&payload)
242            .into_iter()
243            .filter(|line| line != "+")
244            .collect();
245        assert_eq!(decode_challenge(&joined).as_deref(), Some(&payload[..]));
246    }
247
248    #[test]
249    fn an_empty_challenge_is_a_plus() {
250        assert_eq!(decode_challenge("+"), Some(Vec::new()));
251        assert_eq!(decode_challenge("not base64!"), None);
252    }
253
254    #[test]
255    fn an_unadvertised_mechanism_list_is_not_a_refusal() {
256        assert!(
257            offers(None, "PLAIN"),
258            "no list means the server did not say"
259        );
260        assert!(offers(Some(""), "PLAIN"));
261        assert!(offers(Some("PLAIN,EXTERNAL"), "EXTERNAL"));
262        assert!(
263            offers(Some("plain"), "PLAIN"),
264            "mechanism names are case-insensitive"
265        );
266        assert!(!offers(Some("SCRAM-SHA-256"), "PLAIN"));
267    }
268
269    #[test]
270    fn cap_end_waits_only_while_the_exchange_runs() {
271        assert!(!SaslState::Idle.in_flight());
272        assert!(SaslState::Offered.in_flight());
273        assert!(SaslState::Responded.in_flight());
274        assert!(
275            SaslState::ScramChallenged.in_flight() && SaslState::ScramProved.in_flight(),
276            "a challenge-response mechanism takes several rounds, and CAP END must wait for all of them"
277        );
278        assert!(!SaslState::Settled.in_flight());
279    }
280
281    #[test]
282    fn a_password_never_appears_in_a_debug_rendering() {
283        let plain = Credentials::Plain {
284            username: "alice".to_string(),
285            password: "hunter2".to_string(),
286        };
287        let rendered = alloc::format!("{plain:?}");
288        assert!(!rendered.contains("hunter2"), "got: {rendered}");
289        assert!(rendered.contains("alice"), "the username is not the secret");
290
291        let scram = Credentials::Scram {
292            username: "alice".to_string(),
293            password: "hunter2".to_string(),
294            nonce: "abc".to_string(),
295        };
296        assert!(!alloc::format!("{scram:?}").contains("hunter2"));
297    }
298
299    #[test]
300    fn scram_is_a_challenge_response_mechanism_and_the_others_are_not() {
301        let scram = Credentials::Scram {
302            username: "alice".to_string(),
303            password: "hunter2".to_string(),
304            nonce: "abc".to_string(),
305        };
306        assert_eq!(scram.mechanism(), "SCRAM-SHA-256");
307        assert!(scram.is_challenge_response());
308        assert!(!Credentials::External.is_challenge_response());
309    }
310}