Skip to main content

krabka_security/scram/
server.rs

1//! `ScramServerExchange`, the RFC 5802 SCRAM server state machine.
2//!
3//! It supports SCRAM-SHA-256 and SCRAM-SHA-512. The mechanism comes from the
4//! credential that the server authenticates against.
5
6use std::fmt;
7
8use base64::{Engine, engine::general_purpose::STANDARD as B64};
9use ring::{
10    hmac as ring_hmac,
11    rand::{SecureRandom, SystemRandom},
12};
13use sha2::{Digest, Sha256, Sha512};
14use subtle::ConstantTimeEq;
15
16use super::{
17    ScramClientFirst, ScramCredential,
18    messages::{JAVA_B64, ScramClientFinal},
19    scram_hash_len,
20};
21use crate::{AuthError, AuthMethod, Principal, SaslMechanism};
22
23struct AwaitingClientFirst {
24    username: String,
25    credential: ScramCredential,
26    principal_override: Option<Principal>,
27}
28
29struct AwaitingClientFinal {
30    username: String,
31    credential: ScramCredential,
32    principal_override: Option<Principal>,
33    client_first_bare: String,
34    server_first: String,
35}
36
37/// RFC 5802 SCRAM server-side handshake, one type per negotiation phase.
38///
39/// The variant payload types stay unexported on purpose. `step` and
40/// `StepResult` drive the phase, so callers never need to name
41/// `AwaitingClientFirst` or `AwaitingClientFinal` directly.
42#[allow(private_interfaces)]
43pub enum ScramServerExchange {
44    AwaitingClientFirst(AwaitingClientFirst),
45    AwaitingClientFinal(AwaitingClientFinal),
46}
47
48impl fmt::Debug for ScramServerExchange {
49    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
50        match self {
51            Self::AwaitingClientFirst(_) => f
52                .debug_tuple("ScramServerExchange::AwaitingClientFirst")
53                .finish(),
54            Self::AwaitingClientFinal(_) => f
55                .debug_tuple("ScramServerExchange::AwaitingClientFinal")
56                .finish(),
57        }
58    }
59}
60
61#[derive(Debug)]
62#[must_use]
63pub enum StepResult {
64    Continue(Vec<u8>, ScramServerExchange),
65    Done(Principal, Vec<u8>),
66    Failed(AuthError),
67    /// The client-final proof does not decode to the length of the
68    /// mechanism's hash, the empty proof included. Kafka's
69    /// `ScramFormatter.xor` throws `IllegalArgumentException` on it, which no
70    /// `SaslException` handler catches, so `Selector.pollSelectionKeys`
71    /// closes the connection at once: no `SaslAuthenticate` response and no
72    /// `connection.failed.authentication.delay.ms`. The caller closes the
73    /// connection the same way.
74    Disconnect,
75}
76
77impl ScramServerExchange {
78    #[must_use]
79    pub fn new(username: String, credential: ScramCredential) -> Self {
80        Self::AwaitingClientFirst(AwaitingClientFirst {
81            username,
82            credential,
83            principal_override: None,
84        })
85    }
86
87    /// KIP-48: variant of [`Self::new`] that stamps a principal for the `Done`
88    /// arm to return in place of one synthesized from `username`.
89    ///
90    /// The delegation-token SCRAM fallback uses this to surface the token's
91    /// owner, for example `User:alice`, when the client authenticated with the
92    /// token's UUID `token_id` as the SCRAM username.
93    #[must_use]
94    pub fn new_with_principal(
95        username: String,
96        credential: ScramCredential,
97        override_principal: Principal,
98    ) -> Self {
99        Self::AwaitingClientFirst(AwaitingClientFirst {
100            username,
101            credential,
102            principal_override: Some(override_principal),
103        })
104    }
105
106    /// Feed one client message and advance the negotiation.
107    pub fn step(self, client_bytes: &[u8]) -> StepResult {
108        match self {
109            Self::AwaitingClientFirst(s) => s.step(client_bytes),
110            Self::AwaitingClientFinal(s) => s.step(client_bytes),
111        }
112    }
113}
114
115impl AwaitingClientFirst {
116    // Per-step SCRAM server driver. skip_all keeps the raw `client_bytes`
117    // (client-first carrying nonce) out of span fields; only the
118    // non-sensitive mechanism + username are recorded. Returns a
119    // `StepResult` enum (not a Result), so no `err`.
120    #[tracing::instrument(
121        level = "debug",
122        skip_all,
123        fields(
124            mechanism = %self.credential.mechanism.wire_name(),
125            principal = %self.username,
126        )
127    )]
128    fn step(self, client_bytes: &[u8]) -> StepResult {
129        // Kafka's `ClientFirstMessage` grammar: a GS2 header of `n,,` or
130        // `n,a=<authzid>,`, then `n=`, `r=` and any extensions. Extensions
131        // such as `tokenauth` are signed as part of the bare message; the
132        // caller has already chosen the credential they select.
133        let client_first = match ScramClientFirst::parse(client_bytes) {
134            Ok(client_first) => client_first,
135            Err(e) => return StepResult::Failed(e),
136        };
137        if client_first.username != self.username {
138            return StepResult::Failed(AuthError::UnknownUser);
139        }
140        // `ScramSaslServer`: an authorization id, when the client sends one,
141        // must name the authenticating user. Kafka compares the authzid as
142        // the client escaped it with the decoded username.
143        if client_first
144            .gs2_header
145            .strip_prefix("n,a=")
146            .and_then(|header| header.strip_suffix(','))
147            .is_some_and(|authzid| authzid != client_first.username)
148        {
149            return StepResult::Failed(AuthError::AuthorizationIdMismatch);
150        }
151        let c_nonce = client_first.nonce;
152        let mut server_nonce_bytes = [0u8; 18];
153        // The client side maps a failed system RNG the same way.
154        if SystemRandom::new().fill(&mut server_nonce_bytes).is_err() {
155            return StepResult::Failed(AuthError::MalformedMessage);
156        }
157        let server_nonce = B64.encode(server_nonce_bytes);
158        let combined_nonce = format!("{c_nonce}{server_nonce}");
159        let server_first = format!(
160            "r={},s={},i={}",
161            combined_nonce,
162            B64.encode(&self.credential.salt),
163            self.credential.iterations,
164        );
165        let response = server_first.clone().into_bytes();
166        let next = AwaitingClientFinal {
167            username: self.username,
168            credential: self.credential,
169            principal_override: self.principal_override,
170            client_first_bare: client_first.bare,
171            server_first,
172        };
173        StepResult::Continue(response, ScramServerExchange::AwaitingClientFinal(next))
174    }
175}
176
177impl AwaitingClientFinal {
178    // Per-step SCRAM server driver. skip_all keeps the raw `client_bytes`
179    // (client-final carrying proof) out of span fields; only the
180    // non-sensitive mechanism + username are recorded. Terminal: returns
181    // `Done`/`Failed` only. Returns a `StepResult` enum (not a Result), so
182    // no `err`.
183    #[tracing::instrument(
184        level = "debug",
185        skip_all,
186        fields(
187            mechanism = %self.credential.mechanism.wire_name(),
188            principal = %self.username,
189        )
190    )]
191    fn step(self, client_bytes: &[u8]) -> StepResult {
192        let client_first_bare = &self.client_first_bare;
193        let server_first = &self.server_first;
194        // Kafka's `ClientFinalMessage` grammar: `c=`, `r=`, any extensions,
195        // then `p=`, in that order. Anything else is malformed.
196        let client_final = match ScramClientFinal::parse(client_bytes) {
197            Ok(client_final) => client_final,
198            Err(e) => return StepResult::Failed(e),
199        };
200        let nonce = client_final.nonce;
201
202        // RFC 5802 ยง5.1: the client-final `r=` (combined nonce) must
203        // equal the nonce the server issued in server-first
204        // (client-first-nonce + server-nonce). server-first is
205        // `r={combined_nonce},s=...,i=...`, so the expected value is the
206        // `r=` attribute up to the first comma.
207        let expected_nonce = server_first
208            .strip_prefix("r=")
209            .and_then(|rest| rest.split(',').next())
210            .unwrap_or_default();
211        if nonce != expected_nonce {
212            return StepResult::Failed(AuthError::MalformedMessage);
213        }
214
215        // Kafka checks only that `c=` is base64, not that it encodes the GS2
216        // header the client sent: `ScramSaslServer` never reads it, and the
217        // proof signs it as part of the `AuthMessage`.
218
219        // Java's `Base64` decoder ignores the unused bits of the last
220        // character, so the proof decodes as Kafka decodes it. The grammar
221        // already holds it to base64.
222        let Ok(proof) = JAVA_B64.decode(client_final.proof) else {
223            return StepResult::Failed(AuthError::MalformedMessage);
224        };
225        if proof.len() != scram_hash_len(self.credential.mechanism) {
226            return StepResult::Disconnect;
227        }
228
229        // Kafka's `ScramFormatter.authMessage`, from the parsed fields.
230        let client_final_no_proof = client_final.without_proof;
231
232        let auth_message = format!("{client_first_bare},{server_first},{client_final_no_proof}");
233
234        let (computed_stored, server_signature) = match self.credential.mechanism {
235            SaslMechanism::ScramSha512 => verify_and_sign_sha512(
236                &self.credential.stored_key,
237                &self.credential.server_key,
238                &proof,
239                auth_message.as_bytes(),
240            ),
241            SaslMechanism::ScramSha256 => verify_and_sign_sha256(
242                &self.credential.stored_key,
243                &self.credential.server_key,
244                &proof,
245                auth_message.as_bytes(),
246            ),
247            SaslMechanism::Plain | SaslMechanism::OAuthBearer | SaslMechanism::Gssapi => {
248                return StepResult::Failed(AuthError::MalformedMessage);
249            }
250        };
251
252        if computed_stored
253            .ct_eq(self.credential.stored_key.as_slice())
254            .unwrap_u8()
255            != 1
256        {
257            return StepResult::Failed(AuthError::BadProof);
258        }
259        let server_final = format!("v={}", B64.encode(&server_signature));
260        // KIP-48: prefer the override principal when set
261        // (delegation-token SCRAM fallback path). Otherwise build the
262        // standard `User:<scram-username>` principal from the live
263        // exchange state.
264        let principal = self
265            .principal_override
266            .clone()
267            .unwrap_or_else(|| Principal {
268                name: self.username.clone(),
269                auth_method: AuthMethod::from_sasl(self.credential.mechanism),
270                groups: vec![],
271            });
272        StepResult::Done(principal, server_final.into_bytes())
273    }
274}
275
276fn verify_and_sign_sha512(
277    stored_key: &[u8],
278    server_key: &[u8],
279    proof: &[u8],
280    auth_message: &[u8],
281) -> (Vec<u8>, Vec<u8>) {
282    verify_and_sign_with(
283        ring_hmac::HMAC_SHA512,
284        stored_key,
285        server_key,
286        proof,
287        auth_message,
288        |client_key| Sha512::digest(client_key).to_vec(),
289    )
290}
291
292fn verify_and_sign_sha256(
293    stored_key: &[u8],
294    server_key: &[u8],
295    proof: &[u8],
296    auth_message: &[u8],
297) -> (Vec<u8>, Vec<u8>) {
298    verify_and_sign_with(
299        ring_hmac::HMAC_SHA256,
300        stored_key,
301        server_key,
302        proof,
303        auth_message,
304        |client_key| Sha256::digest(client_key).to_vec(),
305    )
306}
307
308/// Recover `H(ClientKey)` from `proof`, and compute the `ServerSignature`,
309/// with `algorithm` as the HMAC and `hash` as `H`.
310///
311/// `ring`'s HMAC takes a key of any length, so this cannot fail.
312fn verify_and_sign_with(
313    algorithm: ring_hmac::Algorithm,
314    stored_key: &[u8],
315    server_key: &[u8],
316    proof: &[u8],
317    auth_message: &[u8],
318    hash: impl FnOnce(&[u8]) -> Vec<u8>,
319) -> (Vec<u8>, Vec<u8>) {
320    let client_signature =
321        ring_hmac::sign(&ring_hmac::Key::new(algorithm, stored_key), auth_message);
322    let client_key: Vec<u8> = client_signature
323        .as_ref()
324        .iter()
325        .zip(proof.iter())
326        .map(|(a, b)| a ^ b)
327        .collect();
328    let computed_stored = hash(&client_key);
329    let server_signature =
330        ring_hmac::sign(&ring_hmac::Key::new(algorithm, server_key), auth_message)
331            .as_ref()
332            .to_vec();
333    (computed_stored, server_signature)
334}