Skip to main content

rustlavel_db/postgres/
auth.rs

1//! PostgreSQL authentication: MD5 and SCRAM-SHA-256.
2//!
3//! The hashing primitives come from crates rather than being hand-written —
4//! this is the one place where "from scratch" would be a liability instead of a
5//! feature. Everything around them, including the SCRAM message flow, is ours.
6
7use crate::base64;
8use hmac::{Hmac, Mac};
9use md5::Md5;
10use rustlavel_core::{Error, Result};
11use sha2::{Digest, Sha256};
12
13type HmacSha256 = Hmac<Sha256>;
14
15/// The `md5` auth response: `md5` + hex(md5(hex(md5(password + user)) + salt)).
16pub fn md5_password(user: &str, password: &str, salt: &[u8; 4]) -> String {
17    let inner = hex(&{
18        let mut hasher = Md5::new();
19        hasher.update(password.as_bytes());
20        hasher.update(user.as_bytes());
21        hasher.finalize()
22    });
23
24    let outer = hex(&{
25        let mut hasher = Md5::new();
26        hasher.update(inner.as_bytes());
27        hasher.update(salt);
28        hasher.finalize()
29    });
30
31    format!("md5{outer}")
32}
33
34fn hex(bytes: &[u8]) -> String {
35    bytes.iter().map(|byte| format!("{byte:02x}")).collect()
36}
37
38/// A SCRAM-SHA-256 exchange in progress.
39///
40/// SCRAM proves both sides know the password without either sending it, and
41/// the server's final message is verified rather than trusted — skipping that
42/// check would leave the client open to a server impersonating the real one.
43pub struct Scram {
44    password: String,
45    client_nonce: String,
46    /// `n=,r=<nonce>` — kept because it forms part of the signed auth message.
47    client_first_bare: String,
48    server_signature: Option<Vec<u8>>,
49}
50
51impl Scram {
52    pub const MECHANISM: &'static str = "SCRAM-SHA-256";
53
54    /// Start an exchange.
55    ///
56    /// The username field is left empty, which is what PostgreSQL requires:
57    /// the role was already sent in the startup packet, and a server that read
58    /// a different name here would be authenticating the wrong account.
59    pub fn new(password: &str, nonce: String) -> Self {
60        Scram::with_username(password, "", nonce)
61    }
62
63    /// Start an exchange that carries a username in the SCRAM message itself.
64    ///
65    /// PostgreSQL never uses this; it exists so the implementation can be
66    /// checked against the RFC 7677 test vectors, which do include one.
67    pub fn with_username(password: &str, username: &str, nonce: String) -> Self {
68        Scram {
69            password: password.to_string(),
70            client_first_bare: format!("n={username},r={nonce}"),
71            client_nonce: nonce,
72            server_signature: None,
73        }
74    }
75
76    /// The client-first message, including the GS2 header (no channel binding).
77    pub fn client_first(&self) -> String {
78        format!("n,,{}", self.client_first_bare)
79    }
80
81    /// Answer the server's challenge with the client proof.
82    pub fn client_final(&mut self, server_first: &str) -> Result<String> {
83        let attributes = parse_attributes(server_first);
84
85        let combined_nonce = attributes
86            .iter()
87            .find(|(key, _)| *key == 'r')
88            .map(|(_, value)| value.clone())
89            .ok_or_else(|| Error::Protocol("SCRAM server-first has no nonce".into()))?;
90
91        // The server must echo our nonce back; if it does not, we are not
92        // talking to the party that received our first message.
93        if !combined_nonce.starts_with(&self.client_nonce) {
94            return Err(Error::Protocol("SCRAM server did not echo the client nonce".into()));
95        }
96
97        let salt = attributes
98            .iter()
99            .find(|(key, _)| *key == 's')
100            .and_then(|(_, value)| base64::decode(value))
101            .ok_or_else(|| Error::Protocol("SCRAM server-first has no salt".into()))?;
102
103        let iterations: u32 = attributes
104            .iter()
105            .find(|(key, _)| *key == 'i')
106            .and_then(|(_, value)| value.parse().ok())
107            .ok_or_else(|| Error::Protocol("SCRAM server-first has no iteration count".into()))?;
108
109        let salted = pbkdf2_sha256(self.password.as_bytes(), &salt, iterations);
110        let client_key = hmac(&salted, b"Client Key");
111        let stored_key = Sha256::digest(&client_key);
112
113        // `c=biws` is base64("n,,") — the GS2 header, echoed back.
114        let client_final_without_proof = format!("c=biws,r={combined_nonce}");
115        let auth_message =
116            format!("{},{server_first},{client_final_without_proof}", self.client_first_bare);
117
118        let client_signature = hmac(&stored_key, auth_message.as_bytes());
119        let proof: Vec<u8> = client_key
120            .iter()
121            .zip(client_signature.iter())
122            .map(|(key, signature)| key ^ signature)
123            .collect();
124
125        let server_key = hmac(&salted, b"Server Key");
126        self.server_signature = Some(hmac(&server_key, auth_message.as_bytes()));
127
128        Ok(format!("{client_final_without_proof},p={}", base64::encode(&proof)))
129    }
130
131    /// Check the server's signature. An exchange that skips this is not
132    /// mutually authenticated.
133    pub fn verify(&self, server_final: &str) -> Result<()> {
134        let expected = self
135            .server_signature
136            .as_ref()
137            .ok_or_else(|| Error::Protocol("SCRAM finished before it started".into()))?;
138
139        let attributes = parse_attributes(server_final);
140
141        if let Some((_, message)) = attributes.iter().find(|(key, _)| *key == 'e') {
142            return Err(Error::msg(format!("authentication failed: {message}")));
143        }
144
145        let received = attributes
146            .iter()
147            .find(|(key, _)| *key == 'v')
148            .and_then(|(_, value)| base64::decode(value))
149            .ok_or_else(|| Error::Protocol("SCRAM server-final has no verifier".into()))?;
150
151        if received != *expected {
152            return Err(Error::msg(
153                "the database server failed SCRAM verification; it may not be the server you think it is"
154                    .to_string(),
155            ));
156        }
157
158        Ok(())
159    }
160}
161
162/// Split `k=v,k=v` into pairs, keeping values that themselves contain `=`.
163fn parse_attributes(message: &str) -> Vec<(char, String)> {
164    message
165        .split(',')
166        .filter_map(|part| {
167            let mut chars = part.chars();
168            let key = chars.next()?;
169            let rest = chars.as_str();
170            rest.strip_prefix('=').map(|value| (key, value.to_string()))
171        })
172        .collect()
173}
174
175fn hmac(key: &[u8], message: &[u8]) -> Vec<u8> {
176    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts any key length");
177    mac.update(message);
178    mac.finalize().into_bytes().to_vec()
179}
180
181/// PBKDF2-HMAC-SHA256 with a 32-byte output, which is the only shape SCRAM-SHA-256 needs.
182fn pbkdf2_sha256(password: &[u8], salt: &[u8], iterations: u32) -> Vec<u8> {
183    let mut salted = Vec::with_capacity(salt.len() + 4);
184    salted.extend_from_slice(salt);
185    salted.extend_from_slice(&1u32.to_be_bytes());
186
187    let mut previous = hmac(password, &salted);
188    let mut result = previous.clone();
189
190    for _ in 1..iterations {
191        previous = hmac(password, &previous);
192        for (accumulated, block) in result.iter_mut().zip(previous.iter()) {
193            *accumulated ^= block;
194        }
195    }
196
197    result
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    #[test]
205    fn md5_matches_the_documented_construction() {
206        // md5("md5" + hex(md5(md5("secretpostgres") + salt)))
207        let digest = md5_password("postgres", "secret", &[0x01, 0x02, 0x03, 0x04]);
208
209        assert!(digest.starts_with("md5"));
210        assert_eq!(digest.len(), 35);
211        assert!(digest[3..].chars().all(|c| c.is_ascii_hexdigit()));
212    }
213
214    #[test]
215    fn pbkdf2_matches_a_known_vector() {
216        // RFC 7677 test vector: password "pencil", salt "W22ZaJ0SNY7soEsUEjb6gQ==", i=4096.
217        let salt = base64::decode("W22ZaJ0SNY7soEsUEjb6gQ==").unwrap();
218        let salted = pbkdf2_sha256(b"pencil", &salt, 4096);
219
220        assert_eq!(base64::encode(&salted), "xKSVEDI6tPlSysH6mUQZOeeOp01r6B3fcJbodRPcYV0=");
221    }
222
223    #[test]
224    fn produces_the_rfc_7677_client_proof() {
225        let mut scram = Scram::with_username("pencil", "user", "rOprNGfwEbeRWgbNEkqO".to_string());
226        assert_eq!(scram.client_first(), "n,,n=user,r=rOprNGfwEbeRWgbNEkqO");
227
228        let server_first =
229            "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096";
230        let client_final = scram.client_final(server_first).unwrap();
231
232        assert_eq!(
233            client_final,
234            "c=biws,r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,\
235             p=dHzbZapWIk4jUhN+Ute9ytag9zjfMHgsqmmiz7AndVQ="
236        );
237
238        scram.verify("v=6rriTRBi23WpRR/wtup+mMhUZUn/dB5nLTJRsjl95G4=").unwrap();
239    }
240
241    #[test]
242    fn postgres_exchanges_leave_the_username_empty() {
243        let scram = Scram::new("pencil", "abc".to_string());
244        assert_eq!(scram.client_first(), "n,,n=,r=abc");
245    }
246
247    #[test]
248    fn rejects_a_server_that_does_not_echo_the_nonce() {
249        let mut scram = Scram::new("pencil", "mynonce".to_string());
250        let error = scram.client_final("r=someoneelse,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096").unwrap_err();
251
252        assert!(error.to_string().contains("echo the client nonce"));
253    }
254
255    #[test]
256    fn rejects_a_bad_server_signature() {
257        let mut scram = Scram::with_username("pencil", "user", "rOprNGfwEbeRWgbNEkqO".to_string());
258        scram
259            .client_final(
260                "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096",
261            )
262            .unwrap();
263
264        let error = scram.verify("v=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").unwrap_err();
265        assert!(error.to_string().contains("failed SCRAM verification"));
266    }
267
268    #[test]
269    fn surfaces_a_server_reported_authentication_error() {
270        let mut scram = Scram::with_username("pencil", "user", "rOprNGfwEbeRWgbNEkqO".to_string());
271        scram
272            .client_final(
273                "r=rOprNGfwEbeRWgbNEkqO%hvYDpWUa2RaTCAfuxFIlj)hNlF$k0,s=W22ZaJ0SNY7soEsUEjb6gQ==,i=4096",
274            )
275            .unwrap();
276
277        let error = scram.verify("e=invalid-proof").unwrap_err();
278        assert!(error.to_string().contains("invalid-proof"));
279    }
280}