Skip to main content

postgresql_protocol/
scram.rs

1//! SCRAM-SHA-256 client (RFC 5802 / RFC 7677), used for PostgreSQL
2//! password authentication.
3//!
4//! Channel binding (`SCRAM-SHA-256-PLUS`) is not implemented; the GS2 header
5//! advertises no channel binding (`n,,`).
6//!
7//! The server is unauthenticated when `sslmode` is `require`, so every value
8//! taken from the server is treated as hostile: iteration counts and salt
9//! lengths are bounded and the nonce is validated before any key derivation
10//! runs.
11//!
12//! The password is prepared with SASLprep (RFC 4013) before key derivation, so
13//! it matches the form PostgreSQL stored the verifier for. See [`saslprep`]
14//! for the implemented profile.
15
16use base64::{Alphabet, decode as b64_decode, encode as b64_encode};
17use crypto::{Hasher, hmac::Hmac, random, sha2::Sha256};
18
19use crate::error::Error;
20
21/// Default highest accepted PBKDF2 iteration count.
22///
23/// The server chooses this value and it is pure CPU work on the client, so an
24/// unbounded count is a denial of service (a `u32::MAX` count would keep a
25/// runtime thread busy for minutes). PostgreSQL itself uses 4096 by default;
26/// 1,000,000 is ~244x that. It can be overridden per connection with
27/// `Config::max_scram_iterations` / the `max_scram_iterations` URL parameter.
28///
29/// See also [`MAX_SALT_LEN`] and [`MAX_SERVER_FIRST_LEN`].
30pub const DEFAULT_MAX_SCRAM_ITERATIONS: u32 = 1_000_000;
31
32/// Highest accepted salt length, in bytes.
33pub const MAX_SALT_LEN: usize = 1024;
34
35/// Highest accepted length for a server-first or server-final message.
36pub const MAX_SERVER_FIRST_LEN: usize = 8 * 1024;
37
38fn hmac_sha256(key: &[u8], data: &[u8]) -> crypto::Hash {
39    Hmac::<Sha256>::mac(key, data)
40}
41
42/// Applies SASLprep (RFC 4013) to a password before SCRAM key derivation.
43///
44/// PostgreSQL computes the stored SCRAM verifier from the SASLprep'd password,
45/// so the client must prepare the password the same way or authentication fails
46/// for any password SASLprep changes (for example one containing a non-ASCII
47/// space, which `libpq` and every other client normalise).
48///
49/// This implements the mapping and prohibition steps: non-ASCII space
50/// characters become ASCII spaces, the "commonly mapped to nothing" characters
51/// are removed, and ASCII control characters are rejected. Full Unicode NFKC
52/// normalisation and bidirectional checks are **not** implemented, so a password
53/// whose meaning depends on them is unsupported (ASCII and ordinary passwords
54/// are unaffected, and are returned unchanged).
55fn saslprep(password: &str) -> std::result::Result<String, Error> {
56    let mut out = String::with_capacity(password.len());
57    for c in password.chars() {
58        match c {
59            // Prohibited ASCII control characters (RFC 3454, C.2.1).
60            '\u{0000}'..='\u{001F}' | '\u{007F}' => {
61                return Err(Error::Auth(
62                    "the password contains a control character that SASLprep prohibits".into(),
63                ));
64            }
65            // Non-ASCII space characters (Zs) map to ASCII space (C.1.2).
66            '\u{00A0}' | '\u{1680}' | '\u{2000}'..='\u{200A}' | '\u{202F}' | '\u{205F}' | '\u{3000}' => {
67                out.push(' ');
68            }
69            // "Commonly mapped to nothing" (B.1).
70            '\u{00AD}'
71            | '\u{034F}'
72            | '\u{1806}'
73            | '\u{180B}'..='\u{180D}'
74            | '\u{200B}'..='\u{200D}'
75            | '\u{2060}'
76            | '\u{FE00}'..='\u{FE0F}'
77            | '\u{FEFF}' => {}
78            other => out.push(other),
79        }
80    }
81    Ok(out)
82}
83
84/// `Hi()` from RFC 5802: PBKDF2-like salted password derivation.
85///
86/// # Errors
87///
88/// Returns [`Error::Auth`] when `iterations` is outside `1..=max_iterations`
89/// or the salt is longer than [`MAX_SALT_LEN`].
90fn hi(password: &[u8], salt: &[u8], iterations: u32, max_iterations: u32) -> std::result::Result<[u8; 32], Error> {
91    if !(1..=max_iterations).contains(&iterations) {
92        return Err(Error::Auth(format!(
93            "server sent an unacceptable SCRAM iteration count ({iterations}, accepted range is 1..={max_iterations})"
94        )));
95    }
96    if salt.len() > MAX_SALT_LEN {
97        return Err(Error::Auth(format!(
98            "server sent an oversized SCRAM salt ({} bytes)",
99            salt.len()
100        )));
101    }
102    let mut salt_input = Vec::with_capacity(salt.len() + 4);
103    salt_input.extend_from_slice(salt);
104    salt_input.extend_from_slice(&1u32.to_be_bytes());
105
106    let mut u = hmac_sha256(password, &salt_input);
107    let mut result = [0u8; 32];
108    result.copy_from_slice(u.as_ref());
109
110    for _ in 1..iterations {
111        u = hmac_sha256(password, u.as_ref());
112        for (a, b) in result.iter_mut().zip(u.as_ref()) {
113            *a ^= b;
114        }
115    }
116    Ok(result)
117}
118
119/// A SCRAM-SHA-256 exchange in progress.
120pub struct ScramClient {
121    password: String,
122    client_nonce: String,
123    client_first_bare: String,
124    server_first: Option<String>,
125    client_final_without_proof: Option<String>,
126    salted_password: Option<[u8; 32]>,
127    max_iterations: u32,
128}
129
130impl ScramClient {
131    /// Starts a new exchange. `password` is the cleartext password.
132    ///
133    /// The startup message already carries the username, so it is not repeated
134    /// in the SCRAM message (matching other PostgreSQL clients).
135    pub fn new(password: &str) -> std::result::Result<Self, Error> {
136        Self::with_max_iterations(password, DEFAULT_MAX_SCRAM_ITERATIONS)
137    }
138
139    /// Like [`ScramClient::new`], but caps the server-supplied PBKDF2
140    /// iteration count at `max_iterations` instead of the default.
141    ///
142    /// # Errors
143    ///
144    /// Returns [`Error::Auth`] when the password cannot be SASLprep'd (it
145    /// contains a prohibited control character).
146    pub fn with_max_iterations(password: &str, max_iterations: u32) -> std::result::Result<Self, Error> {
147        let raw = random::bytes::<18>().map_err(|e| Error::Auth(format!("random: {e}")))?;
148        let client_nonce = b64_encode(raw, Alphabet::StandardNoPadding);
149        Self::with_nonce_and_max("", password, client_nonce, max_iterations)
150    }
151
152    #[cfg(test)]
153    fn with_nonce(username: &str, password: &str, client_nonce: String) -> Self {
154        Self::with_nonce_and_max(username, password, client_nonce, DEFAULT_MAX_SCRAM_ITERATIONS)
155            .expect("test password is valid")
156    }
157
158    fn with_nonce_and_max(
159        username: &str,
160        password: &str,
161        client_nonce: String,
162        max_iterations: u32,
163    ) -> std::result::Result<Self, Error> {
164        let password = saslprep(password)?;
165        let client_first_bare = format!("n={username},r={client_nonce}");
166        Ok(ScramClient {
167            password,
168            client_nonce,
169            client_first_bare,
170            server_first: None,
171            client_final_without_proof: None,
172            salted_password: None,
173            max_iterations,
174        })
175    }
176
177    /// The full initial response: GS2 header `n,,` followed by the
178    /// client-first-message-bare.
179    pub fn client_first_message(&self) -> String {
180        format!("n,,{}", self.client_first_bare)
181    }
182
183    /// Parses the server-first-message and derives the salted password.
184    ///
185    /// # Errors
186    ///
187    /// Returns [`Error::Auth`] when the message is malformed, when the server
188    /// nonce does not extend the client nonce, when the iteration count is
189    /// outside `1..=MAX_SCRAM_ITERATIONS`, or when the salt is oversized.
190    pub fn parse_server_first_message(&mut self, data: &[u8]) -> std::result::Result<(), Error> {
191        if data.len() > MAX_SERVER_FIRST_LEN {
192            return Err(Error::Auth("server-first message is too long".into()));
193        }
194        let msg = std::str::from_utf8(data).map_err(|_| Error::Auth("invalid UTF-8 in server-first".into()))?;
195        self.server_first = Some(msg.to_string());
196
197        let mut combined_nonce: Option<&str> = None;
198        let mut salt_b64: Option<&str> = None;
199        let mut iterations = None;
200
201        for part in msg.split(',') {
202            if let Some(v) = part.strip_prefix("r=") {
203                if combined_nonce.replace(v).is_some() {
204                    return Err(Error::Auth("duplicate nonce attribute".into()));
205                }
206            } else if let Some(v) = part.strip_prefix("s=") {
207                if salt_b64.replace(v).is_some() {
208                    return Err(Error::Auth("duplicate salt attribute".into()));
209                }
210            } else if let Some(v) = part.strip_prefix("i=") {
211                if iterations.is_some() {
212                    return Err(Error::Auth("duplicate iteration attribute".into()));
213                }
214                iterations = Some(
215                    v.parse::<u32>()
216                        .map_err(|_| Error::Auth("invalid iteration count".into()))?,
217                );
218            }
219        }
220
221        let combined_nonce = combined_nonce.ok_or_else(|| Error::Auth("missing nonce".into()))?;
222        let salt_b64 = salt_b64.ok_or_else(|| Error::Auth("missing salt".into()))?;
223        let iterations = iterations.ok_or_else(|| Error::Auth("missing iterations".into()))?;
224
225        // The server appends its own nonce; echoing ours back unchanged is not
226        // a valid challenge and would let a proxy replay the exchange.
227        if !combined_nonce.starts_with(&self.client_nonce) || combined_nonce.len() <= self.client_nonce.len() {
228            return Err(Error::Auth("server nonce does not extend the client nonce".into()));
229        }
230
231        let salt = b64_decode(salt_b64, Alphabet::Standard).map_err(|e| Error::Auth(format!("invalid salt: {e}")))?;
232
233        self.salted_password = Some(hi(self.password.as_bytes(), &salt, iterations, self.max_iterations)?);
234        self.client_final_without_proof = Some(format!("c=biws,r={combined_nonce}"));
235        Ok(())
236    }
237
238    fn auth_message(&self) -> std::result::Result<String, Error> {
239        let server_first = self
240            .server_first
241            .as_deref()
242            .ok_or_else(|| Error::Auth("server-first message has not been parsed".into()))?;
243        let client_final = self
244            .client_final_without_proof
245            .as_deref()
246            .ok_or_else(|| Error::Auth("server-first message has not been parsed".into()))?;
247        Ok(format!("{},{},{}", self.client_first_bare, server_first, client_final))
248    }
249
250    /// Builds the client-final-message.
251    ///
252    /// # Errors
253    ///
254    /// Returns [`Error::Auth`] when called before
255    /// [`parse_server_first_message`](Self::parse_server_first_message).
256    pub fn build_client_final_message(&self) -> std::result::Result<Vec<u8>, Error> {
257        let sp = self
258            .salted_password
259            .as_ref()
260            .ok_or_else(|| Error::Auth("server-first message has not been parsed".into()))?;
261        let client_key = hmac_sha256(sp, b"Client Key");
262        let stored_key = Sha256::hash(client_key.as_ref());
263        let client_signature = hmac_sha256(stored_key.as_ref(), self.auth_message()?.as_bytes());
264
265        let mut proof = [0u8; 32];
266        proof.copy_from_slice(client_key.as_ref());
267        for (a, b) in proof.iter_mut().zip(client_signature.as_ref()) {
268            *a ^= b;
269        }
270
271        Ok(format!(
272            "{},p={}",
273            self.client_final_without_proof
274                .as_deref()
275                .ok_or_else(|| Error::Auth("server-first message has not been parsed".into()))?,
276            b64_encode(proof, Alphabet::Standard)
277        )
278        .into_bytes())
279    }
280
281    /// Verifies the server-final-message (`v=`) against the expected signature.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`Error::Auth`] when the server rejects the exchange (`e=`),
286    /// when the signature is missing or malformed, or when it does not match
287    /// the expected value (a proxy that does not know the password).
288    pub fn parse_server_final_message(&self, data: &[u8]) -> std::result::Result<(), Error> {
289        if data.len() > MAX_SERVER_FIRST_LEN {
290            return Err(Error::Auth("server-final message is too long".into()));
291        }
292        let msg = std::str::from_utf8(data).map_err(|_| Error::Auth("invalid UTF-8 in server-final".into()))?;
293
294        let mut server_sig_b64 = None;
295        for part in msg.split(',') {
296            if let Some(v) = part.strip_prefix("v=") {
297                if server_sig_b64.replace(v).is_some() {
298                    return Err(Error::Auth("duplicate server signature".into()));
299                }
300            } else if let Some(e) = part.strip_prefix("e=") {
301                return Err(Error::Auth(format!("server rejected authentication: {e}")));
302            }
303        }
304
305        let server_sig_b64 = server_sig_b64.ok_or_else(|| Error::Auth("missing server signature".into()))?;
306        let server_sig = b64_decode(server_sig_b64, Alphabet::Standard)
307            .map_err(|_| Error::Auth("server signature is not base64".into()))?;
308
309        let sp = self
310            .salted_password
311            .as_ref()
312            .ok_or_else(|| Error::Auth("server-first message has not been parsed".into()))?;
313        let server_key = hmac_sha256(sp, b"Server Key");
314        let expected = hmac_sha256(server_key.as_ref(), self.auth_message()?.as_bytes());
315
316        // Constant-time: the signature covers the password-derived server key.
317        if !constant_time_eq::constant_time_eq(expected.as_ref(), &server_sig) {
318            return Err(Error::Auth("server signature mismatch".into()));
319        }
320        Ok(())
321    }
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327
328    // RFC 7677 section 3 test vector (SHA-256).
329    #[test]
330    fn rfc7677_vector() {
331        let mut client = ScramClient::with_nonce("user", "pencil", "rOprNGfwEbeRWgbNEkqO".to_string());
332        assert_eq!(client.client_first_message(), "n,,n=user,r=rOprNGfwEbeRWgbNEkqO");
333
334        client
335            .parse_server_first_message(
336                b"r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096",
337            )
338            .unwrap();
339
340        let final_msg = client.build_client_final_message().unwrap();
341        assert_eq!(
342            String::from_utf8(final_msg).unwrap(),
343            "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="
344        );
345
346        client
347            .parse_server_final_message(b"v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=")
348            .unwrap();
349    }
350
351    #[test]
352    fn rejects_wrong_nonce() {
353        let mut client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
354        let err = client
355            .parse_server_first_message(b"r=xyz,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096")
356            .unwrap_err();
357        assert!(matches!(err, Error::Auth(_)));
358    }
359
360    #[test]
361    fn rejects_unextended_nonce() {
362        // A server that echoes the client nonce unchanged is not a valid peer.
363        let mut client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
364        let err = client
365            .parse_server_first_message(b"r=abc,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096")
366            .unwrap_err();
367        assert!(matches!(err, Error::Auth(_)));
368    }
369
370    #[test]
371    fn rejects_unbounded_iterations() {
372        for i in ["0", "2000001", "4294967295"] {
373            let mut client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
374            let msg = format!("r=abcd,s=W22ZaJ0SNY7soEsUEjb6gQ==,i={i}");
375            let err = client.parse_server_first_message(msg.as_bytes()).unwrap_err();
376            assert!(matches!(err, Error::Auth(_)), "i={i} must be rejected");
377        }
378    }
379
380    #[test]
381    fn accepts_bounded_iterations() {
382        for i in ["1", "4096", "100000"] {
383            let mut client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
384            let msg = format!("r=abcd,s=W22ZaJ0SNY7soEsUEjb6gQ==,i={i}");
385            client.parse_server_first_message(msg.as_bytes()).unwrap();
386        }
387    }
388
389    #[test]
390    fn custom_iteration_limit_is_honoured() {
391        // A low configured limit rejects a count the default would accept.
392        let mut client = ScramClient::with_nonce_and_max("user", "pencil", "abc".to_string(), 1000).unwrap();
393        let msg = "r=abcd,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
394        assert!(client.parse_server_first_message(msg.as_bytes()).is_err());
395
396        let mut client = ScramClient::with_nonce_and_max("user", "pencil", "abc".to_string(), 4096).unwrap();
397        client.parse_server_first_message(msg.as_bytes()).unwrap();
398    }
399
400    #[test]
401    fn rejects_oversized_salt() {
402        let salt = "A".repeat(MAX_SALT_LEN * 2);
403        let mut client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
404        let msg = format!("r=abcd,s={salt},i=4096");
405        assert!(client.parse_server_first_message(msg.as_bytes()).is_err());
406    }
407
408    #[test]
409    fn rejects_oversized_messages() {
410        let mut client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
411        let msg = format!("r=abcd,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096,{}", "x".repeat(MAX_SERVER_FIRST_LEN));
412        assert!(client.parse_server_first_message(msg.as_bytes()).is_err());
413        assert!(
414            client
415                .parse_server_final_message(&vec![b'v'; MAX_SERVER_FIRST_LEN + 1])
416                .is_err()
417        );
418    }
419
420    #[test]
421    fn rejects_duplicate_attributes() {
422        let mut client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
423        assert!(
424            client
425                .parse_server_first_message(b"r=abcd,r=abcd,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096")
426                .is_err()
427        );
428    }
429
430    #[test]
431    fn out_of_order_calls_error_instead_of_panicking() {
432        let client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
433        assert!(client.build_client_final_message().is_err());
434        assert!(client.parse_server_final_message(b"v=whatever").is_err());
435    }
436
437    #[test]
438    fn rejects_bad_server_signature() {
439        let mut client = ScramClient::with_nonce("user", "pencil", "abc".to_string());
440        client
441            .parse_server_first_message(b"r=abcdef,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096")
442            .unwrap();
443        let _ = client.build_client_final_message().unwrap();
444        assert!(
445            client
446                .parse_server_final_message(b"v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=")
447                .is_err()
448        );
449        assert!(client.parse_server_final_message(b"v=not base64 !!").is_err());
450        assert!(client.parse_server_final_message(b"e=server-error").is_err());
451    }
452
453    #[test]
454    fn saslprep_maps_and_prohibits() {
455        // ASCII is unchanged.
456        assert_eq!(saslprep("pencil").unwrap(), "pencil");
457        // Non-ASCII spaces map to ASCII space
458        assert_eq!(saslprep("a\u{00A0}b").unwrap(), "a b");
459        assert_eq!(saslprep("a\u{3000}b").unwrap(), "a b");
460        // "Commonly mapped to nothing" characters are removed.
461        assert_eq!(saslprep("a\u{00AD}b").unwrap(), "ab");
462        assert_eq!(saslprep("a\u{200B}b").unwrap(), "ab");
463        // Prohibited ASCII control characters are rejected.
464        for c in ['\u{0000}', '\u{001F}', '\u{007F}'] {
465            assert!(saslprep(&format!("a{c}b")).is_err(), "control {c:?} must be rejected");
466        }
467    }
468
469    #[test]
470    fn passwords_are_saslprep_before_derivation() {
471        // A password with a non-ASCII space must derive the same proof as its
472        // SASLprep'd (ASCII space) form, which is what the server stored.
473        let mut with_nbsp = ScramClient::with_nonce("user", "pi\u{00A0}ncil", "abc".to_string());
474        let mut ascii = ScramClient::with_nonce("user", "pi ncil", "abc".to_string());
475        let server_first = b"r=abcd,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
476        with_nbsp.parse_server_first_message(server_first).unwrap();
477        ascii.parse_server_first_message(server_first).unwrap();
478        assert_eq!(
479            with_nbsp.build_client_final_message().unwrap(),
480            ascii.build_client_final_message().unwrap()
481        );
482    }
483}