Skip to main content

krabka_security/scram/
client.rs

1//! `ScramClientExchange`, the RFC 5802 SCRAM client state machine.
2//!
3//! It supports SCRAM-SHA-256 and SCRAM-SHA-512. The mechanism is fixed at
4//! construction.
5
6use std::{iter, str::from_utf8};
7
8use base64::{Engine, engine::general_purpose::STANDARD as B64};
9use hmac::{Hmac, KeyInit, Mac};
10use ring::rand::{SecureRandom, SystemRandom};
11use sha2::{Digest, Sha256, Sha512};
12use subtle::ConstantTimeEq;
13
14use super::messages::{encode_saslname, is_extension};
15use crate::{AuthError, SaslMechanism};
16
17/// RFC 5802 SCRAM client-side handshake, initial phase.
18#[derive(Debug)]
19pub struct ScramClientExchange {
20    username: String,
21    password: Vec<u8>,
22    mechanism: SaslMechanism,
23    extensions: Vec<(String, String)>,
24}
25
26/// Post-client-first phase: awaiting the server-first message.
27#[derive(Debug)]
28pub struct AwaitingServerFirst {
29    username: String,
30    password: Vec<u8>,
31    mechanism: SaslMechanism,
32    client_first_bare: String,
33    client_nonce: String,
34}
35
36/// Post-client-final phase: awaiting the server-final message.
37#[derive(Debug)]
38pub struct AwaitingServerFinal {
39    mechanism: SaslMechanism,
40    username: String,
41    auth_message: String,
42    server_key: Vec<u8>,
43}
44
45impl ScramClientExchange {
46    #[must_use]
47    /// # Panics
48    /// Panics if validated key material has an impossible size or synchronized credential state is poisoned.
49    pub fn new(username: String, password: Vec<u8>, mechanism: SaslMechanism) -> Self {
50        assert2::assert!(mechanism.is_scram());
51        Self {
52            username,
53            password,
54            mechanism,
55            extensions: Vec::new(),
56        }
57    }
58
59    /// Append SCRAM extensions to client-first, after the nonce, in the given
60    /// order (a repeated key keeps its first position and its last value), as Kafka's `ScramSaslClient` does with the extensions its
61    /// callback supplies. A KIP-48 delegation-token login sends
62    /// `("tokenauth", "true")` with the token id as the username and the token
63    /// HMAC as the password.
64    ///
65    /// # Errors
66    /// [`AuthError::MalformedMessage`] when a key is not ASCII-alphabetic or a
67    /// value is empty or holds a `,` or a non-ASCII byte, which Kafka's
68    /// client-first grammar would reject.
69    pub fn with_extensions<K, V>(
70        mut self,
71        extensions: impl IntoIterator<Item = (K, V)>,
72    ) -> Result<Self, AuthError>
73    where
74        K: Into<String>,
75        V: Into<String>,
76    {
77        for (key, value) in extensions {
78            let (key, value) = (key.into(), value.into());
79            if !is_extension(&key, &value) {
80                return Err(AuthError::MalformedMessage);
81            }
82            // Kafka's `ScramSaslClient` sends its extensions from a map, so a
83            // repeated key keeps its first position and takes the last value,
84            // which is also the form the server signs.
85            match self.extensions.iter_mut().find(|(seen, _)| *seen == key) {
86                Some(entry) => entry.1 = value,
87                None => self.extensions.push((key, value)),
88            }
89        }
90        Ok(self)
91    }
92
93    // SCRAM client-first. skip_all keeps the stored `password` out of span
94    // fields; only the non-sensitive mechanism + username are recorded.
95    //
96    // `AwaitingServerFirst` is intentionally not re-exported: callers thread
97    // it through via type inference (`let (bytes, exch) = x.client_first()?`)
98    // without ever naming the phase type, so the typestate chain can't be
99    // driven out of order.
100    #[allow(private_interfaces)]
101    #[tracing::instrument(
102        level = "debug",
103        skip_all,
104        fields(mechanism = %self.mechanism.wire_name(), principal = %self.username),
105        err
106    )]
107    /// # Errors
108    /// Returns an error when credentials or key material are invalid, cryptographic verification fails, or the TLS, SASL, or Kerberos exchange is rejected.
109    pub fn client_first(self) -> Result<(Vec<u8>, AwaitingServerFirst), AuthError> {
110        let mut nonce_bytes = [0u8; 18];
111        SystemRandom::new()
112            .fill(&mut nonce_bytes)
113            .map_err(|_| AuthError::MalformedMessage)?;
114        let client_nonce = B64.encode(nonce_bytes);
115        let bare = iter::once(format!(
116            "n={},r={client_nonce}",
117            encode_saslname(&self.username)
118        ))
119        .chain(
120            self.extensions
121                .iter()
122                .map(|(key, value)| format!("{key}={value}")),
123        )
124        .collect::<Vec<_>>()
125        .join(",");
126        let msg = format!("n,,{bare}");
127        let next = AwaitingServerFirst {
128            username: self.username,
129            password: self.password,
130            mechanism: self.mechanism,
131            client_first_bare: bare,
132            client_nonce,
133        };
134        Ok((msg.into_bytes(), next))
135    }
136}
137
138impl AwaitingServerFirst {
139    // SCRAM client processing of server-first. skip_all keeps the stored
140    // `password` and the raw `server_bytes` out of span fields.
141    //
142    // `AwaitingServerFinal` is intentionally not re-exported; see
143    // `ScramClientExchange::client_first`.
144    #[allow(private_interfaces)]
145    #[tracing::instrument(
146        level = "debug",
147        skip_all,
148        fields(mechanism = %self.mechanism.wire_name(), principal = %self.username),
149        err
150    )]
151    pub fn step(self, server_bytes: &[u8]) -> Result<(Vec<u8>, AwaitingServerFinal), AuthError> {
152        let s = from_utf8(server_bytes).map_err(|_| AuthError::MalformedMessage)?;
153        let mut nonce = None;
154        let mut salt = None;
155        let mut iterations = None;
156        for attr in s.split(',') {
157            if let Some(v) = attr.strip_prefix("r=") {
158                nonce = Some(v.to_owned());
159            } else if let Some(v) = attr.strip_prefix("s=") {
160                salt = Some(B64.decode(v).map_err(|_| AuthError::MalformedMessage)?);
161            } else if let Some(v) = attr.strip_prefix("i=") {
162                iterations = Some(v.parse::<u32>().map_err(|_| AuthError::MalformedMessage)?);
163            }
164        }
165        let (Some(combined_nonce), Some(salt), Some(iters)) = (nonce, salt, iterations) else {
166            return Err(AuthError::MalformedMessage);
167        };
168        if !combined_nonce.starts_with(&self.client_nonce) {
169            return Err(AuthError::BadProof);
170        }
171
172        let channel_binding = B64.encode(b"n,,");
173        let client_final_no_proof = format!("c={channel_binding},r={combined_nonce}");
174        let auth_message = format!("{},{s},{client_final_no_proof}", self.client_first_bare);
175
176        let (proof, server_key) = match self.mechanism {
177            SaslMechanism::ScramSha512 => {
178                compute_proof_sha512(&self.password, &salt, iters, auth_message.as_bytes())?
179            }
180            SaslMechanism::ScramSha256 => {
181                compute_proof_sha256(&self.password, &salt, iters, auth_message.as_bytes())?
182            }
183            SaslMechanism::Plain | SaslMechanism::OAuthBearer | SaslMechanism::Gssapi => {
184                return Err(AuthError::MalformedMessage);
185            }
186        };
187
188        let client_final = format!("{client_final_no_proof},p={}", B64.encode(&proof));
189        let next = AwaitingServerFinal {
190            mechanism: self.mechanism,
191            username: self.username,
192            auth_message,
193            server_key,
194        };
195        Ok((client_final.into_bytes(), next))
196    }
197}
198
199impl AwaitingServerFinal {
200    // SCRAM client verification of server-final (server signature). skip_all
201    // keeps the raw `server_bytes` out of span fields. Terminal: no further
202    // exchange state to hold.
203    #[tracing::instrument(
204        level = "debug",
205        skip_all,
206        fields(mechanism = %self.mechanism.wire_name(), principal = %self.username),
207        err
208    )]
209    pub fn verify_server_final(self, server_bytes: &[u8]) -> Result<(), AuthError> {
210        let s = from_utf8(server_bytes).map_err(|_| AuthError::MalformedMessage)?;
211        let v_b64 = s.strip_prefix("v=").ok_or(AuthError::MalformedMessage)?;
212        let v = B64.decode(v_b64).map_err(|_| AuthError::MalformedMessage)?;
213        let expected: Vec<u8> = match self.mechanism {
214            SaslMechanism::ScramSha512 => {
215                let mut mac = <Hmac<Sha512>>::new_from_slice(&self.server_key)
216                    .map_err(|_| AuthError::MalformedMessage)?;
217                mac.update(self.auth_message.as_bytes());
218                mac.finalize().into_bytes().to_vec()
219            }
220            SaslMechanism::ScramSha256 => {
221                let mut mac = <Hmac<Sha256>>::new_from_slice(&self.server_key)
222                    .map_err(|_| AuthError::MalformedMessage)?;
223                mac.update(self.auth_message.as_bytes());
224                mac.finalize().into_bytes().to_vec()
225            }
226            SaslMechanism::Plain | SaslMechanism::OAuthBearer | SaslMechanism::Gssapi => {
227                return Err(AuthError::MalformedMessage);
228            }
229        };
230        if expected.ct_eq(&v).unwrap_u8() != 1 {
231            return Err(AuthError::BadProof);
232        }
233        Ok(())
234    }
235}
236
237pub(super) fn compute_proof_sha512(
238    password: &[u8],
239    salt: &[u8],
240    iters: u32,
241    auth_message: &[u8],
242) -> Result<(Vec<u8>, Vec<u8>), AuthError> {
243    let salted: [u8; 64] = pbkdf2::pbkdf2_hmac_array::<Sha512, 64>(password, salt, iters);
244    let mut client_key_mac =
245        <Hmac<Sha512>>::new_from_slice(&salted).map_err(|_| AuthError::MalformedMessage)?;
246    client_key_mac.update(b"Client Key");
247    let client_key = client_key_mac.finalize().into_bytes();
248    let stored_key = Sha512::digest(client_key);
249    let mut server_key_mac =
250        <Hmac<Sha512>>::new_from_slice(&salted).map_err(|_| AuthError::MalformedMessage)?;
251    server_key_mac.update(b"Server Key");
252    let server_key = server_key_mac.finalize().into_bytes().to_vec();
253
254    let mut client_sig_mac =
255        <Hmac<Sha512>>::new_from_slice(&stored_key).map_err(|_| AuthError::MalformedMessage)?;
256    client_sig_mac.update(auth_message);
257    let client_signature = client_sig_mac.finalize().into_bytes();
258    let proof: Vec<u8> = client_key
259        .iter()
260        .zip(client_signature.iter())
261        .map(|(a, b)| a ^ b)
262        .collect();
263    Ok((proof, server_key))
264}
265
266pub(super) fn compute_proof_sha256(
267    password: &[u8],
268    salt: &[u8],
269    iters: u32,
270    auth_message: &[u8],
271) -> Result<(Vec<u8>, Vec<u8>), AuthError> {
272    let salted: [u8; 32] = pbkdf2::pbkdf2_hmac_array::<Sha256, 32>(password, salt, iters);
273    let mut client_key_mac =
274        <Hmac<Sha256>>::new_from_slice(&salted).map_err(|_| AuthError::MalformedMessage)?;
275    client_key_mac.update(b"Client Key");
276    let client_key = client_key_mac.finalize().into_bytes();
277    let stored_key = Sha256::digest(client_key);
278    let mut server_key_mac =
279        <Hmac<Sha256>>::new_from_slice(&salted).map_err(|_| AuthError::MalformedMessage)?;
280    server_key_mac.update(b"Server Key");
281    let server_key = server_key_mac.finalize().into_bytes().to_vec();
282
283    let mut client_sig_mac =
284        <Hmac<Sha256>>::new_from_slice(&stored_key).map_err(|_| AuthError::MalformedMessage)?;
285    client_sig_mac.update(auth_message);
286    let client_signature = client_sig_mac.finalize().into_bytes();
287    let proof: Vec<u8> = client_key
288        .iter()
289        .zip(client_signature.iter())
290        .map(|(a, b)| a ^ b)
291        .collect();
292    Ok((proof, server_key))
293}