Skip to main content

obby_client/
scram.rs

1//! SCRAM-SHA-256, from RFC 5802 and RFC 7677.
2//!
3//! The password never crosses the wire. Each side proves it knows a key derived from the password
4//! and a salt the server chose, over a nonce both sides contributed to, so a recording of the
5//! exchange cannot be replayed and a server compromise does not hand over the password itself.
6//!
7//! Only the `SCRAM-SHA-256` mechanism without channel binding is implemented, which is the one IRC
8//! servers offer. The `n,,` prefix below is the GS2 header saying exactly that.
9
10use alloc::string::{String, ToString};
11use alloc::vec::Vec;
12use hmac::{Hmac, Mac};
13use sha2::{Digest, Sha256};
14
15type HmacSha256 = Hmac<Sha256>;
16
17/// How long the nonce we contribute is, in characters.
18const NONCE_LEN: usize = 24;
19
20/// The most iterations we will run.
21///
22/// The count comes from the server, so a hostile one could otherwise ask for billions and hang the
23/// client in a loop it cannot interrupt.
24const MAX_ITERATIONS: u32 = 1_000_000;
25
26/// Why an exchange could not continue.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29#[non_exhaustive]
30pub enum ScramError {
31    /// The server's message was not the shape the specification defines.
32    Malformed,
33    /// The server's nonce does not start with the one we sent, so it is not answering us.
34    NonceMismatch,
35    /// The server asked for more iterations than we will run.
36    TooManyIterations,
37    /// The salt was not valid base64.
38    BadSalt,
39    /// The server could not prove it knows the password, so it is not the server it claims.
40    ServerProofInvalid,
41}
42
43/// An exchange in progress.
44///
45/// Holds what the first message committed to, so the second can be checked against it.
46pub struct Scram {
47    /// What we sent, minus the GS2 header, which the signature is computed over.
48    client_first_bare: String,
49    /// Our half of the nonce, which the server must echo back.
50    client_nonce: String,
51    password: String,
52    /// Set once we have computed it, so the server's own proof can be checked.
53    server_key: Option<Vec<u8>>,
54    auth_message: Option<String>,
55}
56
57impl core::fmt::Debug for Scram {
58    /// Deliberately hand-written: a derived one would print the password into whatever log the
59    /// caller happens to write, and this type is reachable from `Client`, which is `Debug`.
60    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
61        f.debug_struct("Scram")
62            .field("client_nonce", &self.client_nonce)
63            .field("password", &"<redacted>")
64            .finish_non_exhaustive()
65    }
66}
67
68impl Scram {
69    /// Start an exchange.
70    ///
71    /// `nonce` must be unpredictable and never reused: it is what stops a recorded exchange being
72    /// replayed. The core has no entropy source, so the host supplies it.
73    pub fn new(username: &str, password: &str, nonce: &str) -> Self {
74        let client_nonce = sanitise_nonce(nonce);
75        let client_first_bare = alloc::format!("n={},r={client_nonce}", saslprep(username));
76        Self {
77            client_first_bare,
78            client_nonce,
79            password: password.to_string(),
80            server_key: None,
81            auth_message: None,
82        }
83    }
84
85    /// The first message to send, including the GS2 header saying we do not use channel binding.
86    pub fn client_first(&self) -> Vec<u8> {
87        alloc::format!("n,,{}", self.client_first_bare).into_bytes()
88    }
89
90    /// Answer the server's first message with our proof.
91    pub fn client_final(&mut self, server_first: &[u8]) -> Result<Vec<u8>, ScramError> {
92        let server_first = core::str::from_utf8(server_first).map_err(|_| ScramError::Malformed)?;
93        let nonce = field(server_first, 'r').ok_or(ScramError::Malformed)?;
94        let salt = field(server_first, 's').ok_or(ScramError::Malformed)?;
95        let iterations: u32 = field(server_first, 'i')
96            .ok_or(ScramError::Malformed)?
97            .parse()
98            .map_err(|_| ScramError::Malformed)?;
99
100        // the server extends our nonce rather than replacing it, and a server that does not is
101        // either confused or replaying somebody else's exchange at us
102        if !nonce.starts_with(&self.client_nonce) || nonce == self.client_nonce {
103            return Err(ScramError::NonceMismatch);
104        }
105        if iterations == 0 || iterations > MAX_ITERATIONS {
106            return Err(ScramError::TooManyIterations);
107        }
108        let salt = base64_decode(salt).ok_or(ScramError::BadSalt)?;
109
110        let salted =
111            hi(self.password.as_bytes(), &salt, iterations).ok_or(ScramError::Malformed)?;
112        let client_key = hmac(&salted, b"Client Key").ok_or(ScramError::Malformed)?;
113        let stored_key = Sha256::digest(&client_key);
114        self.server_key = Some(hmac(&salted, b"Server Key").ok_or(ScramError::Malformed)?);
115
116        // `c=biws` is base64 of the `n,,` header we opened with, echoed so the server can check
117        // that nobody rewrote it in flight
118        let client_final_bare = alloc::format!("c=biws,r={nonce}");
119        let auth_message = alloc::format!(
120            "{},{server_first},{client_final_bare}",
121            self.client_first_bare
122        );
123
124        let client_signature =
125            hmac(&stored_key, auth_message.as_bytes()).ok_or(ScramError::Malformed)?;
126        let proof: Vec<u8> = client_key
127            .iter()
128            .zip(client_signature.iter())
129            .map(|(key, signature)| key ^ signature)
130            .collect();
131
132        self.auth_message = Some(auth_message);
133        Ok(alloc::format!("{client_final_bare},p={}", base64_encode(&proof)).into_bytes())
134    }
135
136    /// Check the server's closing message.
137    ///
138    /// This is the half most implementations skip, and skipping it means a server that never knew
139    /// the password can still convince the client it did.
140    pub fn verify(&self, server_final: &[u8]) -> Result<(), ScramError> {
141        let server_final = core::str::from_utf8(server_final).map_err(|_| ScramError::Malformed)?;
142        let signature = field(server_final, 'v').ok_or(ScramError::Malformed)?;
143        let (Some(server_key), Some(auth_message)) = (&self.server_key, &self.auth_message) else {
144            return Err(ScramError::Malformed);
145        };
146        let expected = hmac(server_key, auth_message.as_bytes()).ok_or(ScramError::Malformed)?;
147        let given = base64_decode(signature).ok_or(ScramError::Malformed)?;
148        if constant_time_eq(&expected, &given) {
149            Ok(())
150        } else {
151            Err(ScramError::ServerProofInvalid)
152        }
153    }
154}
155
156/// HMAC-SHA-256.
157///
158/// `None` only if the key length were rejected, which HMAC never does, so callers propagate it
159/// rather than assert it away.
160fn hmac(key: &[u8], message: &[u8]) -> Option<Vec<u8>> {
161    let mut mac = <HmacSha256 as Mac>::new_from_slice(key).ok()?;
162    mac.update(message);
163    Some(mac.finalize().into_bytes().to_vec())
164}
165
166/// `Hi` from RFC 5802: the password stretched over `iterations` rounds of HMAC.
167fn hi(password: &[u8], salt: &[u8], iterations: u32) -> Option<Vec<u8>> {
168    let mut previous = hmac(password, &[salt, &[0, 0, 0, 1]].concat())?;
169    let mut result = previous.clone();
170    for _ in 1..iterations {
171        previous = hmac(password, &previous)?;
172        for (accumulated, round) in result.iter_mut().zip(previous.iter()) {
173            *accumulated ^= round;
174        }
175    }
176    Some(result)
177}
178
179/// Read one `k=value` field out of a comma-separated SCRAM message.
180fn field(message: &str, key: char) -> Option<&str> {
181    message
182        .split(',')
183        .find_map(|part| part.strip_prefix(key)?.strip_prefix('='))
184}
185
186/// Strip what a nonce may not contain.
187///
188/// A comma would end the field early and let a caller's nonce forge the rest of the message.
189fn sanitise_nonce(nonce: &str) -> String {
190    let cleaned: String = nonce
191        .chars()
192        .filter(char::is_ascii_alphanumeric)
193        .take(NONCE_LEN)
194        .collect();
195    if cleaned.is_empty() {
196        "0".to_string()
197    } else {
198        cleaned
199    }
200}
201
202/// Escape the two characters a username may not carry literally.
203///
204/// A `,` or `=` would otherwise end the field or start a new one, letting a username rewrite the
205/// message around it.
206fn saslprep(username: &str) -> String {
207    username.replace('=', "=3D").replace(',', "=2C")
208}
209
210/// Compare without letting the time taken reveal where two values first differ.
211fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
212    if a.len() != b.len() {
213        return false;
214    }
215    a.iter()
216        .zip(b.iter())
217        .fold(0u8, |difference, (x, y)| difference | (x ^ y))
218        == 0
219}
220
221fn base64_encode(data: &[u8]) -> String {
222    use base64::Engine as _;
223    base64::engine::general_purpose::STANDARD.encode(data)
224}
225
226fn base64_decode(text: &str) -> Option<Vec<u8>> {
227    use base64::Engine as _;
228    base64::engine::general_purpose::STANDARD.decode(text).ok()
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    /// The worked example from RFC 7677 section 3.
236    const USER: &str = "user";
237    const PASSWORD: &str = "pencil";
238    const CLIENT_NONCE: &str = "rOprNGfwEbeRWgbNEkqO";
239    const SERVER_FIRST: &str =
240        "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
241
242    #[test]
243    fn the_first_message_carries_the_user_and_our_nonce() {
244        let scram = Scram::new(USER, PASSWORD, CLIENT_NONCE);
245        assert_eq!(
246            scram.client_first(),
247            b"n,,n=user,r=rOprNGfwEbeRWgbNEkqO".to_vec()
248        );
249    }
250
251    #[test]
252    fn the_proof_matches_the_published_example() {
253        let mut scram = Scram::new(USER, PASSWORD, CLIENT_NONCE);
254        let final_message = scram
255            .client_final(SERVER_FIRST.as_bytes())
256            .expect("the example server message is well formed");
257        assert_eq!(
258            core::str::from_utf8(&final_message).expect("utf8"),
259            "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ=",
260            "RFC 7677 publishes this exact proof, so a mismatch means the derivation is wrong"
261        );
262    }
263
264    #[test]
265    fn the_servers_own_proof_is_checked() {
266        let mut scram = Scram::new(USER, PASSWORD, CLIENT_NONCE);
267        scram
268            .client_final(SERVER_FIRST.as_bytes())
269            .expect("client final");
270        assert_eq!(
271            scram.verify(b"v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4="),
272            Ok(()),
273            "the published server signature must be accepted"
274        );
275    }
276
277    #[test]
278    fn a_server_that_never_knew_the_password_is_rejected() {
279        let mut scram = Scram::new(USER, PASSWORD, CLIENT_NONCE);
280        scram
281            .client_final(SERVER_FIRST.as_bytes())
282            .expect("client final");
283        assert_eq!(
284            scram.verify(b"v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="),
285            Err(ScramError::ServerProofInvalid),
286            "skipping this check is how a fake server convinces a client it is real"
287        );
288    }
289
290    #[test]
291    fn a_server_that_does_not_extend_our_nonce_is_not_answering_us() {
292        let mut scram = Scram::new(USER, PASSWORD, CLIENT_NONCE);
293        assert_eq!(
294            scram.client_final(b"r=somebodyelsesnonce,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096"),
295            Err(ScramError::NonceMismatch)
296        );
297        assert_eq!(
298            scram.client_final(
299                alloc::format!("r={CLIENT_NONCE},s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096").as_bytes()
300            ),
301            Err(ScramError::NonceMismatch),
302            "the server has to contribute its own half, not merely echo ours"
303        );
304    }
305
306    #[test]
307    fn an_absurd_iteration_count_is_refused_rather_than_run() {
308        let mut scram = Scram::new(USER, PASSWORD, CLIENT_NONCE);
309        assert_eq!(
310            scram.client_final(
311                alloc::format!("r={CLIENT_NONCE}x,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4000000000")
312                    .as_bytes()
313            ),
314            Err(ScramError::TooManyIterations),
315            "the count comes from the server, so it must be bounded on our side"
316        );
317        assert_eq!(
318            scram.client_final(
319                alloc::format!("r={CLIENT_NONCE}x,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=0").as_bytes()
320            ),
321            Err(ScramError::TooManyIterations)
322        );
323    }
324
325    #[test]
326    fn a_malformed_server_message_is_refused() {
327        let mut scram = Scram::new(USER, PASSWORD, CLIENT_NONCE);
328        assert_eq!(scram.client_final(b""), Err(ScramError::Malformed));
329        assert_eq!(scram.client_final(b"nonsense"), Err(ScramError::Malformed));
330        assert_eq!(
331            scram.client_final(b"r=abc,s=notbase64!,i=4096"),
332            Err(ScramError::NonceMismatch),
333            "the nonce is checked before the salt is even decoded"
334        );
335    }
336
337    #[test]
338    fn verifying_before_the_exchange_has_run_fails_rather_than_passing() {
339        let scram = Scram::new(USER, PASSWORD, CLIENT_NONCE);
340        assert_eq!(scram.verify(b"v=anything"), Err(ScramError::Malformed));
341    }
342
343    #[test]
344    fn a_username_cannot_rewrite_the_message_around_it() {
345        let scram = Scram::new("ev,il=user", PASSWORD, CLIENT_NONCE);
346        let first = String::from_utf8(scram.client_first()).expect("utf8");
347        assert!(first.contains("n=ev=2Cil=3Duser"));
348        assert_eq!(
349            first.matches(',').count(),
350            3,
351            "the header's two commas plus the one before r=, and none smuggled in by the name"
352        );
353    }
354
355    #[test]
356    fn a_nonce_cannot_smuggle_a_field_separator() {
357        let scram = Scram::new(USER, PASSWORD, "abc,r=evil");
358        assert_eq!(scram.client_nonce, "abcrevil");
359    }
360
361    #[test]
362    fn an_empty_nonce_still_produces_a_usable_one() {
363        let scram = Scram::new(USER, PASSWORD, ",,,");
364        assert!(!scram.client_nonce.is_empty());
365    }
366
367    #[test]
368    fn comparison_does_not_leak_where_two_values_differ() {
369        assert!(constant_time_eq(b"abc", b"abc"));
370        assert!(!constant_time_eq(b"abc", b"abd"));
371        assert!(!constant_time_eq(b"abc", b"ab"));
372    }
373}