Skip to main content

reddb_wire/redwire/
scram.rs

1//! SCRAM-SHA-256 primitives and sans-I/O client/server handshake drivers.
2//!
3//! The drivers own SCRAM message sequencing and proof validation. Transport I/O,
4//! credential lookup, session creation, and authorization policy remain adapter concerns.
5
6use hmac::{Hmac, KeyInit, Mac};
7use sha2::{Digest, Sha256};
8
9use super::handshake::{
10    base64_std, base64_std_decode, build_scram_server_first, expect_auth_response_payload,
11    parse_scram_client_final, parse_scram_client_first, AuthFail, AuthOk,
12};
13use super::MessageKind;
14
15/// Default iteration count for newly-created RedDB SCRAM verifiers.
16pub const DEFAULT_ITER: u32 = 16_384;
17
18/// Minimum accepted iteration count for persisted SCRAM verifiers.
19pub const MIN_ITER: u32 = 4096;
20
21/// Persisted SCRAM verifier. It never contains the plaintext password or salted password.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ScramVerifier {
24    pub salt: Vec<u8>,
25    pub iter: u32,
26    pub stored_key: [u8; 32],
27    pub server_key: [u8; 32],
28}
29
30impl ScramVerifier {
31    pub fn from_password(password: &str, salt: Vec<u8>, iter: u32) -> Self {
32        let salted = salted_password(password.as_bytes(), &salt, iter);
33        let client_key = hmac_sha256(&salted, b"Client Key");
34        let stored_key = sha256(&client_key);
35        let server_key = hmac_sha256(&salted, b"Server Key");
36        Self {
37            salt,
38            iter,
39            stored_key,
40            server_key,
41        }
42    }
43}
44
45/// One adapter action produced by a [`ScramClientHandshake`].
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum ScramClientOutput {
48    Response(Vec<u8>),
49    Authenticated(AuthOk),
50}
51
52/// Terminal client-side SCRAM failure.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum ScramClientError {
55    Refused(String),
56    Protocol(String),
57}
58
59impl std::fmt::Display for ScramClientError {
60    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        match self {
62            Self::Refused(reason) => write!(formatter, "SCRAM authentication refused: {reason}"),
63            Self::Protocol(reason) => formatter.write_str(reason),
64        }
65    }
66}
67
68impl std::error::Error for ScramClientError {}
69
70/// Transport-independent SCRAM-SHA-256 client exchange.
71pub struct ScramClientHandshake {
72    state: ScramClientState,
73}
74
75enum ScramClientState {
76    Ready {
77        username: String,
78        password: String,
79        client_nonce: String,
80    },
81    AwaitServerFirst {
82        password: String,
83        client_nonce: String,
84        client_first_bare: String,
85    },
86    AwaitServerFinal {
87        expected_server_signature: [u8; 32],
88    },
89    Complete,
90}
91
92impl ScramClientHandshake {
93    /// Create a client driver with adapter-supplied randomness.
94    pub fn new(
95        username: impl Into<String>,
96        password: impl Into<String>,
97        client_nonce: impl Into<String>,
98    ) -> Result<Self, ScramClientError> {
99        let username = username.into();
100        if username.is_empty() || username.contains([',', '=']) {
101            return Err(ScramClientError::Protocol(
102                "SCRAM username must be non-empty and contain neither ',' nor '='".to_string(),
103            ));
104        }
105        let client_nonce = client_nonce.into();
106        if client_nonce.is_empty() || client_nonce.contains(',') {
107            return Err(ScramClientError::Protocol(
108                "SCRAM client nonce must be non-empty and contain no ','".to_string(),
109            ));
110        }
111        Ok(Self {
112            state: ScramClientState::Ready {
113                username,
114                password: password.into(),
115                client_nonce,
116            },
117        })
118    }
119
120    /// Start the exchange by producing the client-first-message.
121    pub fn start(&mut self) -> Result<ScramClientOutput, ScramClientError> {
122        let state = std::mem::replace(&mut self.state, ScramClientState::Complete);
123        let ScramClientState::Ready {
124            username,
125            password,
126            client_nonce,
127        } = state
128        else {
129            return Err(unexpected_client_state());
130        };
131        let client_first_bare = format!("n={username},r={client_nonce}");
132        let response = format!("n,,{client_first_bare}").into_bytes();
133        self.state = ScramClientState::AwaitServerFirst {
134            password,
135            client_nonce,
136            client_first_bare,
137        };
138        Ok(ScramClientOutput::Response(response))
139    }
140
141    /// Consume one server frame and produce the next adapter action.
142    pub fn step(
143        &mut self,
144        kind: MessageKind,
145        payload: &[u8],
146    ) -> Result<ScramClientOutput, ScramClientError> {
147        let state = std::mem::replace(&mut self.state, ScramClientState::Complete);
148        match state {
149            ScramClientState::AwaitServerFirst {
150                password,
151                client_nonce,
152                client_first_bare,
153            } => {
154                self.receive_server_first(kind, payload, password, client_nonce, client_first_bare)
155            }
156            ScramClientState::AwaitServerFinal {
157                expected_server_signature,
158            } => Self::receive_server_final(kind, payload, &expected_server_signature),
159            ScramClientState::Ready { .. } | ScramClientState::Complete => {
160                Err(unexpected_client_state())
161            }
162        }
163    }
164
165    fn receive_server_first(
166        &mut self,
167        kind: MessageKind,
168        payload: &[u8],
169        password: String,
170        client_nonce: String,
171        client_first_bare: String,
172    ) -> Result<ScramClientOutput, ScramClientError> {
173        if kind == MessageKind::AuthFail {
174            return Err(auth_refused(payload));
175        }
176        if kind != MessageKind::AuthRequest {
177            return Err(ScramClientError::Protocol(format!(
178                "expected AuthRequest(server-first-message), got {kind:?}"
179            )));
180        }
181        let server_first = std::str::from_utf8(payload).map_err(|_| {
182            ScramClientError::Protocol("SCRAM server-first is not UTF-8".to_string())
183        })?;
184        let (combined_nonce, salt, iter) = parse_server_first(server_first, &client_nonce)?;
185        let client_final_no_proof = format!("c=biws,r={combined_nonce}");
186        let message = auth_message(&client_first_bare, server_first, &client_final_no_proof);
187        let salted = salted_password(password.as_bytes(), &salt, iter);
188        let client_key = hmac_sha256(&salted, b"Client Key");
189        let stored_key = sha256(&client_key);
190        let proof = client_proof(&stored_key, &message, &client_key);
191        let expected_server_signature =
192            server_signature(&hmac_sha256(&salted, b"Server Key"), &message);
193        let response = format!("{client_final_no_proof},p={}", base64_std(&proof)).into_bytes();
194        self.state = ScramClientState::AwaitServerFinal {
195            expected_server_signature,
196        };
197        Ok(ScramClientOutput::Response(response))
198    }
199
200    fn receive_server_final(
201        kind: MessageKind,
202        payload: &[u8],
203        expected_server_signature: &[u8; 32],
204    ) -> Result<ScramClientOutput, ScramClientError> {
205        if kind == MessageKind::AuthFail {
206            return Err(auth_refused(payload));
207        }
208        if kind != MessageKind::AuthOk {
209            return Err(ScramClientError::Protocol(format!(
210                "expected AuthOk/AuthFail, got {kind:?}"
211            )));
212        }
213        let auth_ok = AuthOk::from_payload(payload)
214            .map_err(|error| ScramClientError::Protocol(format!("decode SCRAM AuthOk: {error}")))?;
215        let encoded_signature = auth_ok.server_signature.as_deref().ok_or_else(|| {
216            ScramClientError::Protocol("SCRAM AuthOk omitted server signature".to_string())
217        })?;
218        let presented_signature = base64_std_decode(encoded_signature).ok_or_else(|| {
219            ScramClientError::Protocol(
220                "SCRAM AuthOk server signature is not valid base64".to_string(),
221            )
222        })?;
223        if !constant_time_eq(expected_server_signature, &presented_signature) {
224            return Err(ScramClientError::Protocol(
225                "SCRAM server signature did not verify".to_string(),
226            ));
227        }
228        Ok(ScramClientOutput::Authenticated(auth_ok))
229    }
230}
231
232fn parse_server_first(
233    server_first: &str,
234    client_nonce: &str,
235) -> Result<(String, Vec<u8>, u32), ScramClientError> {
236    let mut combined_nonce = None;
237    let mut encoded_salt = None;
238    let mut iterations = None;
239    for field in server_first.split(',') {
240        if let Some(value) = field.strip_prefix("r=") {
241            set_once(&mut combined_nonce, value, "r")?;
242        } else if let Some(value) = field.strip_prefix("s=") {
243            set_once(&mut encoded_salt, value, "s")?;
244        } else if let Some(value) = field.strip_prefix("i=") {
245            set_once(&mut iterations, value, "i")?;
246        }
247    }
248
249    let combined_nonce = combined_nonce.ok_or_else(|| {
250        ScramClientError::Protocol("SCRAM server-first omitted r=<nonce>".to_string())
251    })?;
252    if !combined_nonce.starts_with(client_nonce) || combined_nonce == client_nonce {
253        return Err(ScramClientError::Protocol(
254            "server nonce does not extend client nonce".to_string(),
255        ));
256    }
257    let encoded_salt = encoded_salt.ok_or_else(|| {
258        ScramClientError::Protocol("SCRAM server-first omitted s=<salt>".to_string())
259    })?;
260    let salt = base64_std_decode(encoded_salt)
261        .ok_or_else(|| ScramClientError::Protocol("server salt is not valid base64".to_string()))?;
262    if salt.is_empty() {
263        return Err(ScramClientError::Protocol(
264            "server salt is empty".to_string(),
265        ));
266    }
267    let iterations = iterations
268        .ok_or_else(|| {
269            ScramClientError::Protocol("SCRAM server-first omitted i=<iterations>".to_string())
270        })?
271        .parse::<u32>()
272        .map_err(|_| {
273            ScramClientError::Protocol("SCRAM iteration count is not an integer".to_string())
274        })?;
275    if iterations < MIN_ITER {
276        return Err(ScramClientError::Protocol(format!(
277            "SCRAM iteration count {iterations} is below minimum {MIN_ITER}"
278        )));
279    }
280    Ok((combined_nonce.to_string(), salt, iterations))
281}
282
283fn set_once<'a>(
284    destination: &mut Option<&'a str>,
285    value: &'a str,
286    name: &str,
287) -> Result<(), ScramClientError> {
288    if destination.replace(value).is_some() {
289        return Err(ScramClientError::Protocol(format!(
290            "SCRAM server-first repeated {name}= field"
291        )));
292    }
293    Ok(())
294}
295
296fn auth_refused(payload: &[u8]) -> ScramClientError {
297    match AuthFail::from_payload(payload) {
298        Ok(failure) => ScramClientError::Refused(failure.reason),
299        Err(error) => ScramClientError::Protocol(format!("decode SCRAM AuthFail: {error}")),
300    }
301}
302
303fn unexpected_client_state() -> ScramClientError {
304    ScramClientError::Protocol("unexpected SCRAM client message".to_string())
305}
306
307/// One external event consumed by [`ScramServerHandshake::step`].
308#[derive(Debug, Clone, PartialEq, Eq)]
309pub enum ScramServerInput {
310    ClientMessage {
311        correlation_id: u64,
312        kind: MessageKind,
313        payload: Vec<u8>,
314    },
315    Verifier(Option<ScramVerifier>),
316}
317
318/// One adapter action produced by [`ScramServerHandshake::step`].
319#[derive(Debug, Clone, PartialEq, Eq)]
320pub enum ScramServerOutput {
321    NeedVerifier {
322        username: String,
323    },
324    Challenge {
325        correlation_id: u64,
326        payload: Vec<u8>,
327    },
328    Authenticated {
329        correlation_id: u64,
330        username: String,
331        server_signature: [u8; 32],
332    },
333    Failed {
334        correlation_id: u64,
335        reason: String,
336    },
337}
338
339/// Transport-independent SCRAM-SHA-256 server exchange.
340#[derive(Debug)]
341pub struct ScramServerHandshake {
342    server_nonce: String,
343    unknown_user_salt: Vec<u8>,
344    state: ScramServerState,
345}
346
347#[derive(Debug)]
348enum ScramServerState {
349    AwaitClientFirst,
350    AwaitVerifier {
351        correlation_id: u64,
352        username: String,
353        client_nonce: String,
354        client_first_bare: String,
355    },
356    AwaitClientFinal(ScramClientFinalState),
357    Complete,
358}
359
360#[derive(Debug)]
361struct ScramClientFinalState {
362    username: String,
363    combined_nonce: String,
364    client_first_bare: String,
365    server_first: String,
366    verifier: ScramVerifier,
367    user_known: bool,
368}
369
370impl ScramServerHandshake {
371    /// Create a driver with adapter-supplied randomness.
372    ///
373    /// `unknown_user_salt` is sent with a dummy verifier so unknown users follow the same
374    /// challenge/response shape as known users.
375    pub fn new(server_nonce: impl Into<String>, unknown_user_salt: Vec<u8>) -> Self {
376        Self {
377            server_nonce: server_nonce.into(),
378            unknown_user_salt,
379            state: ScramServerState::AwaitClientFirst,
380        }
381    }
382
383    pub fn step(&mut self, input: ScramServerInput) -> ScramServerOutput {
384        let state = std::mem::replace(&mut self.state, ScramServerState::Complete);
385        match (state, input) {
386            (
387                ScramServerState::AwaitClientFirst,
388                ScramServerInput::ClientMessage {
389                    correlation_id,
390                    kind,
391                    payload,
392                },
393            ) => self.step_receive_client_first(correlation_id, kind, &payload),
394            (
395                ScramServerState::AwaitVerifier {
396                    correlation_id,
397                    username,
398                    client_nonce,
399                    client_first_bare,
400                },
401                ScramServerInput::Verifier(verifier),
402            ) => self.step_receive_verifier(
403                correlation_id,
404                username,
405                client_nonce,
406                client_first_bare,
407                verifier,
408            ),
409            (
410                ScramServerState::AwaitClientFinal(final_state),
411                ScramServerInput::ClientMessage {
412                    correlation_id,
413                    kind,
414                    payload,
415                },
416            ) => Self::step_receive_client_final(correlation_id, kind, &payload, final_state),
417            (_, ScramServerInput::ClientMessage { correlation_id, .. }) => {
418                ScramServerOutput::Failed {
419                    correlation_id,
420                    reason: "unexpected SCRAM client message".to_string(),
421                }
422            }
423            (_, ScramServerInput::Verifier(_)) => ScramServerOutput::Failed {
424                correlation_id: 0,
425                reason: "unexpected SCRAM verifier".to_string(),
426            },
427        }
428    }
429
430    fn step_receive_client_first(
431        &mut self,
432        correlation_id: u64,
433        kind: MessageKind,
434        payload: &[u8],
435    ) -> ScramServerOutput {
436        let payload =
437            match expect_auth_response_payload(kind, payload, "AuthResponse(client-first-message)")
438            {
439                Ok(payload) => payload,
440                Err(error) => return failure(correlation_id, error.to_string()),
441            };
442        let (username, client_nonce, client_first_bare) = match parse_scram_client_first(payload) {
443            Ok(parsed) => parsed,
444            Err(error) => return failure(correlation_id, format!("scram client-first: {error}")),
445        };
446        self.state = ScramServerState::AwaitVerifier {
447            correlation_id,
448            username: username.clone(),
449            client_nonce,
450            client_first_bare,
451        };
452        ScramServerOutput::NeedVerifier { username }
453    }
454
455    fn step_receive_verifier(
456        &mut self,
457        correlation_id: u64,
458        username: String,
459        client_nonce: String,
460        client_first_bare: String,
461        verifier: Option<ScramVerifier>,
462    ) -> ScramServerOutput {
463        let user_known = verifier.is_some();
464        let verifier = verifier.unwrap_or_else(|| ScramVerifier {
465            salt: self.unknown_user_salt.clone(),
466            iter: DEFAULT_ITER,
467            stored_key: [0; 32],
468            server_key: [0; 32],
469        });
470        let server_first = build_scram_server_first(
471            &client_nonce,
472            &self.server_nonce,
473            &verifier.salt,
474            verifier.iter,
475        );
476        let combined_nonce = format!("{client_nonce}{}", self.server_nonce);
477        self.state = ScramServerState::AwaitClientFinal(ScramClientFinalState {
478            username,
479            combined_nonce,
480            client_first_bare,
481            server_first: server_first.clone(),
482            verifier,
483            user_known,
484        });
485        ScramServerOutput::Challenge {
486            correlation_id,
487            payload: server_first.into_bytes(),
488        }
489    }
490
491    fn step_receive_client_final(
492        correlation_id: u64,
493        kind: MessageKind,
494        payload: &[u8],
495        state: ScramClientFinalState,
496    ) -> ScramServerOutput {
497        let payload =
498            match expect_auth_response_payload(kind, payload, "AuthResponse(client-final-message)")
499            {
500                Ok(payload) => payload,
501                Err(error) => return failure(correlation_id, error.to_string()),
502            };
503        let (combined_nonce, presented_proof, client_final_no_proof) =
504            match parse_scram_client_final(payload) {
505                Ok(parsed) => parsed,
506                Err(error) => {
507                    return failure(correlation_id, format!("scram client-final: {error}"))
508                }
509            };
510        if combined_nonce != state.combined_nonce {
511            return failure(
512                correlation_id,
513                "scram nonce mismatch — replay protection failed".to_string(),
514            );
515        }
516        let auth_message = auth_message(
517            &state.client_first_bare,
518            &state.server_first,
519            &client_final_no_proof,
520        );
521        if !state.user_known
522            || !verify_client_proof(&state.verifier, &auth_message, &presented_proof)
523        {
524            return failure(correlation_id, "invalid SCRAM proof".to_string());
525        }
526        ScramServerOutput::Authenticated {
527            correlation_id,
528            username: state.username,
529            server_signature: server_signature(&state.verifier.server_key, &auth_message),
530        }
531    }
532}
533
534fn failure(correlation_id: u64, reason: String) -> ScramServerOutput {
535    ScramServerOutput::Failed {
536        correlation_id,
537        reason,
538    }
539}
540
541/// Compute `SaltedPassword` using PBKDF2-HMAC-SHA256.
542pub fn salted_password(password: &[u8], salt: &[u8], iter: u32) -> [u8; 32] {
543    let mut salt_with_block = Vec::with_capacity(salt.len() + 4);
544    salt_with_block.extend_from_slice(salt);
545    salt_with_block.extend_from_slice(&1u32.to_be_bytes());
546
547    let mut round = hmac_sha256(password, &salt_with_block);
548    let mut result = round;
549    for _ in 1..iter {
550        round = hmac_sha256(password, &round);
551        for (result_byte, round_byte) in result.iter_mut().zip(round) {
552            *result_byte ^= round_byte;
553        }
554    }
555    result
556}
557
558pub fn hmac_sha256(key: &[u8], data: &[u8]) -> [u8; 32] {
559    type HmacSha256 = Hmac<Sha256>;
560    let mut mac = HmacSha256::new_from_slice(key).expect("HMAC accepts keys of any length");
561    mac.update(data);
562    mac.finalize().into_bytes().into()
563}
564
565pub fn sha256(data: &[u8]) -> [u8; 32] {
566    Sha256::digest(data).into()
567}
568
569pub fn xor(left: &[u8], right: &[u8]) -> Vec<u8> {
570    assert_eq!(
571        left.len(),
572        right.len(),
573        "SCRAM XOR operands must have equal lengths"
574    );
575    left.iter()
576        .zip(right)
577        .map(|(left_byte, right_byte)| left_byte ^ right_byte)
578        .collect()
579}
580
581pub fn auth_message(
582    client_first_bare: &str,
583    server_first: &str,
584    client_final_no_proof: &str,
585) -> Vec<u8> {
586    let mut message = Vec::with_capacity(
587        client_first_bare.len() + server_first.len() + client_final_no_proof.len() + 2,
588    );
589    message.extend_from_slice(client_first_bare.as_bytes());
590    message.push(b',');
591    message.extend_from_slice(server_first.as_bytes());
592    message.push(b',');
593    message.extend_from_slice(client_final_no_proof.as_bytes());
594    message
595}
596
597pub fn client_proof(stored_key: &[u8], auth_message: &[u8], client_key: &[u8]) -> Vec<u8> {
598    xor(client_key, &hmac_sha256(stored_key, auth_message))
599}
600
601pub fn verify_client_proof(
602    verifier: &ScramVerifier,
603    auth_message: &[u8],
604    presented_proof: &[u8],
605) -> bool {
606    if presented_proof.len() != 32 {
607        return false;
608    }
609    let client_signature = hmac_sha256(&verifier.stored_key, auth_message);
610    let client_key = xor(presented_proof, &client_signature);
611    constant_time_eq(&sha256(&client_key), &verifier.stored_key)
612}
613
614pub fn server_signature(server_key: &[u8], auth_message: &[u8]) -> [u8; 32] {
615    hmac_sha256(server_key, auth_message)
616}
617
618pub fn verify_server_signature(
619    server_key: &[u8],
620    auth_message: &[u8],
621    presented_signature: &[u8],
622) -> bool {
623    presented_signature.len() == 32
624        && constant_time_eq(
625            &server_signature(server_key, auth_message),
626            presented_signature,
627        )
628}
629
630fn constant_time_eq(left: &[u8], right: &[u8]) -> bool {
631    // No early return on a length mismatch: the loop covers the longer
632    // input and the length difference is folded into the result.
633    let len = left.len().max(right.len());
634    let mut difference = u8::from(left.len() != right.len());
635    for i in 0..len {
636        let l = left.get(i).copied().unwrap_or(0);
637        let r = right.get(i).copied().unwrap_or(0);
638        difference |= l ^ r;
639    }
640    std::hint::black_box(difference) == 0
641}