Skip to main content

fraiseql_wire/auth/scram/
mod.rs

1//! SCRAM-SHA-256 authentication implementation
2//!
3//! Implements the SCRAM-SHA-256 (Salted Challenge Response Authentication Mechanism)
4//! as defined in RFC 5802 for PostgreSQL authentication (Postgres 10+).
5
6use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
7use hmac::{Hmac, KeyInit, Mac};
8use pbkdf2::pbkdf2;
9use rand::Rng;
10use sha2::{Digest, Sha256};
11use std::fmt;
12use zeroize::Zeroizing;
13
14type HmacSha256 = Hmac<Sha256>;
15
16/// Maximum PBKDF2 iteration count accepted from the server (DoS protection).
17///
18/// A malicious server can supply a very large `i=` value in its SCRAM first message,
19/// causing the client to spend seconds (or minutes) in PBKDF2 before the connection
20/// is rejected. Capping at 1,000,000 prevents this denial-of-service vector while
21/// remaining orders of magnitude above typical PostgreSQL defaults (4096–600,000).
22pub(crate) const MAX_SCRAM_ITERATIONS: u32 = 1_000_000;
23
24/// SCRAM authentication error types
25#[derive(Debug, Clone)]
26#[non_exhaustive]
27pub enum ScramError {
28    /// Invalid proof from server
29    InvalidServerProof(String),
30    /// Invalid server message format
31    InvalidServerMessage(String),
32    /// UTF-8 encoding/decoding error
33    Utf8Error(String),
34    /// Base64 decoding error
35    Base64Error(String),
36    /// PBKDF2 key derivation failure (e.g. invalid output length)
37    KeyDerivation(String),
38}
39
40impl fmt::Display for ScramError {
41    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
42        match self {
43            ScramError::InvalidServerProof(msg) => write!(f, "invalid server proof: {}", msg),
44            ScramError::InvalidServerMessage(msg) => write!(f, "invalid server message: {}", msg),
45            ScramError::Utf8Error(msg) => write!(f, "UTF-8 error: {}", msg),
46            ScramError::Base64Error(msg) => write!(f, "Base64 error: {}", msg),
47            ScramError::KeyDerivation(msg) => write!(f, "key derivation error: {}", msg),
48        }
49    }
50}
51
52impl std::error::Error for ScramError {}
53
54/// Internal state needed for SCRAM authentication
55#[derive(Clone, Debug)]
56pub struct ScramState {
57    /// Combined authentication message (for verification)
58    auth_message: Vec<u8>,
59    /// Server key (for verification calculation)
60    server_key: Vec<u8>,
61}
62
63/// SCRAM-SHA-256 client implementation
64pub struct ScramClient {
65    username: String,
66    /// Password is stored as `Zeroizing<String>` so the key material is
67    /// overwritten with zeros when `ScramClient` is dropped (S38).
68    password: Zeroizing<String>,
69    nonce: String,
70}
71
72impl ScramClient {
73    /// Create a new SCRAM client
74    #[must_use]
75    pub fn new(username: String, password: String) -> Self {
76        // SECURITY: rand::rng() is backed by OS-level entropy for SCRAM nonces.
77        let mut rng = rand::rng();
78        let nonce_bytes: Vec<u8> = (0..24).map(|_| rng.random()).collect();
79        let nonce = BASE64.encode(&nonce_bytes);
80
81        Self {
82            username,
83            password: Zeroizing::new(password),
84            nonce,
85        }
86    }
87
88    /// Generate client first message (no proof)
89    #[must_use]
90    pub fn client_first(&self) -> String {
91        // RFC 5802 format: gs2-header client-first-message-bare
92        // gs2-header = "n,," (n = no channel binding, empty authorization identity)
93        // client-first-message-bare = "n=<username>,r=<nonce>"
94        // RFC 5802 §5.1: username must have ',' escaped as '=2C' and '=' escaped as '=3D'.
95        let escaped_username = self.username.replace('=', "=3D").replace(',', "=2C");
96        format!("n,,n={},r={}", escaped_username, self.nonce)
97    }
98
99    /// Process server first message and generate client final message
100    ///
101    /// Returns (`client_final_message`, `internal_state`)
102    ///
103    /// # Errors
104    ///
105    /// Returns [`ScramError::InvalidServerMessage`] if the server message cannot be parsed,
106    /// the server nonce does not start with the client nonce, or the iteration count is
107    /// invalid or exceeds `MAX_SCRAM_ITERATIONS`. Returns [`ScramError::Base64Error`] if
108    /// the salt is not valid base64.
109    pub fn client_final(&mut self, server_first: &str) -> Result<(String, ScramState), ScramError> {
110        // Parse server first message: r=<client_nonce><server_nonce>,s=<salt>,i=<iterations>
111        let (server_nonce, salt, iterations) = parse_server_first(server_first)?;
112
113        // Verify server nonce starts with our client nonce
114        if !server_nonce.starts_with(&self.nonce) {
115            return Err(ScramError::InvalidServerMessage(
116                "server nonce doesn't contain client nonce".to_string(),
117            ));
118        }
119
120        // Decode salt and iterations
121        let salt_bytes = BASE64
122            .decode(&salt)
123            .map_err(|_| ScramError::Base64Error("invalid salt encoding".to_string()))?;
124        let iterations = iterations
125            .parse::<u32>()
126            .map_err(|_| ScramError::InvalidServerMessage("invalid iteration count".to_string()))?;
127
128        // SECURITY: Guard against server-supplied iteration counts large enough to
129        // cause a denial-of-service via excessive PBKDF2 CPU time.
130        if iterations > MAX_SCRAM_ITERATIONS {
131            return Err(ScramError::InvalidServerMessage(format!(
132                "server iteration count {iterations} exceeds maximum of {MAX_SCRAM_ITERATIONS}"
133            )));
134        }
135
136        // Build channel binding (no channel binding for SCRAM-SHA-256)
137        let channel_binding = BASE64.encode(b"n,,");
138
139        // Build client final without proof
140        let client_final_without_proof = format!("c={},r={}", channel_binding, server_nonce);
141
142        // Build auth message for signature calculation.
143        // client-first-message-bare is "n=<escaped_username>,r=<nonce>" (without gs2-header).
144        // SECURITY: Must use the RFC 5802 §5.1-escaped username (same as client_first()),
145        // not the raw username — otherwise an attacker who controls ',' or '=' in a username
146        // can inject arbitrary SCRAM attributes and break authentication.
147        let escaped_username = self.username.replace('=', "=3D").replace(',', "=2C");
148        let client_first_bare = format!("n={},r={}", escaped_username, self.nonce);
149        let auth_message = format!(
150            "{},{},{}",
151            client_first_bare, server_first, client_final_without_proof
152        );
153
154        // Calculate proof
155        let proof = calculate_client_proof(
156            &self.password,
157            &salt_bytes,
158            iterations,
159            auth_message.as_bytes(),
160        )?;
161
162        // Calculate server signature for later verification
163        let server_key = calculate_server_key(&self.password, &salt_bytes, iterations)?;
164
165        // Build client final message
166        let client_final = format!("{},p={}", client_final_without_proof, BASE64.encode(&proof));
167
168        let state = ScramState {
169            auth_message: auth_message.into_bytes(),
170            server_key,
171        };
172
173        Ok((client_final, state))
174    }
175
176    /// Verify server final message and confirm authentication
177    ///
178    /// # Errors
179    ///
180    /// Returns `ScramError::InvalidServerMessage` if the server final message is malformed.
181    /// Returns `ScramError::Base64Error` if the server signature is not valid base64.
182    /// Returns `ScramError::AuthenticationFailed` if the server signature does not match.
183    pub fn verify_server_final(
184        &self,
185        server_final: &str,
186        state: &ScramState,
187    ) -> Result<(), ScramError> {
188        // Parse server final: v=<server_signature>
189        let server_sig_encoded = server_final
190            .strip_prefix("v=")
191            .ok_or_else(|| ScramError::InvalidServerMessage("missing 'v=' prefix".to_string()))?;
192
193        let server_signature = BASE64.decode(server_sig_encoded).map_err(|_| {
194            ScramError::Base64Error("invalid server signature encoding".to_string())
195        })?;
196
197        // Calculate expected server signature
198        let expected_signature =
199            calculate_server_signature(&state.server_key, &state.auth_message)?;
200
201        // Constant-time comparison
202        if constant_time_compare(&server_signature, &expected_signature) {
203            Ok(())
204        } else {
205            Err(ScramError::InvalidServerProof(
206                "server signature verification failed".to_string(),
207            ))
208        }
209    }
210}
211
212/// Parse server first message format: r=<nonce>,s=<salt>,i=<iterations>
213pub(crate) fn parse_server_first(msg: &str) -> Result<(String, String, String), ScramError> {
214    let mut nonce = String::new();
215    let mut salt = String::new();
216    let mut iterations = String::new();
217
218    for part in msg.split(',') {
219        if let Some(value) = part.strip_prefix("r=") {
220            nonce = value.to_string();
221        } else if let Some(value) = part.strip_prefix("s=") {
222            salt = value.to_string();
223        } else if let Some(value) = part.strip_prefix("i=") {
224            iterations = value.to_string();
225        }
226    }
227
228    if nonce.is_empty() || salt.is_empty() || iterations.is_empty() {
229        return Err(ScramError::InvalidServerMessage(
230            "missing required fields in server first message".to_string(),
231        ));
232    }
233
234    Ok((nonce, salt, iterations))
235}
236
237/// Calculate SCRAM client proof
238fn calculate_client_proof(
239    password: &str,
240    salt: &[u8],
241    iterations: u32,
242    auth_message: &[u8],
243) -> Result<Vec<u8>, ScramError> {
244    // SaltedPassword := PBKDF2(password, salt, iterations, HMAC-SHA256)
245    let password_bytes = password.as_bytes();
246    let mut salted_password = vec![0u8; 32]; // SHA256 produces 32 bytes
247                                             // Propagate the PBKDF2 result instead of discarding it (audit L-wire-scram):
248                                             // a swallowed `InvalidLength` would leave `salted_password` all-zeros and
249                                             // silently produce a wrong client proof rather than failing authentication.
250    pbkdf2::<HmacSha256>(password_bytes, salt, iterations, &mut salted_password).map_err(|_| {
251        ScramError::KeyDerivation("PBKDF2 produced an invalid output length".into())
252    })?;
253
254    // ClientKey := HMAC(SaltedPassword, "Client Key")
255    let mut client_key_hmac = HmacSha256::new_from_slice(&salted_password)
256        .map_err(|_| ScramError::Utf8Error("HMAC key error".to_string()))?;
257    client_key_hmac.update(b"Client Key");
258    let client_key = client_key_hmac.finalize().into_bytes();
259
260    // StoredKey := SHA256(ClientKey)
261    let stored_key = Sha256::digest(client_key.to_vec().as_slice());
262
263    // ClientSignature := HMAC(StoredKey, AuthMessage)
264    let mut client_sig_hmac = HmacSha256::new_from_slice(&stored_key)
265        .map_err(|_| ScramError::Utf8Error("HMAC key error".to_string()))?;
266    client_sig_hmac.update(auth_message);
267    let client_signature = client_sig_hmac.finalize().into_bytes();
268
269    // ClientProof := ClientKey XOR ClientSignature
270    let mut proof = client_key.to_vec();
271    for (proof_byte, sig_byte) in proof.iter_mut().zip(client_signature.iter()) {
272        *proof_byte ^= sig_byte;
273    }
274
275    Ok(proof.clone())
276}
277
278/// Calculate server key for server signature verification
279fn calculate_server_key(
280    password: &str,
281    salt: &[u8],
282    iterations: u32,
283) -> Result<Vec<u8>, ScramError> {
284    // SaltedPassword := PBKDF2(password, salt, iterations, HMAC-SHA256)
285    let password_bytes = password.as_bytes();
286    let mut salted_password = vec![0u8; 32];
287    // Propagate the PBKDF2 result (audit L-wire-scram): a swallowed error here
288    // would derive the server key from an all-zero salted password, breaking
289    // server-signature verification silently.
290    pbkdf2::<HmacSha256>(password_bytes, salt, iterations, &mut salted_password).map_err(|_| {
291        ScramError::KeyDerivation("PBKDF2 produced an invalid output length".into())
292    })?;
293
294    // ServerKey := HMAC(SaltedPassword, "Server Key")
295    let mut server_key_hmac = HmacSha256::new_from_slice(&salted_password)
296        .map_err(|_| ScramError::Utf8Error("HMAC key error".to_string()))?;
297    server_key_hmac.update(b"Server Key");
298
299    Ok(server_key_hmac.finalize().into_bytes().to_vec())
300}
301
302/// Calculate server signature for verification
303fn calculate_server_signature(
304    server_key: &[u8],
305    auth_message: &[u8],
306) -> Result<Vec<u8>, ScramError> {
307    let mut hmac = HmacSha256::new_from_slice(server_key)
308        .map_err(|_| ScramError::Utf8Error("invalid HMAC key for server signature".to_string()))?;
309    hmac.update(auth_message);
310    Ok(hmac.finalize().into_bytes().to_vec())
311}
312
313/// Constant-time comparison to prevent timing attacks.
314///
315/// Uses the `subtle` crate for verified constant-time operations.
316pub(crate) fn constant_time_compare(a: &[u8], b: &[u8]) -> bool {
317    use subtle::ConstantTimeEq;
318    a.ct_eq(b).into()
319}
320
321#[cfg(test)]
322mod tests;