Skip to main content

hyperdb_api_core/client/
auth.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Authentication mechanisms for Hyper connections.
5//!
6//! The server selects the authentication method during the startup handshake by
7//! sending an `AuthenticationRequest` message. This module provides the
8//! client-side implementations for each supported method:
9//!
10//! | Method | Server message | Client response |
11//! |--------|---------------|-----------------|
12//! | Trust | `AuthenticationOk` | (none) |
13//! | Cleartext | `AuthenticationCleartextPassword` | Password in plain text |
14//! | MD5 | `AuthenticationMD5Password(salt)` | `"md5" + MD5(MD5(password+user) + salt)` |
15//! | SCRAM-SHA-256 | `AuthenticationSASL` | Multi-step: client-first, server-first, client-final, server-final |
16//!
17//! # SCRAM-SHA-256 Protocol (RFC 5802)
18//!
19//! The SCRAM exchange is a 4-message handshake managed by [`AuthState`]:
20//!
21//! 1. **Client-first** ([`scram_client_first`]) — Client sends `n,,n=,r=<nonce>`
22//! 2. **Server-first** — Server responds with `r=<combined-nonce>,s=<salt>,i=<iterations>`
23//! 3. **Client-final** ([`scram_client_final`]) — Client derives keys via PBKDF2-SHA-256
24//!    and sends proof: `c=<channel-binding>,r=<nonce>,p=<client-proof>`
25//! 4. **Server-final** ([`scram_verify_server`]) — Client verifies server signature
26//!
27//! # Security
28//!
29//! This module uses `zeroize` to securely clear sensitive cryptographic material
30//! (passwords, derived keys, HMAC outputs) from memory when they go out of
31//! scope. All intermediate key material in the SCRAM exchange is wrapped in
32//! [`Zeroizing<Vec<u8>>`](zeroize::Zeroizing) to prevent memory disclosure.
33//!
34//! # Attribution
35//!
36//! Portions of this module's SCRAM-SHA-256 implementation were adapted from
37//! [`postgres-protocol`](https://github.com/sfackler/rust-postgres)'s
38//! `authentication/sasl.rs` (Copyright (c) 2016 Steven Fackler, MIT or
39//! Apache-2.0). Adapted material includes the variable naming
40//! (`client_first_bare`, `salted_password`, `client_key`, `server_key`,
41//! `stored_key`, `client_signature`, `client_proof`, `auth_message`) and the
42//! key-derivation sequence. The field-parsing structure was rewritten;
43//! Hyper-specific changes added include `zeroize`-based memory hygiene of
44//! derived key material. See the `NOTICE` file at the repo root for the full
45//! upstream copyright and reproduced license text.
46
47use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
48use hmac::{Hmac, Mac};
49use md5::{Digest, Md5};
50use pbkdf2::pbkdf2_hmac;
51use rand::RngExt;
52use sha2::Sha256;
53use zeroize::{Zeroize, Zeroizing};
54
55use super::error::{Error, Result};
56
57/// Computes the MD5 password hash for `PostgreSQL` authentication.
58///
59/// The format is: "md5" + MD5(MD5(password + user) + salt)
60#[must_use]
61pub fn compute_md5_password(user: &str, password: &str, salt: &[u8]) -> String {
62    // First hash: MD5(password + user)
63    let mut hasher = Md5::new();
64    hasher.update(password.as_bytes());
65    hasher.update(user.as_bytes());
66    let first_hash = hasher.finalize();
67
68    // Convert to hex string
69    let first_hex = hex_encode(&first_hash);
70
71    // Second hash: MD5(first_hex + salt)
72    let mut hasher = Md5::new();
73    hasher.update(first_hex.as_bytes());
74    hasher.update(salt);
75    let second_hash = hasher.finalize();
76
77    // Format: "md5" + hex(second_hash)
78    format!("md5{}", hex_encode(&second_hash))
79}
80
81#[expect(
82    clippy::format_collect,
83    reason = "readable hex/string formatting loop; refactoring to fold! obscures intent"
84)]
85/// Converts bytes to lowercase hex string.
86fn hex_encode(bytes: &[u8]) -> String {
87    bytes.iter().map(|b| format!("{b:02x}")).collect()
88}
89
90/// State for SCRAM-SHA-256 authentication exchange.
91///
92/// Sensitive cryptographic material (password, derived keys) is automatically
93/// zeroized when this struct is dropped to prevent memory disclosure attacks.
94///
95/// This struct maintains the state needed for the multi-step SCRAM-SHA-256
96/// authentication protocol:
97/// 1. Client sends client-first message
98/// 2. Server responds with server-first message (salt, iterations, nonce)
99/// 3. Client computes keys and sends client-final message
100/// 4. Server responds with server-final message (signature verification)
101#[derive(Debug)]
102pub struct AuthState {
103    /// Password - automatically zeroized when dropped.
104    password: Zeroizing<String>,
105    /// Client-generated random nonce for this authentication exchange.
106    client_nonce: String,
107    /// Client-first message without GS2 header (for auth message construction).
108    client_first_bare: String,
109    /// Server-first message (stored for auth message construction).
110    #[allow(
111        dead_code,
112        reason = "retained for future re-authentication flows that replay the SCRAM exchange"
113    )]
114    server_first: Option<String>,
115    /// Complete authentication message (client-first + server-first + client-final).
116    auth_message: Option<String>,
117    /// Server key (derived from password) - automatically zeroized when dropped.
118    server_key: Option<Zeroizing<Vec<u8>>>,
119}
120
121/// Generates the client-first message for SCRAM-SHA-256.
122///
123/// Returns the auth state and the client-first message to send.
124/// The password is stored securely and will be zeroized when the `AuthState` is dropped.
125///
126/// # Errors
127///
128/// Currently infallible — always returns `Ok`. The `Result` return type
129/// is preserved for forward compatibility so future validation (password
130/// length, encoding checks) can surface errors without a signature
131/// change.
132pub fn scram_client_first(password: &str) -> Result<(AuthState, Vec<u8>)> {
133    // Generate a random nonce
134    let client_nonce = generate_nonce();
135
136    // Build the client-first-message-bare (without GS2 header)
137    // Format: n=<username>,r=<nonce>
138    // We use empty username since Hyper uses separate user parameter
139    let client_first_bare = format!("n=,r={client_nonce}");
140
141    // Build the full client-first-message
142    // Format: <gs2-header><client-first-message-bare>
143    // GS2 header: n,, (no channel binding, no authzid)
144    let client_first = format!("n,,{client_first_bare}");
145
146    let state = AuthState {
147        password: Zeroizing::new(password.to_string()),
148        client_nonce,
149        client_first_bare,
150        server_first: None,
151        auth_message: None,
152        server_key: None,
153    };
154
155    Ok((state, client_first.into_bytes()))
156}
157
158/// Processes the server-first message and generates the client-final message.
159///
160/// Returns the updated auth state and the client-final message to send.
161///
162/// # Errors
163///
164/// Returns [`Error`] (auth) when:
165/// - `server_first` is not valid UTF-8.
166/// - The iteration count (`i=`), nonce (`r=`), or salt (`s=`) field is
167///   missing or cannot be parsed.
168/// - The server nonce does not start with the client-generated nonce
169///   (an anti-MITM guarantee required by the SCRAM spec).
170/// - The salt cannot be base64-decoded.
171pub fn scram_client_final(
172    mut state: AuthState,
173    server_first: &[u8],
174) -> Result<(AuthState, Vec<u8>)> {
175    let server_first_str = std::str::from_utf8(server_first)
176        .map_err(|_| Error::authentication("invalid UTF-8 in server-first message"))?;
177
178    // Parse server-first message
179    // Format: r=<nonce>,s=<salt>,i=<iterations>
180    let mut server_nonce = None;
181    let mut salt_b64 = None;
182    let mut iterations = None;
183
184    for part in server_first_str.split(',') {
185        if let Some(value) = part.strip_prefix("r=") {
186            server_nonce = Some(value);
187        } else if let Some(value) = part.strip_prefix("s=") {
188            salt_b64 = Some(value);
189        } else if let Some(value) = part.strip_prefix("i=") {
190            iterations = Some(value.parse::<u32>().map_err(|_| {
191                Error::authentication("invalid iteration count in server-first message")
192            })?);
193        }
194    }
195
196    let server_nonce = server_nonce
197        .ok_or_else(|| Error::authentication("missing nonce in server-first message"))?;
198    let salt_b64 =
199        salt_b64.ok_or_else(|| Error::authentication("missing salt in server-first message"))?;
200    let iterations = iterations
201        .ok_or_else(|| Error::authentication("missing iterations in server-first message"))?;
202
203    // Verify server nonce starts with client nonce.
204    // Use a constant-time comparison to avoid leaking the client nonce prefix
205    // length via timing observation by a network adversary.
206    let client_nonce_bytes = state.client_nonce.as_bytes();
207    let server_nonce_bytes = server_nonce.as_bytes();
208    let prefix_match = server_nonce_bytes.len() >= client_nonce_bytes.len() && {
209        let mut diff: u8 = 0;
210        for (a, b) in server_nonce_bytes
211            .iter()
212            .zip(client_nonce_bytes.iter())
213            .take(client_nonce_bytes.len())
214        {
215            diff |= a ^ b;
216        }
217        diff == 0
218    };
219    if !prefix_match {
220        return Err(Error::authentication(
221            "server nonce doesn't match client nonce",
222        ));
223    }
224
225    // Decode salt
226    let salt = BASE64
227        .decode(salt_b64)
228        .map_err(|_| Error::authentication("invalid base64 in salt"))?;
229
230    // Derive keys using PBKDF2
231    // Use Zeroizing to ensure sensitive key material is cleared from memory
232    let salted_password: Zeroizing<Vec<u8>> =
233        Zeroizing::new(pbkdf2_sha256(&state.password, &salt, iterations));
234
235    let client_key: Zeroizing<Vec<u8>> =
236        Zeroizing::new(hmac_sha256(&salted_password, b"Client Key"));
237    let server_key: Zeroizing<Vec<u8>> =
238        Zeroizing::new(hmac_sha256(&salted_password, b"Server Key"));
239    // stored_key is SHA256(client_key) - a derived cryptographic key that should be zeroized
240    let stored_key: Zeroizing<Vec<u8>> = Zeroizing::new(sha256(&client_key));
241
242    // Build client-final-message-without-proof
243    // Format: c=<channel-binding>,r=<nonce>
244    // Channel binding is base64("n,,") since we used n,, in client-first
245    let channel_binding_b64 = BASE64.encode(b"n,,");
246    let client_final_without_proof = format!("c={channel_binding_b64},r={server_nonce}");
247
248    // Build auth message
249    let auth_message = format!(
250        "{},{},{}",
251        state.client_first_bare, server_first_str, client_final_without_proof
252    );
253
254    // Compute client signature and proof
255    // client_signature is a cryptographic signature derived from stored_key - should be zeroized
256    let client_signature: Zeroizing<Vec<u8>> =
257        Zeroizing::new(hmac_sha256(&stored_key, auth_message.as_bytes()));
258    let mut client_proof: Zeroizing<Vec<u8>> = Zeroizing::new(
259        client_key
260            .iter()
261            .zip(client_signature.iter())
262            .map(|(k, s)| k ^ s)
263            .collect(),
264    );
265
266    // Build client-final message
267    let client_final = format!(
268        "{},p={}",
269        client_final_without_proof,
270        BASE64.encode(client_proof.as_slice())
271    );
272
273    // Explicitly zeroize client_proof now that we've encoded it
274    client_proof.zeroize();
275
276    // Update state
277    state.server_first = Some(server_first_str.to_string());
278    state.auth_message = Some(auth_message);
279    state.server_key = Some(server_key);
280
281    Ok((state, client_final.into_bytes()))
282}
283
284/// Verifies the server-final message.
285///
286/// This function consumes the `AuthState`, ensuring all sensitive cryptographic
287/// material is zeroized after verification completes.
288///
289/// # Errors
290///
291/// Returns [`Error`] (auth) when:
292/// - `server_final` is not valid UTF-8.
293/// - The payload is missing the `v=` server-signature prefix.
294/// - The server signature cannot be base64-decoded.
295/// - The SCRAM state is incomplete (missing `AuthState::server_key`
296///   or `AuthState::auth_message`), indicating the caller did not
297///   run [`scram_client_final`] first.
298/// - The computed server signature does not match the one provided by
299///   the server (indicates the server does not know the user's
300///   password).
301pub fn scram_verify_server(state: AuthState, server_final: &[u8]) -> Result<()> {
302    let server_final_str = std::str::from_utf8(server_final)
303        .map_err(|_| Error::authentication("invalid UTF-8 in server-final message"))?;
304
305    // Parse server signature
306    // Format: v=<server-signature>
307    let server_sig_b64 = server_final_str
308        .strip_prefix("v=")
309        .ok_or_else(|| Error::authentication("invalid server-final message format"))?;
310
311    // Decoded server signature - wrap in Zeroizing for secure memory handling
312    let server_sig: Zeroizing<Vec<u8>> = Zeroizing::new(
313        BASE64
314            .decode(server_sig_b64)
315            .map_err(|_| Error::authentication("invalid base64 in server signature"))?,
316    );
317
318    // Compute expected server signature
319    let server_key = state
320        .server_key
321        .ok_or_else(|| Error::authentication("missing server key in auth state"))?;
322    let auth_message = state
323        .auth_message
324        .ok_or_else(|| Error::authentication("missing auth message in auth state"))?;
325
326    // expected_sig is a cryptographic signature - should be zeroized
327    let expected_sig: Zeroizing<Vec<u8>> =
328        Zeroizing::new(hmac_sha256(&server_key, auth_message.as_bytes()));
329
330    // Verify signatures match (all sensitive material is zeroized when dropped)
331    if server_sig.as_slice() != expected_sig.as_slice() {
332        return Err(Error::authentication(
333            "server signature verification failed",
334        ));
335    }
336
337    // AuthState is dropped here, zeroizing all sensitive material
338    Ok(())
339}
340
341/// Generates a random base64-encoded nonce.
342fn generate_nonce() -> String {
343    let mut rng = rand::rng();
344    let bytes: [u8; 18] = rng.random();
345    BASE64.encode(bytes)
346}
347
348/// PBKDF2 with SHA-256 for SCRAM.
349fn pbkdf2_sha256(password: &str, salt: &[u8], iterations: u32) -> Vec<u8> {
350    let mut result = [0u8; 32];
351    pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut result);
352    result.to_vec()
353}
354
355/// HMAC-SHA256.
356fn hmac_sha256(key: &[u8], data: &[u8]) -> Vec<u8> {
357    type HmacSha256 = Hmac<Sha256>;
358
359    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC can take key of any size");
360    mac.update(data);
361    mac.finalize().into_bytes().to_vec()
362}
363
364/// SHA-256 hash.
365fn sha256(data: &[u8]) -> Vec<u8> {
366    let mut hasher = Sha256::new();
367    hasher.update(data);
368    hasher.finalize().to_vec()
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_md5_password() {
377        // Test vector from PostgreSQL documentation
378        let result = compute_md5_password("user", "password", &[0x01, 0x02, 0x03, 0x04]);
379        assert!(result.starts_with("md5"));
380        assert_eq!(result.len(), 35); // "md5" + 32 hex chars
381    }
382
383    #[test]
384    fn test_hex_encode() {
385        assert_eq!(hex_encode(&[0x00, 0xff, 0x12, 0xab]), "00ff12ab");
386    }
387
388    #[test]
389    fn test_generate_nonce() {
390        let nonce1 = generate_nonce();
391        let nonce2 = generate_nonce();
392        assert_ne!(nonce1, nonce2);
393        assert!(!nonce1.is_empty());
394    }
395}