Skip to main content

krabka_security/scram/
mod.rs

1//! SCRAM (RFC 5802) with support for SHA-256 and SHA-512.
2
3use std::{fmt, num::ParseIntError, str::FromStr};
4
5mod client;
6mod messages;
7mod pg_verifier;
8mod server;
9
10pub use client::ScramClientExchange;
11pub use messages::{ScramClientFirst, TOKEN_AUTH_EXTENSION, decode_saslname, encode_saslname};
12pub use pg_verifier::{PgScramVerifier, ScramError};
13use refined_type::rule::MinMaxI32;
14use ring::{
15    hmac as ring_hmac,
16    rand::{SecureRandom, SystemRandom},
17};
18pub use server::{ScramServerExchange, StepResult};
19use sha2::{Digest, Sha256, Sha512};
20
21use crate::SaslMechanism;
22
23/// Default PBKDF2 iteration count for new Kafka SCRAM credentials.
24pub const DEFAULT_SCRAM_ITERATIONS: i32 = 8192;
25/// Minimum PBKDF2 iteration count accepted by the broker.
26pub const MIN_SCRAM_ITERATIONS: i32 = 4096;
27/// Maximum PBKDF2 iteration count accepted by the broker.
28pub const MAX_SCRAM_ITERATIONS: i32 = 16_384;
29
30/// Broker-valid PBKDF2 iteration count for a SCRAM credential.
31#[derive(Clone, Copy, Debug, Eq, PartialEq)]
32pub struct ScramIterations(i32);
33
34impl ScramIterations {
35    /// Validate a SCRAM iteration count.
36    ///
37    /// # Errors
38    ///
39    /// Returns an error unless `value` is accepted by the broker.
40    pub fn new(value: i32) -> Result<Self, String> {
41        MinMaxI32::<MIN_SCRAM_ITERATIONS, MAX_SCRAM_ITERATIONS>::new(value)
42            .map(|value| Self(value.into_value()))
43            .map_err(|error| error.to_string())
44    }
45
46    #[must_use]
47    pub const fn into_value(self) -> i32 {
48        self.0
49    }
50}
51
52impl Default for ScramIterations {
53    fn default() -> Self {
54        // `DEFAULT_SCRAM_ITERATIONS` lies inside the broker-valid range.
55        Self(DEFAULT_SCRAM_ITERATIONS)
56    }
57}
58
59impl fmt::Display for ScramIterations {
60    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
61        self.0.fmt(formatter)
62    }
63}
64
65impl FromStr for ScramIterations {
66    type Err = String;
67
68    fn from_str(value: &str) -> Result<Self, Self::Err> {
69        value
70            .parse()
71            .map_err(|error: ParseIntError| error.to_string())
72            .and_then(Self::new)
73    }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub struct ScramCredential {
78    pub mechanism: SaslMechanism,
79    pub salt: Vec<u8>,
80    pub stored_key: Vec<u8>,
81    pub server_key: Vec<u8>,
82    pub iterations: u32,
83}
84
85/// Output byte length of the underlying hash function for a SCRAM mechanism.
86///
87/// The length is 32 for SHA-256 and 64 for SHA-512. This function panics on a
88/// non-SCRAM mechanism.
89#[must_use]
90/// # Panics
91/// Panics if validated key material has an impossible size or synchronized credential state is poisoned.
92pub fn scram_hash_len(mechanism: SaslMechanism) -> usize {
93    match mechanism {
94        SaslMechanism::ScramSha256 => 32,
95        SaslMechanism::ScramSha512 => 64,
96        SaslMechanism::Plain | SaslMechanism::OAuthBearer | SaslMechanism::Gssapi => {
97            panic!("scram_hash_len called with non-SCRAM mechanism {mechanism:?}")
98        }
99    }
100}
101
102/// Hash `password` into a SCRAM credential with a fresh random 16-byte salt.
103///
104/// # Errors
105///
106/// [`ScramError::RandomUnavailable`] when the system RNG cannot produce the
107/// salt.
108///
109/// # Panics
110///
111/// Panics if `iterations` is zero, or `mechanism` is not a SCRAM mechanism.
112pub fn hash_scram_password(
113    password: &[u8],
114    mechanism: SaslMechanism,
115    iterations: u32,
116) -> Result<ScramCredential, ScramError> {
117    assert2::assert!(iterations > 0);
118    let mut salt = vec![0u8; 16];
119    SystemRandom::new()
120        .fill(&mut salt)
121        .map_err(|_| ScramError::RandomUnavailable)?;
122    Ok(hash_scram_password_with_salt(
123        password, mechanism, iterations, salt,
124    ))
125}
126
127/// Test-only entry that lets callers fix the salt for golden vectors.
128#[must_use]
129/// # Panics
130/// Panics if validated key material has an impossible size or synchronized credential state is poisoned.
131pub fn hash_scram_password_with_salt(
132    password: &[u8],
133    mechanism: SaslMechanism,
134    iterations: u32,
135    salt: Vec<u8>,
136) -> ScramCredential {
137    let (stored_key, server_key) = match mechanism {
138        SaslMechanism::ScramSha512 => {
139            let salted: [u8; 64] =
140                pbkdf2::pbkdf2_hmac_array::<Sha512, 64>(password, &salt, iterations);
141            derive_keys_sha512(&salted)
142        }
143        SaslMechanism::ScramSha256 => {
144            let salted: [u8; 32] =
145                pbkdf2::pbkdf2_hmac_array::<Sha256, 32>(password, &salt, iterations);
146            derive_keys_sha256(&salted)
147        }
148        SaslMechanism::Plain | SaslMechanism::OAuthBearer | SaslMechanism::Gssapi => {
149            panic!("hash_scram_password called with non-SCRAM mechanism {mechanism:?}");
150        }
151    };
152    ScramCredential {
153        mechanism,
154        salt,
155        stored_key,
156        server_key,
157        iterations,
158    }
159}
160
161/// Compute the PBKDF2 output for a SCRAM mechanism.
162///
163/// KIP-554 and RFC 5802 call this output the "salted password". Its length
164/// matches [`scram_hash_len`]: 32 bytes for SHA-256, 64 bytes for SHA-512.
165///
166/// `krabka-client-admin` uses this function to populate
167/// `AlterUserScramCredentialsRequest.upsertions[].salted_password`. It does so
168/// without leaking the broker-side `derive_keys_from_salted` machinery or the
169/// raw password into the operator. This function panics on a non-SCRAM
170/// mechanism.
171#[must_use]
172/// # Panics
173/// Panics if validated key material has an impossible size or synchronized credential state is poisoned.
174pub fn pbkdf2_salted(
175    password: &[u8],
176    mechanism: SaslMechanism,
177    iterations: u32,
178    salt: &[u8],
179) -> Vec<u8> {
180    assert2::assert!(iterations > 0);
181    match mechanism {
182        SaslMechanism::ScramSha512 => {
183            let arr: [u8; 64] = pbkdf2::pbkdf2_hmac_array::<Sha512, 64>(password, salt, iterations);
184            arr.to_vec()
185        }
186        SaslMechanism::ScramSha256 => {
187            let arr: [u8; 32] = pbkdf2::pbkdf2_hmac_array::<Sha256, 32>(password, salt, iterations);
188            arr.to_vec()
189        }
190        SaslMechanism::Plain | SaslMechanism::OAuthBearer | SaslMechanism::Gssapi => {
191            panic!("pbkdf2_salted called with non-SCRAM mechanism {mechanism:?}");
192        }
193    }
194}
195
196/// Reconstruct `(stored_key, server_key)` from the salted-password
197/// output the client sends in an `AlterUserScramCredentialsRequest`
198/// per KIP-554.
199///
200/// The KIP places PBKDF2 on the client side. The wire request carries the
201/// already-stretched PBKDF2 output, which is 32 bytes for SHA-256 and 64 bytes
202/// for SHA-512, and the broker derives the two stored keys from it. This keeps
203/// the broker from holding the raw password even briefly. The transformation,
204/// for each hash H, is:
205///
206/// ```text
207/// client_key  = HMAC-H(salted_password, "Client Key")
208/// stored_key  = H(client_key)
209/// server_key  = HMAC-H(salted_password, "Server Key")
210/// ```
211///
212/// The mechanism argument selects which `H` to use. This function panics on a
213/// non-SCRAM mechanism.
214#[must_use]
215/// # Panics
216/// Panics if validated key material has an impossible size or synchronized credential state is poisoned.
217pub fn derive_keys_from_salted(mechanism: SaslMechanism, salted: &[u8]) -> (Vec<u8>, Vec<u8>) {
218    match mechanism {
219        SaslMechanism::ScramSha512 => derive_keys_sha512(salted),
220        SaslMechanism::ScramSha256 => derive_keys_sha256(salted),
221        SaslMechanism::Plain | SaslMechanism::OAuthBearer | SaslMechanism::Gssapi => {
222            panic!("derive_keys_from_salted called with non-SCRAM mechanism {mechanism:?}");
223        }
224    }
225}
226
227fn derive_keys_sha512(salted: &[u8]) -> (Vec<u8>, Vec<u8>) {
228    derive_keys_with(ring_hmac::HMAC_SHA512, salted, |client_key| {
229        Sha512::digest(client_key).to_vec()
230    })
231}
232
233fn derive_keys_sha256(salted: &[u8]) -> (Vec<u8>, Vec<u8>) {
234    derive_keys_with(ring_hmac::HMAC_SHA256, salted, |client_key| {
235        Sha256::digest(client_key).to_vec()
236    })
237}
238
239/// `StoredKey := H(HMAC(salted, "Client Key"))` and
240/// `ServerKey := HMAC(salted, "Server Key")`, with `algorithm` as the HMAC and
241/// `hash` as `H`.
242///
243/// `ring`'s HMAC takes a key of any length, so this cannot fail.
244fn derive_keys_with(
245    algorithm: ring_hmac::Algorithm,
246    salted: &[u8],
247    hash: impl FnOnce(&[u8]) -> Vec<u8>,
248) -> (Vec<u8>, Vec<u8>) {
249    let key = ring_hmac::Key::new(algorithm, salted);
250    let client_key = ring_hmac::sign(&key, b"Client Key");
251    let stored_key = hash(client_key.as_ref());
252    let server_key = ring_hmac::sign(&key, b"Server Key").as_ref().to_vec();
253    (stored_key, server_key)
254}
255
256#[cfg(test)]
257mod tests {
258    use assert2::check;
259    use base64::{Engine, engine::general_purpose::STANDARD as B64};
260    use hmac::{Hmac, KeyInit, Mac};
261    use sha2::{Digest, Sha512};
262
263    use super::*;
264    use crate::{AuthMethod, Principal};
265
266    /// SHA-512 PBKDF2 vector + verify `stored_key = H(client_key)`.
267    /// `ScramIterations` is a validated newtype whose whole job is to carry a
268    /// number the broker will accept, and nothing read one back out: an
269    /// `into_value` answering a constant, a `Display` writing nothing, and a
270    /// `from_str` returning the default all describe an iteration count that
271    /// was never the one asked for.
272    #[test]
273    fn scram_iterations_round_trip_through_value_text_and_parse() {
274        let iterations = ScramIterations::new(MIN_SCRAM_ITERATIONS + 1).expect("in range");
275        check!(iterations.into_value() == MIN_SCRAM_ITERATIONS + 1);
276        check!(iterations.to_string() == (MIN_SCRAM_ITERATIONS + 1).to_string());
277        check!(
278            (MIN_SCRAM_ITERATIONS + 1)
279                .to_string()
280                .parse::<ScramIterations>()
281                == Ok(iterations)
282        );
283
284        check!(ScramIterations::default().into_value() == DEFAULT_SCRAM_ITERATIONS);
285        check!(
286            ScramIterations::new(MAX_SCRAM_ITERATIONS)
287                .expect("the maximum is in range")
288                .into_value()
289                == MAX_SCRAM_ITERATIONS
290        );
291
292        // Outside the broker's range, and not a number at all.
293        check!(ScramIterations::new(MIN_SCRAM_ITERATIONS - 1).is_err());
294        check!(ScramIterations::new(MAX_SCRAM_ITERATIONS + 1).is_err());
295        check!("not-a-number".parse::<ScramIterations>().is_err());
296    }
297
298    #[test]
299    fn hash_scram_password_produces_expected_keys() {
300        let password = b"pencil";
301        let cred = hash_scram_password(password, SaslMechanism::ScramSha512, 4096).unwrap();
302        check!(
303            (
304                cred.mechanism,
305                cred.salt.len(),
306                cred.stored_key.len(),
307                cred.server_key.len(),
308                cred.iterations,
309            ) == (SaslMechanism::ScramSha512, 16, 64, 64, 4096)
310        );
311        let salted =
312            pbkdf2::pbkdf2_hmac_array::<sha2::Sha512, 64>(password, &cred.salt, cred.iterations);
313        let client_key = {
314            use hmac::{Hmac, KeyInit, Mac};
315            let mut m = <Hmac<Sha512>>::new_from_slice(&salted).unwrap();
316            m.update(b"Client Key");
317            m.finalize().into_bytes()
318        };
319        let expected_stored = Sha512::digest(client_key);
320        assert2::assert!(cred.stored_key == expected_stored.as_slice());
321    }
322
323    /// SHA-256 analog of the SHA-512 vector. It checks the 32-byte output
324    /// lengths and the same `stored_key = H(client_key)` invariant.
325    #[test]
326    fn hash_scram_password_sha256_produces_expected_keys() {
327        let password = b"pencil";
328        let cred = hash_scram_password(password, SaslMechanism::ScramSha256, 4096).unwrap();
329        check!(
330            (
331                cred.mechanism,
332                cred.salt.len(),
333                cred.stored_key.len(),
334                cred.server_key.len(),
335            ) == (SaslMechanism::ScramSha256, 16, 32, 32)
336        );
337        let salted =
338            pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(password, &cred.salt, cred.iterations);
339        let client_key = {
340            use hmac::{Hmac, KeyInit, Mac};
341            let mut m = <Hmac<sha2::Sha256>>::new_from_slice(&salted).unwrap();
342            m.update(b"Client Key");
343            m.finalize().into_bytes()
344        };
345        let expected_stored = sha2::Sha256::digest(client_key);
346        assert2::assert!(cred.stored_key == expected_stored.as_slice());
347    }
348
349    #[test]
350    fn hash_scram_password_is_deterministic_given_salt() {
351        let a = hash_scram_password(b"x", SaslMechanism::ScramSha512, 4096).unwrap();
352        let b = hash_scram_password(b"x", SaslMechanism::ScramSha512, 4096).unwrap();
353        assert2::assert!(a.salt != b.salt);
354    }
355
356    use crate::{
357        AuthError,
358        scram::{
359            client::ScramClientExchange,
360            server::{ScramServerExchange, StepResult},
361        },
362    };
363
364    #[test]
365    fn scram_server_and_client_round_trip() {
366        let password = b"hunter2";
367        let cred = hash_scram_password_with_salt(
368            password,
369            SaslMechanism::ScramSha512,
370            4096,
371            (0..16).collect::<Vec<u8>>(),
372        );
373        let server = ScramServerExchange::new("alice".to_owned(), cred);
374        let client = ScramClientExchange::new(
375            "alice".to_owned(),
376            password.to_vec(),
377            SaslMechanism::ScramSha512,
378        );
379
380        // Client first
381        let (c1, client) = client.client_first().expect("client first");
382        // Server step 1 -> server-first
383        let (s1, server) = match server.step(&c1) {
384            StepResult::Continue(b, next) => (b, next),
385            other => panic!("server step 1 must continue, got {other:?}"),
386        };
387        // Client final
388        let (c2, client) = client.step(&s1).expect("client final");
389        // Server step 2 -> done
390        let (principal, s2) = match server.step(&c2) {
391            StepResult::Done(p, b) => (p, b),
392            other => panic!("server step 2 must Done, got {other:?}"),
393        };
394        assert2::assert!(principal.name.as_str() == "alice");
395        assert2::assert!(principal.auth_method == crate::AuthMethod::SaslScramSha512);
396        // Client verifies server signature. `verify_server_final` consumes
397        // `client`, so a second verification attempt (the old "server final
398        // must only verify once" regression test) is now a compile-time
399        // move error rather than a runtime `MalformedMessage` — the
400        // scenario it guarded against is no longer expressible.
401        let final_check = client.verify_server_final(&s2);
402        assert2::assert!(final_check.is_ok());
403    }
404
405    /// Mirror of the SHA-512 round-trip with SHA-256. It proves that the
406    /// generalized state machines produce a matching client/server pair on the
407    /// smaller-hash path.
408    #[test]
409    fn scram_server_and_client_round_trip_sha256() {
410        let password = b"hunter2";
411        let cred = hash_scram_password_with_salt(
412            password,
413            SaslMechanism::ScramSha256,
414            4096,
415            (0..16).collect::<Vec<u8>>(),
416        );
417        let server = ScramServerExchange::new("alice".to_owned(), cred);
418        let client = ScramClientExchange::new(
419            "alice".to_owned(),
420            password.to_vec(),
421            SaslMechanism::ScramSha256,
422        );
423
424        let (c1, client) = client.client_first().expect("client first");
425        let (s1, server) = match server.step(&c1) {
426            StepResult::Continue(b, next) => (b, next),
427            other => panic!("server step 1 must continue, got {other:?}"),
428        };
429        let (c2, client) = client.step(&s1).expect("client final");
430        let (principal, s2) = match server.step(&c2) {
431            StepResult::Done(p, b) => (p, b),
432            other => panic!("server step 2 must Done, got {other:?}"),
433        };
434        assert2::assert!(principal.name.as_str() == "alice");
435        assert2::assert!(principal.auth_method == crate::AuthMethod::SaslScramSha256);
436        let final_check = client.verify_server_final(&s2);
437        assert2::assert!(final_check.is_ok());
438    }
439
440    /// The hand-written `Debug` impl on `ScramServerExchange` prints the
441    /// observable negotiation phase. The variant payload is not exported, so
442    /// callers can only ever see the phase name through `Debug` or logging.
443    #[test]
444    fn scram_server_exchange_debug_includes_the_observable_phase() {
445        let password = b"hunter2";
446        let cred = hash_scram_password_with_salt(
447            password,
448            SaslMechanism::ScramSha256,
449            4096,
450            (0..16).collect::<Vec<u8>>(),
451        );
452        let server = ScramServerExchange::new("alice".to_owned(), cred);
453        let rendered = format!("{server:?}");
454        assert2::assert!(rendered.contains("ScramServerExchange::AwaitingClientFirst"));
455
456        let client = ScramClientExchange::new(
457            "alice".to_owned(),
458            password.to_vec(),
459            SaslMechanism::ScramSha256,
460        );
461        let (c1, _client) = client.client_first().expect("client first");
462        let server = match server.step(&c1) {
463            StepResult::Continue(_, next) => next,
464            other => panic!("server step 1 must continue, got {other:?}"),
465        };
466        let rendered = format!("{server:?}");
467        assert2::assert!(rendered.contains("ScramServerExchange::AwaitingClientFinal"));
468    }
469
470    #[test]
471    fn scram_client_rejects_tampered_server_final_signature() {
472        let password = b"hunter2";
473        let cred = hash_scram_password_with_salt(
474            password,
475            SaslMechanism::ScramSha256,
476            4096,
477            (0..16).collect::<Vec<u8>>(),
478        );
479        let server = ScramServerExchange::new("alice".to_owned(), cred);
480        let client = ScramClientExchange::new(
481            "alice".to_owned(),
482            password.to_vec(),
483            SaslMechanism::ScramSha256,
484        );
485
486        let (c1, client) = client.client_first().expect("client first");
487        let (s1, server) = match server.step(&c1) {
488            StepResult::Continue(b, next) => (b, next),
489            other => panic!("server step 1 must continue, got {other:?}"),
490        };
491        let (c2, client) = client.step(&s1).expect("client final");
492        match server.step(&c2) {
493            StepResult::Done(_, _) => {}
494            other => panic!("server step 2 must Done, got {other:?}"),
495        }
496
497        assert2::assert!(client.verify_server_final(b"v=AAAA").is_err());
498    }
499
500    /// How a server exchange ended after client-final, comparable as a whole.
501    #[derive(Debug, PartialEq, Eq)]
502    enum Ending {
503        Done(Principal, Vec<u8>),
504        Failed(AuthError),
505        Disconnect,
506    }
507
508    /// A server exchange that continued past client-final has no ending: the
509    /// conversion hands the result back.
510    impl TryFrom<StepResult> for Ending {
511        type Error = StepResult;
512
513        fn try_from(result: StepResult) -> Result<Self, StepResult> {
514            match result {
515                StepResult::Done(principal, server_final) => {
516                    Ok(Self::Done(principal, server_final))
517                }
518                StepResult::Failed(e) => Ok(Self::Failed(e)),
519                StepResult::Disconnect => Ok(Self::Disconnect),
520                continued @ StepResult::Continue(..) => Err(continued),
521            }
522        }
523    }
524
525    /// A proof that does not decode to the hash length makes Kafka's
526    /// `ScramFormatter.xor` throw `IllegalArgumentException`, and the
527    /// connection closes with no response. The nonce is checked first, as in
528    /// `ScramSaslServer`, so a wrong nonce still fails as malformed.
529    #[test]
530    fn scram_server_disconnects_on_a_proof_of_the_wrong_length() {
531        let password = b"hunter2";
532        for (case, mechanism, proof_len, wrong_nonce, want) in [
533            (
534                "empty proof",
535                SaslMechanism::ScramSha256,
536                0,
537                false,
538                Ending::Disconnect,
539            ),
540            (
541                "short proof",
542                SaslMechanism::ScramSha256,
543                3,
544                false,
545                Ending::Disconnect,
546            ),
547            (
548                "one byte short",
549                SaslMechanism::ScramSha256,
550                31,
551                false,
552                Ending::Disconnect,
553            ),
554            (
555                "one byte long",
556                SaslMechanism::ScramSha256,
557                33,
558                false,
559                Ending::Disconnect,
560            ),
561            (
562                "SHA-512 length on SHA-256",
563                SaslMechanism::ScramSha256,
564                64,
565                false,
566                Ending::Disconnect,
567            ),
568            (
569                "SHA-256 length on SHA-512",
570                SaslMechanism::ScramSha512,
571                32,
572                false,
573                Ending::Disconnect,
574            ),
575            (
576                "right length, wrong value",
577                SaslMechanism::ScramSha256,
578                32,
579                false,
580                Ending::Failed(AuthError::BadProof),
581            ),
582            (
583                "wrong length and wrong nonce",
584                SaslMechanism::ScramSha256,
585                3,
586                true,
587                Ending::Failed(AuthError::MalformedMessage),
588            ),
589        ] {
590            let cred = hash_scram_password_with_salt(
591                password,
592                mechanism,
593                4096,
594                (0..16).collect::<Vec<u8>>(),
595            );
596            let server = ScramServerExchange::new("alice".to_owned(), cred);
597            let StepResult::Continue(server_first, server) = server.step(b"n,,n=alice,r=cn") else {
598                panic!("server step 1 must continue");
599            };
600            let server_first = String::from_utf8(server_first).unwrap();
601            let nonce = server_first
602                .strip_prefix("r=")
603                .and_then(|rest| rest.split(',').next())
604                .unwrap();
605            let nonce = if wrong_nonce { "cnX" } else { nonce };
606            let client_final = format!("c=biws,r={nonce},p={}", B64.encode(vec![7u8; proof_len]));
607            check!(
608                Ending::try_from(server.step(client_final.as_bytes())).unwrap() == want,
609                "case {case}"
610            );
611        }
612    }
613
614    #[test]
615    fn pbkdf2_salted_matches_hash_scram_password_intermediate() {
616        // `pbkdf2_salted` exposes the PBKDF2 intermediate so the
617        // operator can produce the KIP-554 wire `salted_password`. It
618        // must equal the value `hash_scram_password_with_salt` feeds
619        // into `derive_keys_from_salted` internally.
620        let password = b"pencil";
621        let salt: Vec<u8> = (0..16).collect();
622        for (_case, mechanism, expected_len) in [
623            ("SCRAM SHA-512", SaslMechanism::ScramSha512, 64),
624            ("SCRAM SHA-256", SaslMechanism::ScramSha256, 32),
625        ] {
626            let cred = hash_scram_password_with_salt(password, mechanism, 4096, salt.clone());
627            let salted = pbkdf2_salted(password, mechanism, 4096, &salt);
628            let (stored_key, server_key) = derive_keys_from_salted(mechanism, &salted);
629            assert2::assert!(salted.len() == expected_len);
630            assert2::assert!(stored_key == cred.stored_key);
631            assert2::assert!(server_key == cred.server_key);
632        }
633    }
634
635    #[test]
636    fn derive_keys_from_salted_matches_hash_scram_password_sha512() {
637        let password = b"hunter2";
638        let salt: Vec<u8> = (0..16).collect();
639        let cred =
640            hash_scram_password_with_salt(password, SaslMechanism::ScramSha512, 4096, salt.clone());
641        let salted: [u8; 64] = pbkdf2::pbkdf2_hmac_array::<sha2::Sha512, 64>(password, &salt, 4096);
642        let (stored_key, server_key) = derive_keys_from_salted(SaslMechanism::ScramSha512, &salted);
643        check!(
644            (&stored_key, &server_key, stored_key.len(), server_key.len())
645                == (&cred.stored_key, &cred.server_key, 64, 64)
646        );
647    }
648
649    #[test]
650    fn derive_keys_from_salted_matches_hash_scram_password_sha256() {
651        let password = b"hunter2";
652        let salt: Vec<u8> = (0..16).collect();
653        let cred =
654            hash_scram_password_with_salt(password, SaslMechanism::ScramSha256, 4096, salt.clone());
655        let salted: [u8; 32] = pbkdf2::pbkdf2_hmac_array::<sha2::Sha256, 32>(password, &salt, 4096);
656        let (stored_key, server_key) = derive_keys_from_salted(SaslMechanism::ScramSha256, &salted);
657        check!(
658            (&stored_key, &server_key, stored_key.len(), server_key.len())
659                == (&cred.stored_key, &cred.server_key, 32, 32)
660        );
661    }
662
663    /// KIP-48: `new_with_principal` stamps an override principal that wins on
664    /// the `Done` arm. The delegation-token SCRAM fallback uses it, so a client
665    /// that authenticates with a `tokenId` as the SCRAM username surfaces as
666    /// the token's owner, for example `User:alice`, and not as
667    /// `User:<token-uuid>`.
668    #[test]
669    fn scram_server_with_principal_override_yields_override_on_done() {
670        let password = b"hunter2";
671        let cred = hash_scram_password_with_salt(
672            password,
673            SaslMechanism::ScramSha256,
674            4096,
675            (0..16).collect::<Vec<u8>>(),
676        );
677        let override_principal = crate::Principal {
678            name: "alice".to_owned(),
679            auth_method: crate::AuthMethod::SaslScramSha256,
680            groups: vec![],
681        };
682        // SCRAM username (the wire "n=..." attribute) is "tok-uuid";
683        // the override principal is "alice" (the token's owner).
684        let server = ScramServerExchange::new_with_principal(
685            "tok-uuid".to_owned(),
686            cred,
687            override_principal.clone(),
688        );
689        let client = ScramClientExchange::new(
690            "tok-uuid".to_owned(),
691            password.to_vec(),
692            SaslMechanism::ScramSha256,
693        );
694
695        let (c1, client) = client.client_first().expect("client first");
696        let (s1, server) = match server.step(&c1) {
697            StepResult::Continue(b, next) => (b, next),
698            other => panic!("server step 1 must continue, got {other:?}"),
699        };
700        let (c2, _client) = client.step(&s1).expect("client final");
701        let (principal, _s2) = match server.step(&c2) {
702            StepResult::Done(p, b) => (p, b),
703            other => panic!("server step 2 must Done, got {other:?}"),
704        };
705        // Override wins: principal is the token owner, NOT "tok-uuid".
706        assert2::assert!(principal == override_principal);
707    }
708
709    #[test]
710    fn scram_server_rejects_bad_proof() {
711        let cred = hash_scram_password_with_salt(
712            b"correct",
713            SaslMechanism::ScramSha512,
714            4096,
715            vec![0u8; 16],
716        );
717        let server = ScramServerExchange::new("alice".to_owned(), cred);
718        let client = ScramClientExchange::new(
719            "alice".to_owned(),
720            b"wrong".to_vec(),
721            SaslMechanism::ScramSha512,
722        );
723        let (c1, client) = client.client_first().unwrap();
724        let StepResult::Continue(s1, server) = server.step(&c1) else {
725            panic!();
726        };
727        let (c2, _client) = client.step(&s1).unwrap();
728        match server.step(&c2) {
729            StepResult::Failed(crate::AuthError::BadProof) => {}
730            other => panic!("expected BadProof, got {other:?}"),
731        }
732    }
733
734    /// RFC 5802 §5.1: the server must reject a client-final whose `r=`
735    /// combined nonce does not equal the nonce it issued in server-first. This
736    /// test tampers with the `r=` attribute of an otherwise well-formed
737    /// client-final and expects `MalformedMessage`.
738    #[test]
739    fn scram_server_rejects_wrong_nonce() {
740        // Arbitrary, non-secret test password generated at runtime (not a
741        // hard-coded credential literal).
742        let password: Vec<u8> = (b'A'..=b'Z').collect();
743        let cred = hash_scram_password_with_salt(
744            &password,
745            SaslMechanism::ScramSha256,
746            4096,
747            (0..16).collect::<Vec<u8>>(),
748        );
749        let server = ScramServerExchange::new("alice".to_owned(), cred);
750        let client =
751            ScramClientExchange::new("alice".to_owned(), password, SaslMechanism::ScramSha256);
752        let (c1, client) = client.client_first().unwrap();
753        let StepResult::Continue(s1, server) = server.step(&c1) else {
754            panic!("server step 1 must continue");
755        };
756        let (c2, _client) = client.step(&s1).unwrap();
757        let c2_str = String::from_utf8(c2).unwrap();
758        // Flip the combined nonce: replace `r=<nonce>` with a different
759        // value while leaving `c=` and `p=` intact.
760        let combined = c2_str
761            .split(',')
762            .find_map(|a| a.strip_prefix("r="))
763            .expect("client-final has r=");
764        let tampered = c2_str.replacen(
765            &format!("r={combined}"),
766            &format!("r={combined}deadbeef"),
767            1,
768        );
769        match server.step(tampered.as_bytes()) {
770            StepResult::Failed(crate::AuthError::MalformedMessage) => {}
771            other => panic!("expected MalformedMessage for wrong nonce, got {other:?}"),
772        }
773    }
774
775    /// Kafka does not compare `c=` with the GS2 header, but the proof signs
776    /// it: a channel binding swapped in after the client signed fails as a
777    /// bad proof.
778    #[test]
779    fn scram_server_fails_a_channel_binding_the_client_did_not_sign() {
780        // Arbitrary, non-secret test password generated at runtime (not a
781        // hard-coded credential literal).
782        let password: Vec<u8> = (b'A'..=b'Z').collect();
783        let cred = hash_scram_password_with_salt(
784            &password,
785            SaslMechanism::ScramSha256,
786            4096,
787            (0..16).collect::<Vec<u8>>(),
788        );
789        let server = ScramServerExchange::new("alice".to_owned(), cred);
790        let client =
791            ScramClientExchange::new("alice".to_owned(), password, SaslMechanism::ScramSha256);
792        let (c1, client) = client.client_first().unwrap();
793        let StepResult::Continue(s1, server) = server.step(&c1) else {
794            panic!("server step 1 must continue");
795        };
796        let (c2, _client) = client.step(&s1).unwrap();
797        let c2_str = String::from_utf8(c2).unwrap();
798        // The client always emits `c=biws`; rewrite it to a different
799        // (still-valid-base64) channel binding.
800        assert2::assert!(c2_str.starts_with("c=biws,"));
801        let wrong_cb = B64.encode(b"y,,");
802        let tampered = c2_str.replacen("c=biws", &format!("c={wrong_cb}"), 1);
803        match server.step(tampered.as_bytes()) {
804            StepResult::Failed(crate::AuthError::BadProof) => {}
805            other => panic!("expected BadProof for an unsigned channel binding, got {other:?}"),
806        }
807    }
808
809    /// Drive a server exchange for `alice` by hand with the given GS2 header
810    /// and extensions in client-first, and the given header base64-encoded as
811    /// the client-final `c=`. Returns the principal on success, or the first
812    /// failure from either round.
813    fn handshake_with_header(
814        gs2_header: &str,
815        extensions: &str,
816        channel_binding_header: &str,
817    ) -> Result<crate::Principal, crate::AuthError> {
818        let password: Vec<u8> = (b'a'..=b'z').collect();
819        let salt: Vec<u8> = (0..16).collect();
820        let cred = hash_scram_password_with_salt(
821            &password,
822            SaslMechanism::ScramSha256,
823            4096,
824            salt.clone(),
825        );
826        let server = ScramServerExchange::new("alice".to_owned(), cred);
827
828        let bare = format!("n=alice,r=client-nonce{extensions}");
829        let (server_first, server) = match server.step(format!("{gs2_header}{bare}").as_bytes()) {
830            StepResult::Continue(server_first, next) => (server_first, next),
831            StepResult::Failed(e) => return Err(e),
832            StepResult::Done(..) | StepResult::Disconnect => {
833                panic!("server ended the exchange on client-first")
834            }
835        };
836        let server_first = String::from_utf8(server_first).unwrap();
837        let combined_nonce = server_first
838            .strip_prefix("r=")
839            .and_then(|rest| rest.split(',').next())
840            .unwrap();
841
842        let client_final_no_proof = format!(
843            "c={},r={combined_nonce}",
844            B64.encode(channel_binding_header.as_bytes())
845        );
846        let auth_message = format!("{bare},{server_first},{client_final_no_proof}");
847        let (proof, _) =
848            client::compute_proof_sha256(&password, &salt, 4096, auth_message.as_bytes()).unwrap();
849        match server.step(format!("{client_final_no_proof},p={}", B64.encode(proof)).as_bytes()) {
850            StepResult::Done(principal, _) => Ok(principal),
851            StepResult::Failed(e) => Err(e),
852            StepResult::Continue(..) | StepResult::Disconnect => {
853                panic!("server did not finish client-final")
854            }
855        }
856    }
857
858    /// Broker issue #764: the server accepts a GS2 header carrying an
859    /// authorization id, as Kafka's `ScramSaslServer` does. Like Kafka, it
860    /// does not hold the client-final `c=` to that header: the proof signs
861    /// whatever channel binding the client sent.
862    #[test]
863    fn scram_server_reads_the_gs2_header_but_not_the_channel_binding() {
864        let alice = Ok(crate::Principal {
865            name: "alice".into(),
866            auth_method: crate::AuthMethod::SaslScramSha256,
867            groups: vec![],
868        });
869        for (case, gs2_header, extensions, channel_binding_header, want) in [
870            ("plain header", "n,,", "", "n,,", alice.clone()),
871            (
872                "authzid naming the user",
873                "n,a=alice,",
874                "",
875                "n,a=alice,",
876                alice.clone(),
877            ),
878            (
879                "token extension",
880                "n,,",
881                ",tokenauth=true",
882                "n,,",
883                alice.clone(),
884            ),
885            (
886                "authzid with extension",
887                "n,a=alice,",
888                ",tokenauth=true",
889                "n,a=alice,",
890                alice.clone(),
891            ),
892            (
893                "authzid naming another user",
894                "n,a=bob,",
895                "",
896                "n,a=bob,",
897                Err(crate::AuthError::AuthorizationIdMismatch),
898            ),
899            (
900                "authzid header but biws binding",
901                "n,a=alice,",
902                "",
903                "n,,",
904                alice.clone(),
905            ),
906            (
907                "plain header but authzid binding",
908                "n,,",
909                "",
910                "n,a=alice,",
911                alice,
912            ),
913            (
914                "channel binding requested",
915                "p=tls-unique,,",
916                "",
917                "p=tls-unique,,",
918                Err(crate::AuthError::MalformedMessage),
919            ),
920            (
921                "client supports binding",
922                "y,,",
923                "",
924                "y,,",
925                Err(crate::AuthError::MalformedMessage),
926            ),
927        ] {
928            check!(
929                handshake_with_header(gs2_header, extensions, channel_binding_header) == want,
930                "case {case}"
931            );
932        }
933    }
934
935    /// Drive a SCRAM-SHA-256 server exchange for `username` with a hand-built
936    /// client-first (`gs2_header` then `bare`) and a client-final built from
937    /// `template`. In `template`, `{cb}` stands for the base64 of
938    /// `gs2_header`, `{nonce}` for the combined nonce and `{proof}` for the
939    /// base64 proof. The proof signs `c={cb},r={nonce}`, which is what Kafka
940    /// rebuilds for any of these templates. With
941    /// `flip_unused_bit`, the proof's last base64 character has its unused
942    /// low bit set. Returns the principal, or the first failure.
943    fn handshake_with_client_final(
944        username: &str,
945        gs2_header: &str,
946        bare: &str,
947        template: &str,
948        flip_unused_bit: bool,
949    ) -> Result<Principal, AuthError> {
950        let password: Vec<u8> = (b'a'..=b'z').collect();
951        let salt: Vec<u8> = (0..16).collect();
952        let cred = hash_scram_password_with_salt(
953            &password,
954            SaslMechanism::ScramSha256,
955            4096,
956            salt.clone(),
957        );
958        let server = ScramServerExchange::new(username.to_owned(), cred);
959        let (server_first, server) = match server.step(format!("{gs2_header}{bare}").as_bytes()) {
960            StepResult::Continue(server_first, next) => (server_first, next),
961            StepResult::Failed(e) => return Err(e),
962            StepResult::Done(..) | StepResult::Disconnect => {
963                panic!("server ended the exchange on client-first")
964            }
965        };
966        let server_first = String::from_utf8(server_first).unwrap();
967        let combined_nonce = server_first
968            .strip_prefix("r=")
969            .and_then(|rest| rest.split(',').next())
970            .unwrap();
971        let client_final = template
972            .replace("{cb}", &B64.encode(gs2_header.as_bytes()))
973            .replace("{nonce}", combined_nonce);
974        let signed = format!("c={},r={combined_nonce}", B64.encode(gs2_header.as_bytes()));
975        let auth_message = format!("{bare},{server_first},{signed}");
976        let (client_proof, _) =
977            client::compute_proof_sha256(&password, &salt, 4096, auth_message.as_bytes()).unwrap();
978        let mut proof_b64 = B64.encode(client_proof);
979        if flip_unused_bit {
980            // 32 bytes encode as 43 characters and one `=`; the 43rd carries
981            // four data bits and two unused ones.
982            let alphabet = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
983            let last = proof_b64.as_bytes()[42];
984            let index = alphabet.iter().position(|&c| c == last).unwrap();
985            proof_b64.replace_range(42..43, &char::from(alphabet[index ^ 1]).to_string());
986        }
987        match server.step(client_final.replace("{proof}", &proof_b64).as_bytes()) {
988            StepResult::Done(principal, _) => Ok(principal),
989            StepResult::Failed(e) => Err(e),
990            StepResult::Continue(..) | StepResult::Disconnect => {
991                panic!("server did not finish client-final")
992            }
993        }
994    }
995
996    /// Issue #65: the server reads the client-final message with Kafka's
997    /// `ClientFinalMessage` grammar, signs it without its extensions, decodes
998    /// the proof as Java does, and compares the authzid as the client
999    /// escaped it with the decoded username, as `ScramSaslServer` does.
1000    #[test]
1001    fn scram_server_reads_client_final_with_kafkas_grammar() {
1002        let principal = |name: &str| {
1003            Ok(Principal {
1004                name: name.into(),
1005                auth_method: AuthMethod::SaslScramSha256,
1006                groups: vec![],
1007            })
1008        };
1009        let malformed = Err(AuthError::MalformedMessage);
1010        let alice_bare = "n=alice,r=client-nonce";
1011        for (case, username, gs2_header, bare, template, flip_unused_bit, want) in [
1012            (
1013                "kafka order",
1014                "alice",
1015                "n,,",
1016                alice_bare,
1017                "c={cb},r={nonce},p={proof}",
1018                false,
1019                principal("alice"),
1020            ),
1021            (
1022                "extension before the proof is not signed",
1023                "alice",
1024                "n,,",
1025                alice_bare,
1026                "c={cb},r={nonce},foo=bar,p={proof}",
1027                false,
1028                principal("alice"),
1029            ),
1030            (
1031                "unused proof bit set",
1032                "alice",
1033                "n,,",
1034                alice_bare,
1035                "c={cb},r={nonce},p={proof}",
1036                true,
1037                principal("alice"),
1038            ),
1039            (
1040                "nonce before channel binding",
1041                "alice",
1042                "n,,",
1043                alice_bare,
1044                "r={nonce},c={cb},p={proof}",
1045                false,
1046                malformed.clone(),
1047            ),
1048            (
1049                "proof before nonce",
1050                "alice",
1051                "n,,",
1052                alice_bare,
1053                "c={cb},p={proof},r={nonce}",
1054                false,
1055                malformed.clone(),
1056            ),
1057            (
1058                "unknown attribute after the proof",
1059                "alice",
1060                "n,,",
1061                alice_bare,
1062                "c={cb},r={nonce},p={proof},x=1",
1063                false,
1064                malformed.clone(),
1065            ),
1066            (
1067                "duplicate channel binding",
1068                "alice",
1069                "n,,",
1070                alice_bare,
1071                "c={cb},c={cb},r={nonce},p={proof}",
1072                false,
1073                malformed.clone(),
1074            ),
1075            (
1076                // Kafka reads the second `r=` as an extension.
1077                "repeated nonce",
1078                "alice",
1079                "n,,",
1080                alice_bare,
1081                "c={cb},r={nonce},r={nonce},p={proof}",
1082                false,
1083                principal("alice"),
1084            ),
1085            (
1086                "trailing comma",
1087                "alice",
1088                "n,,",
1089                alice_bare,
1090                "c={cb},r={nonce},p={proof},",
1091                false,
1092                malformed,
1093            ),
1094            (
1095                "escaped authzid equal to the decoded username",
1096                "a=3D",
1097                "n,a=a=3D,",
1098                "n=a=3D3D,r=client-nonce",
1099                "c={cb},r={nonce},p={proof}",
1100                false,
1101                principal("a=3D"),
1102            ),
1103            (
1104                "escaped authzid that decodes to the username",
1105                "a,b",
1106                "n,a=a=2Cb,",
1107                "n=a=2Cb,r=client-nonce",
1108                "c={cb},r={nonce},p={proof}",
1109                false,
1110                Err(AuthError::AuthorizationIdMismatch),
1111            ),
1112        ] {
1113            check!(
1114                handshake_with_client_final(username, gs2_header, bare, template, flip_unused_bit)
1115                    == want,
1116                "case {case}"
1117            );
1118        }
1119    }
1120
1121    /// Kafka's `ScramFormatter.authMessage` signs the messages that
1122    /// `ClientFirstMessage.clientFirstMessageBare` and
1123    /// `ClientFinalMessage.clientFinalMessageWithoutProof` rebuild from the
1124    /// parsed fields, not the bytes the client sent. The expected forms below
1125    /// are what Kafka 4.3.1's own `ScramMessages` returns for each message.
1126    /// A proof over Kafka's form authenticates, and the server signature is
1127    /// over the same form. A proof over the raw bytes, where they differ,
1128    /// fails as a bad proof, as it does against Kafka.
1129    #[test]
1130    fn scram_server_signs_the_auth_message_kafka_rebuilds() {
1131        let password: Vec<u8> = (b'a'..=b'z').collect();
1132        let salt: Vec<u8> = (0..16).collect();
1133        let alice = Principal {
1134            name: "alice".into(),
1135            auth_method: AuthMethod::SaslScramSha256,
1136            groups: vec![],
1137        };
1138        for (case, client_first, client_final, kafka_bare, kafka_final) in [
1139            (
1140                "canonical",
1141                "n,,n=alice,r=cn",
1142                "c=biws,r={nonce},p={proof}",
1143                "n=alice,r=cn",
1144                "c=biws,r={nonce}",
1145            ),
1146            (
1147                "reserved attribute",
1148                "n,,m=ext,n=alice,r=cn",
1149                "c=biws,r={nonce},p={proof}",
1150                "n=alice,r=cn",
1151                "c=biws,r={nonce}",
1152            ),
1153            (
1154                "repeated client-first extension",
1155                "n,,n=alice,r=cn,foo=1,foo=2",
1156                "c=biws,r={nonce},p={proof}",
1157                "n=alice,r=cn,foo=2",
1158                "c=biws,r={nonce}",
1159            ),
1160            (
1161                "client-final extension",
1162                "n,,n=alice,r=cn",
1163                "c=biws,r={nonce},foo=bar,p={proof}",
1164                "n=alice,r=cn",
1165                "c=biws,r={nonce}",
1166            ),
1167            (
1168                "unused bits in the channel binding",
1169                "n,a=alice,n=alice,r=cn",
1170                "c=bixhPWFsaWNlLB==,r={nonce},p={proof}",
1171                "n=alice,r=cn",
1172                "c=bixhPWFsaWNlLA==,r={nonce}",
1173            ),
1174            (
1175                "channel binding of another header",
1176                "n,,n=alice,r=cn",
1177                "c=bixhPWFsaWNlLA==,r={nonce},p={proof}",
1178                "n=alice,r=cn",
1179                "c=bixhPWFsaWNlLA==,r={nonce}",
1180            ),
1181        ] {
1182            let raw_bare = client_first.splitn(3, ',').nth(2).unwrap();
1183            let raw_final = client_final.split(",p={proof}").next().unwrap();
1184            for (signed_bare, signed_final) in [(kafka_bare, kafka_final), (raw_bare, raw_final)] {
1185                let cred = hash_scram_password_with_salt(
1186                    &password,
1187                    SaslMechanism::ScramSha256,
1188                    4096,
1189                    salt.clone(),
1190                );
1191                let server = ScramServerExchange::new("alice".to_owned(), cred);
1192                let StepResult::Continue(server_first, server) =
1193                    server.step(client_first.as_bytes())
1194                else {
1195                    panic!("case {case}: server step 1 must continue");
1196                };
1197                let server_first = String::from_utf8(server_first).unwrap();
1198                let combined_nonce = server_first
1199                    .strip_prefix("r=")
1200                    .and_then(|rest| rest.split(',').next())
1201                    .unwrap();
1202                let auth_message = format!(
1203                    "{signed_bare},{server_first},{}",
1204                    signed_final.replace("{nonce}", combined_nonce)
1205                );
1206                let (client_proof, server_key) =
1207                    client::compute_proof_sha256(&password, &salt, 4096, auth_message.as_bytes())
1208                        .unwrap();
1209                let mut server_mac = <Hmac<Sha256>>::new_from_slice(&server_key).unwrap();
1210                server_mac.update(auth_message.as_bytes());
1211                let server_final =
1212                    format!("v={}", B64.encode(server_mac.finalize().into_bytes())).into_bytes();
1213                let want = if signed_bare == kafka_bare && signed_final == kafka_final {
1214                    Ending::Done(alice.clone(), server_final)
1215                } else {
1216                    Ending::Failed(AuthError::BadProof)
1217                };
1218                let message = client_final
1219                    .replace("{nonce}", combined_nonce)
1220                    .replace("{proof}", &B64.encode(client_proof));
1221                check!(
1222                    Ending::try_from(server.step(message.as_bytes())).unwrap() == want,
1223                    "case {case}, signed {signed_bare} / {signed_final}"
1224                );
1225            }
1226        }
1227    }
1228
1229    /// The client sends the extensions it is given after the nonce, and the
1230    /// server signs them as part of the bare message, so a `tokenauth=true`
1231    /// login completes.
1232    #[test]
1233    fn scram_client_sends_extensions_in_client_first() {
1234        let password = b"token-hmac";
1235        let cred = hash_scram_password_with_salt(
1236            password,
1237            SaslMechanism::ScramSha256,
1238            4096,
1239            (0..16).collect::<Vec<u8>>(),
1240        );
1241        let server = ScramServerExchange::new("token-id".to_owned(), cred);
1242        let client = ScramClientExchange::new(
1243            "token-id".to_owned(),
1244            password.to_vec(),
1245            SaslMechanism::ScramSha256,
1246        )
1247        .with_extensions([(TOKEN_AUTH_EXTENSION, "true")])
1248        .unwrap();
1249
1250        let (c1, client) = client.client_first().unwrap();
1251        let parsed = ScramClientFirst::parse(&c1).unwrap();
1252        check!(
1253            (
1254                parsed.gs2_header.as_str(),
1255                parsed.username.as_str(),
1256                parsed.extensions.clone(),
1257                parsed.token_authenticated(),
1258            ) == (
1259                "n,,",
1260                "token-id",
1261                vec![(TOKEN_AUTH_EXTENSION.to_owned(), "true".to_owned())],
1262                true,
1263            )
1264        );
1265        let StepResult::Continue(s1, server) = server.step(&c1) else {
1266            panic!("server step 1 must continue");
1267        };
1268        let (c2, client) = client.step(&s1).unwrap();
1269        let StepResult::Done(principal, s2) = server.step(&c2) else {
1270            panic!("server step 2 must finish");
1271        };
1272        check!(principal.name == "token-id");
1273        check!(client.verify_server_final(&s2).is_ok());
1274    }
1275
1276    /// A repeated extension key is sent once, at its first position with its
1277    /// last value, as Kafka's map-backed client sends it, so the server's
1278    /// rebuilt `AuthMessage` matches what the client signed.
1279    #[test]
1280    fn scram_client_sends_a_repeated_extension_once_and_authenticates() {
1281        let password = b"pw";
1282        let cred = hash_scram_password_with_salt(
1283            password,
1284            SaslMechanism::ScramSha256,
1285            4096,
1286            (0..16).collect::<Vec<u8>>(),
1287        );
1288        let server = ScramServerExchange::new("alice".to_owned(), cred);
1289        let client = ScramClientExchange::new(
1290            "alice".to_owned(),
1291            password.to_vec(),
1292            SaslMechanism::ScramSha256,
1293        )
1294        .with_extensions([("foo", "1"), ("bar", "2"), ("foo", "3")])
1295        .unwrap();
1296
1297        let (c1, client) = client.client_first().unwrap();
1298        let parsed = ScramClientFirst::parse(&c1).unwrap();
1299        check!(
1300            parsed.extensions
1301                == vec![
1302                    ("foo".to_owned(), "3".to_owned()),
1303                    ("bar".to_owned(), "2".to_owned()),
1304                ]
1305        );
1306        let StepResult::Continue(s1, server) = server.step(&c1) else {
1307            panic!("server step 1 must continue");
1308        };
1309        let (c2, client) = client.step(&s1).unwrap();
1310        let StepResult::Done(principal, s2) = server.step(&c2) else {
1311            panic!("server step 2 must finish");
1312        };
1313        check!(principal.name == "alice");
1314        check!(client.verify_server_final(&s2).is_ok());
1315    }
1316
1317    #[test]
1318    fn scram_client_rejects_extensions_kafka_cannot_parse() {
1319        for (case, key, value) in [
1320            ("empty key", "", "true"),
1321            ("non-alphabetic key", "token_auth", "true"),
1322            ("empty value", "tokenauth", ""),
1323            ("comma in value", "tokenauth", "a,b"),
1324            ("non-ASCII value", "tokenauth", "\u{e9}"),
1325        ] {
1326            let client = ScramClientExchange::new(
1327                "alice".to_owned(),
1328                b"pw".to_vec(),
1329                SaslMechanism::ScramSha256,
1330            );
1331            check!(
1332                client.with_extensions([(key, value)]).map(|_| ())
1333                    == Err(crate::AuthError::MalformedMessage),
1334                "case {case}"
1335            );
1336        }
1337    }
1338
1339    /// A username holding `,` or `=` is sent as an escaped `saslname` and the
1340    /// server authenticates it under its decoded name.
1341    #[test]
1342    fn scram_round_trip_escapes_the_username() {
1343        let password = b"pw";
1344        let cred = hash_scram_password_with_salt(
1345            password,
1346            SaslMechanism::ScramSha256,
1347            4096,
1348            (0..16).collect::<Vec<u8>>(),
1349        );
1350        let server = ScramServerExchange::new("a,b=c".to_owned(), cred);
1351        let client = ScramClientExchange::new(
1352            "a,b=c".to_owned(),
1353            password.to_vec(),
1354            SaslMechanism::ScramSha256,
1355        );
1356        let (c1, client) = client.client_first().unwrap();
1357        check!(c1.starts_with(b"n,,n=a=2Cb=3Dc,r="));
1358        let StepResult::Continue(s1, server) = server.step(&c1) else {
1359            panic!("server step 1 must continue");
1360        };
1361        let (c2, _client) = client.step(&s1).unwrap();
1362        let StepResult::Done(principal, _) = server.step(&c2) else {
1363            panic!("server step 2 must finish");
1364        };
1365        check!(principal.name == "a,b=c");
1366    }
1367}