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}