Skip to main content

krabka_security/scram/
messages.rs

1//! RFC 5802 client-first parsing and the `saslname` and extension encodings,
2//! shaped as Kafka's `ScramMessages.ClientFirstMessage` accepts them.
3//!
4//! Kafka's grammar is
5//! `n,[a=<authzid>],[m=<value>,]n=<saslname>,r=<nonce>[,<alpha>=<value>]*`.
6//! Only the `n` channel-binding flag is accepted: Kafka runs SCRAM without
7//! channel binding, so a `y` or `p` GS2 header is malformed.
8//!
9//! Both sides sign an `AuthMessage` that Kafka's `ScramFormatter.authMessage`
10//! rebuilds from the parsed fields rather than from the bytes the client
11//! sent: `ClientFirstMessage.clientFirstMessageBare` and
12//! `ClientFinalMessage.clientFinalMessageWithoutProof`. A client that signs
13//! the bytes it sent, as RFC 5802 reads, authenticates only when the two
14//! agree, as against Kafka.
15
16use std::{collections::HashMap, iter, str::from_utf8};
17
18use base64::{
19    Engine, alphabet,
20    engine::{GeneralPurpose, GeneralPurposeConfig, general_purpose::STANDARD as B64},
21};
22
23use crate::AuthError;
24
25/// Standard base64 that, like Java's `Base64` decoder, ignores the unused
26/// bits of the last character: Java decodes `bh==` to `n`, as it does `bg==`.
27pub(super) const JAVA_B64: GeneralPurpose = GeneralPurpose::new(
28    &alphabet::STANDARD,
29    GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
30);
31
32/// The SCRAM extension that marks a KIP-48 delegation-token authentication.
33/// Its value is `true` when the SCRAM username is a token id.
34pub const TOKEN_AUTH_EXTENSION: &str = "tokenauth";
35
36/// A parsed SCRAM client-first message.
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ScramClientFirst {
39    /// The GS2 header exactly as the client sent it, `n,,` or `n,a=<authzid>,`.
40    pub gs2_header: String,
41    /// The decoded GS2 authorization id, `None` when the header carries none.
42    pub authzid: Option<String>,
43    /// The decoded SCRAM username (`n=`), with `=2C` and `=3D` unescaped.
44    pub username: String,
45    /// The client nonce (`r=`).
46    pub nonce: String,
47    /// The SCRAM extensions after the nonce, in the order the client sent
48    /// them, for example `("tokenauth", "true")`.
49    pub extensions: Vec<(String, String)>,
50    /// `client-first-message-bare` as Kafka's
51    /// `ClientFirstMessage.clientFirstMessageBare` rebuilds it, the first part
52    /// of the `AuthMessage` both sides sign: `n=<saslname as sent>,r=<nonce>`,
53    /// then `,<key>=<value>` for each distinct extension key. A reserved `m=`
54    /// attribute is dropped, and a repeated key keeps its last value, as the
55    /// map that `ScramExtensions` builds does.
56    ///
57    /// Kafka iterates that map through `Map.copyOf`, whose order is fixed for
58    /// one entry but randomized per JVM for two or more, so no order matches
59    /// Kafka every time for a client that sends several distinct extensions.
60    /// The keys keep the order in which the client first sent them, which is
61    /// the order Kafka's own client signs.
62    pub bare: String,
63}
64
65impl ScramClientFirst {
66    /// Parse a client-first message.
67    ///
68    /// # Errors
69    /// [`AuthError::MalformedMessage`] when the message is not UTF-8, has a
70    /// GS2 header other than `n,,` or `n,a=<authzid>,`, or does not follow
71    /// Kafka's client-first grammar.
72    pub fn parse(bytes: &[u8]) -> Result<Self, AuthError> {
73        let message = from_utf8(bytes).map_err(|_| AuthError::MalformedMessage)?;
74        let after_flag = message
75            .strip_prefix("n,")
76            .ok_or(AuthError::MalformedMessage)?;
77        let (authzid_part, bare) = after_flag
78            .split_once(',')
79            .ok_or(AuthError::MalformedMessage)?;
80        let authzid = if authzid_part.is_empty() {
81            None
82        } else {
83            let raw = authzid_part
84                .strip_prefix("a=")
85                .ok_or(AuthError::MalformedMessage)?;
86            Some(decode_saslname(raw)?)
87        };
88        let gs2_header = format!("n,{authzid_part},");
89
90        let mut attributes = bare.split(',').peekable();
91        // Kafka accepts, and ignores, a reserved `m=` attribute here.
92        if let Some(reserved) = attributes
93            .peek()
94            .copied()
95            .and_then(|a| a.strip_prefix("m="))
96        {
97            attributes.next();
98            if !is_value(reserved) {
99                return Err(AuthError::MalformedMessage);
100            }
101        }
102        let saslname = attributes
103            .next()
104            .and_then(|a| a.strip_prefix("n="))
105            .ok_or(AuthError::MalformedMessage)?;
106        let username = decode_saslname(saslname)?;
107        let nonce = attributes
108            .next()
109            .and_then(|a| a.strip_prefix("r="))
110            .filter(|n| is_printable(n))
111            .ok_or(AuthError::MalformedMessage)?
112            .to_owned();
113        let extensions = attributes
114            .map(|attribute| {
115                attribute
116                    .split_once('=')
117                    .filter(|(key, value)| is_extension(key, value))
118                    .map(|(key, value)| (key.to_owned(), value.to_owned()))
119                    .ok_or(AuthError::MalformedMessage)
120            })
121            .collect::<Result<Vec<_>, _>>()?;
122        let bare = iter::once(format!("n={saslname},r={nonce}"))
123            .chain(
124                distinct_extensions(&extensions)
125                    .into_iter()
126                    .map(|(key, value)| format!("{key}={value}")),
127            )
128            .collect::<Vec<_>>()
129            .join(",");
130
131        Ok(Self {
132            gs2_header,
133            authzid,
134            username,
135            nonce,
136            extensions,
137            bare,
138        })
139    }
140
141    /// The value of extension `key`, if the client sent it.
142    ///
143    /// When the client sent `key` more than once, the last value wins, as in
144    /// the map that Kafka's `ScramExtensions` builds.
145    #[must_use]
146    pub fn extension(&self, key: &str) -> Option<&str> {
147        self.extensions
148            .iter()
149            .rev()
150            .find(|(k, _)| k == key)
151            .map(|(_, v)| v.as_str())
152    }
153
154    /// True when the client sent `tokenauth=true`, Kafka's
155    /// `ScramExtensions.tokenAuthenticated`.
156    #[must_use]
157    pub fn token_authenticated(&self) -> bool {
158        self.extension(TOKEN_AUTH_EXTENSION)
159            .is_some_and(|v| v.eq_ignore_ascii_case("true"))
160    }
161}
162
163/// Each distinct extension key once, in the order the client first sent it,
164/// with the last value the client sent for it.
165///
166/// The client-first message arrives before authentication, so a key's slot
167/// is found through a hash index rather than a scan of the keys seen so far.
168fn distinct_extensions(extensions: &[(String, String)]) -> Vec<(&str, &str)> {
169    let mut last: HashMap<&str, &str> = HashMap::with_capacity(extensions.len());
170    for (key, value) in extensions {
171        last.insert(key, value);
172    }
173    // Taking a key's value out of `last` at its first occurrence emits each
174    // key once, in first-sent order.
175    extensions
176        .iter()
177        .filter_map(|(key, _)| last.remove(key.as_str()).map(|value| (key.as_str(), value)))
178        .collect()
179}
180
181/// A SCRAM client-final message, split as Kafka's
182/// `ScramMessages.ClientFinalMessage` matches it.
183///
184/// Kafka's grammar is `c=<base64>,r=<nonce>[,<alpha>=<value>]*,p=<base64>`,
185/// in that order and nothing else. Every field but `without_proof` borrows
186/// the raw message.
187#[derive(Debug, Clone, PartialEq, Eq)]
188pub(super) struct ScramClientFinal<'a> {
189    /// The base64 channel binding (`c=`), as the client sent it.
190    pub channel_binding: &'a str,
191    /// The combined nonce (`r=`).
192    pub nonce: &'a str,
193    /// The extensions between the nonce and the proof, in the order the
194    /// client sent them.
195    pub extensions: Vec<(&'a str, &'a str)>,
196    /// The base64 client proof (`p=`), as the client sent it.
197    pub proof: &'a str,
198    /// `client-final-message-without-proof` as Kafka's
199    /// `ClientFinalMessage.clientFinalMessageWithoutProof` rebuilds it, the
200    /// last part of the `AuthMessage` both sides sign:
201    /// `c=<channel binding>,r=<nonce>`. The extensions are dropped, and the
202    /// channel binding is decoded and encoded again as Java's `Base64` does,
203    /// so unused bits the client set in its last character are cleared.
204    pub without_proof: String,
205}
206
207impl<'a> ScramClientFinal<'a> {
208    /// Parse a client-final message with Kafka's grammar.
209    ///
210    /// # Errors
211    /// [`AuthError::MalformedMessage`] when the message is not UTF-8 or does
212    /// not match Kafka's client-final grammar as a whole.
213    pub(super) fn parse(bytes: &'a [u8]) -> Result<Self, AuthError> {
214        let message = from_utf8(bytes).map_err(|_| AuthError::MalformedMessage)?;
215        // No base64 character is a `,`, so the proof is what follows the
216        // last `,p=`. A later attribute leaves a `,` in it and fails below.
217        let (before_proof, proof) = message
218            .rsplit_once(",p=")
219            .filter(|(_, proof)| is_base64(proof))
220            .ok_or(AuthError::MalformedMessage)?;
221        let mut attributes = before_proof.split(',');
222        let channel_binding = attributes
223            .next()
224            .and_then(|a| a.strip_prefix("c="))
225            .filter(|c| is_base64(c))
226            .ok_or(AuthError::MalformedMessage)?;
227        let nonce = attributes
228            .next()
229            .and_then(|a| a.strip_prefix("r="))
230            .filter(|n| is_printable(n))
231            .ok_or(AuthError::MalformedMessage)?;
232        let extensions = attributes
233            .map(|attribute| {
234                attribute
235                    .split_once('=')
236                    .filter(|(key, value)| is_extension(key, value))
237                    .ok_or(AuthError::MalformedMessage)
238            })
239            .collect::<Result<Vec<_>, _>>()?;
240        let channel_binding_bytes = JAVA_B64
241            .decode(channel_binding)
242            .map_err(|_| AuthError::MalformedMessage)?;
243        let without_proof = format!("c={},r={nonce}", B64.encode(channel_binding_bytes));
244        Ok(Self {
245            channel_binding,
246            nonce,
247            extensions,
248            proof,
249            without_proof,
250        })
251    }
252}
253
254/// Kafka's `BASE64` grammar: groups of four base64 characters, the last of
255/// which may end in `=` or `==`. It matches the empty string.
256fn is_base64(value: &str) -> bool {
257    let (body, remainder) = value.strip_suffix("==").map_or_else(
258        || value.strip_suffix('=').map_or((value, 0), |body| (body, 3)),
259        |body| (body, 2),
260    );
261    body.len() % 4 == remainder
262        && body
263            .bytes()
264            .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/')
265}
266
267/// Encode a username as an RFC 5802 `saslname`: `=` becomes `=3D` and `,`
268/// becomes `=2C`, as Kafka's `ScramFormatter.saslName` does.
269#[must_use]
270pub fn encode_saslname(username: &str) -> String {
271    username.replace('=', "=3D").replace(',', "=2C")
272}
273
274/// Decode an RFC 5802 `saslname`, as Kafka's `ScramFormatter.username` does.
275///
276/// # Errors
277/// [`AuthError::MalformedMessage`] when the name is empty, holds a `,`, or
278/// holds an `=` that does not start `=2C` or `=3D`.
279pub fn decode_saslname(saslname: &str) -> Result<String, AuthError> {
280    if saslname.is_empty() || saslname.contains(',') {
281        return Err(AuthError::MalformedMessage);
282    }
283    let mut decoded = String::with_capacity(saslname.len());
284    let mut rest = saslname;
285    while let Some((before, after)) = rest.split_once('=') {
286        decoded.push_str(before);
287        let (escape, tail) = after
288            .split_at_checked(2)
289            .ok_or(AuthError::MalformedMessage)?;
290        decoded.push(match escape {
291            "2C" => ',',
292            "3D" => '=',
293            _ => return Err(AuthError::MalformedMessage),
294        });
295        rest = tail;
296    }
297    decoded.push_str(rest);
298    Ok(decoded)
299}
300
301/// Kafka's `EXTENSIONS` grammar: an ASCII-alphabetic key and a non-empty
302/// value of ASCII characters other than NUL and `,`.
303pub(super) fn is_extension(key: &str, value: &str) -> bool {
304    !key.is_empty() && key.bytes().all(|b| b.is_ascii_alphabetic()) && is_value(value)
305}
306
307fn is_value(value: &str) -> bool {
308    !value.is_empty()
309        && value
310            .bytes()
311            .all(|b| (0x01..=0x7F).contains(&b) && b != b',')
312}
313
314fn is_printable(value: &str) -> bool {
315    !value.is_empty()
316        && value
317            .bytes()
318            .all(|b| (0x21..=0x7E).contains(&b) && b != b',')
319}
320
321#[cfg(test)]
322mod tests {
323    use assert2::check;
324
325    use super::*;
326
327    #[test]
328    fn client_first_parses_kafkas_grammar() {
329        for (case, message, want) in [
330            (
331                "plain header",
332                "n,,n=alice,r=abc",
333                ScramClientFirst {
334                    gs2_header: "n,,".into(),
335                    authzid: None,
336                    username: "alice".into(),
337                    nonce: "abc".into(),
338                    extensions: vec![],
339                    bare: "n=alice,r=abc".into(),
340                },
341            ),
342            (
343                "authzid header",
344                "n,a=alice,n=alice,r=abc",
345                ScramClientFirst {
346                    gs2_header: "n,a=alice,".into(),
347                    authzid: Some("alice".into()),
348                    username: "alice".into(),
349                    nonce: "abc".into(),
350                    extensions: vec![],
351                    bare: "n=alice,r=abc".into(),
352                },
353            ),
354            (
355                "token extension",
356                "n,,n=tok-id,r=abc,tokenauth=true",
357                ScramClientFirst {
358                    gs2_header: "n,,".into(),
359                    authzid: None,
360                    username: "tok-id".into(),
361                    nonce: "abc".into(),
362                    extensions: vec![("tokenauth".into(), "true".into())],
363                    bare: "n=tok-id,r=abc,tokenauth=true".into(),
364                },
365            ),
366            (
367                "escaped names and reserved attribute",
368                "n,a=a=2Cb=3Dc,m=ignored,n=a=2Cb=3Dc,r=x,foo=1,bar=2",
369                ScramClientFirst {
370                    gs2_header: "n,a=a=2Cb=3Dc,".into(),
371                    authzid: Some("a,b=c".into()),
372                    username: "a,b=c".into(),
373                    nonce: "x".into(),
374                    extensions: vec![("foo".into(), "1".into()), ("bar".into(), "2".into())],
375                    bare: "n=a=2Cb=3Dc,r=x,foo=1,bar=2".into(),
376                },
377            ),
378            (
379                "repeated extension keeps its first place and last value",
380                "n,,n=alice,r=x,a=1,b=2,a=3",
381                ScramClientFirst {
382                    gs2_header: "n,,".into(),
383                    authzid: None,
384                    username: "alice".into(),
385                    nonce: "x".into(),
386                    extensions: vec![
387                        ("a".into(), "1".into()),
388                        ("b".into(), "2".into()),
389                        ("a".into(), "3".into()),
390                    ],
391                    bare: "n=alice,r=x,a=3,b=2".into(),
392                },
393            ),
394        ] {
395            check!(
396                ScramClientFirst::parse(message.as_bytes()) == Ok(want),
397                "case {case}"
398            );
399        }
400    }
401
402    #[test]
403    fn client_first_rejects_what_kafka_rejects() {
404        for (case, message) in [
405            (
406                "channel binding requested",
407                &b"p=tls-unique,,n=alice,r=abc"[..],
408            ),
409            ("client supports binding", b"y,,n=alice,r=abc"),
410            ("no GS2 header", b"n=alice,r=abc"),
411            ("authzid without a=", b"n,alice,n=alice,r=abc"),
412            ("empty authzid", b"n,a=,n=alice,r=abc"),
413            ("attributes out of order", b"n,,r=abc,n=alice"),
414            ("missing nonce", b"n,,n=alice"),
415            ("empty nonce", b"n,,n=alice,r="),
416            ("bad escape", b"n,,n=a=2Xb,r=abc"),
417            ("truncated escape", b"n,,n=ab=2,r=abc"),
418            ("extension without value", b"n,,n=alice,r=abc,tokenauth"),
419            (
420                "non-alphabetic extension key",
421                b"n,,n=alice,r=abc,t0ken=true",
422            ),
423            ("not UTF-8", b"n,,n=\xff,r=abc"),
424        ] {
425            check!(
426                ScramClientFirst::parse(message) == Err(AuthError::MalformedMessage),
427                "case {case}"
428            );
429        }
430    }
431
432    /// A pre-authentication client-first can carry as many distinct
433    /// extensions as `sasl.server.max.receive.size` holds. They are all kept,
434    /// in order, and a repeat of the first one still takes its last value.
435    #[test]
436    fn client_first_rebuilds_a_large_extension_list_in_order() {
437        let keys: Vec<String> = (0..60_000_u32)
438            .map(|mut n| {
439                let mut key = String::new();
440                loop {
441                    key.push(char::from(b'a' + u8::try_from(n % 26).unwrap()));
442                    n /= 26;
443                    if n == 0 {
444                        break key;
445                    }
446                }
447            })
448            .collect();
449        let mut message = String::from("n,,n=alice,r=cn");
450        let mut expected = String::from("n=alice,r=cn");
451        for key in &keys {
452            let value = if key == "a" { "2" } else { "1" };
453            for (out, value) in [(&mut message, "1"), (&mut expected, value)] {
454                out.extend([",", key.as_str(), "=", value]);
455            }
456        }
457        message.push_str(",a=2");
458        let parsed = ScramClientFirst::parse(message.as_bytes()).unwrap();
459        check!(parsed.bare == expected);
460    }
461
462    #[test]
463    fn token_authenticated_reads_the_tokenauth_extension() {
464        for (message, want) in [
465            ("n,,n=t,r=a,tokenauth=true", true),
466            ("n,,n=t,r=a,tokenauth=TRUE", true),
467            ("n,,n=t,r=a,tokenauth=false", false),
468            ("n,,n=t,r=a", false),
469            ("n,,n=t,r=a,tokenauth=true,tokenauth=false", false),
470            ("n,,n=t,r=a,tokenauth=false,tokenauth=true", true),
471        ] {
472            let parsed = ScramClientFirst::parse(message.as_bytes()).unwrap();
473            check!(parsed.token_authenticated() == want, "message {message}");
474        }
475    }
476
477    #[test]
478    fn client_final_parses_kafkas_grammar() {
479        for (case, message, want) in [
480            (
481                "plain",
482                "c=biws,r=NS,p=AAAA",
483                ScramClientFinal {
484                    channel_binding: "biws",
485                    nonce: "NS",
486                    extensions: vec![],
487                    proof: "AAAA",
488                    without_proof: "c=biws,r=NS".into(),
489                },
490            ),
491            (
492                "padded base64",
493                "c=bixhPWFsaWNlLA==,r=N!~,p=AAA=",
494                ScramClientFinal {
495                    channel_binding: "bixhPWFsaWNlLA==",
496                    nonce: "N!~",
497                    extensions: vec![],
498                    proof: "AAA=",
499                    without_proof: "c=bixhPWFsaWNlLA==,r=N!~".into(),
500                },
501            ),
502            (
503                "extensions before the proof",
504                "c=biws,r=NS,foo=1,Bar=a=b,p=x,p=AA==",
505                ScramClientFinal {
506                    channel_binding: "biws",
507                    nonce: "NS",
508                    extensions: vec![("foo", "1"), ("Bar", "a=b"), ("p", "x")],
509                    proof: "AA==",
510                    without_proof: "c=biws,r=NS".into(),
511                },
512            ),
513            (
514                // Kafka reads a repeated `r=` or `p=` before the proof as an
515                // extension.
516                "repeated nonce and proof",
517                "c=biws,r=NS,r=NS,p=AAAA,p=AAAA",
518                ScramClientFinal {
519                    channel_binding: "biws",
520                    nonce: "NS",
521                    extensions: vec![("r", "NS"), ("p", "AAAA")],
522                    proof: "AAAA",
523                    without_proof: "c=biws,r=NS".into(),
524                },
525            ),
526            (
527                "unused bits in the channel binding",
528                "c=bixhPWFsaWNlLB==,r=NS,p=AAAA",
529                ScramClientFinal {
530                    channel_binding: "bixhPWFsaWNlLB==",
531                    nonce: "NS",
532                    extensions: vec![],
533                    proof: "AAAA",
534                    without_proof: "c=bixhPWFsaWNlLA==,r=NS".into(),
535                },
536            ),
537            (
538                "empty base64",
539                "c=,r=NS,p=",
540                ScramClientFinal {
541                    channel_binding: "",
542                    nonce: "NS",
543                    extensions: vec![],
544                    proof: "",
545                    without_proof: "c=,r=NS".into(),
546                },
547            ),
548        ] {
549            check!(
550                ScramClientFinal::parse(message.as_bytes()) == Ok(want),
551                "case {case}"
552            );
553        }
554    }
555
556    #[test]
557    fn client_final_rejects_what_kafka_rejects() {
558        for (case, message) in [
559            ("nonce first", &b"r=NS,c=biws,p=AAAA"[..]),
560            ("proof before nonce", b"c=biws,p=AAAA,r=NS"),
561            ("missing channel binding", b"r=NS,p=AAAA"),
562            ("missing nonce", b"c=biws,p=AAAA"),
563            ("missing proof", b"c=biws,r=NS"),
564            (
565                "unknown attribute after the proof",
566                b"c=biws,r=NS,p=AAAA,x=1",
567            ),
568            ("duplicate channel binding", b"c=biws,c=biws,r=NS,p=AAAA"),
569            ("extension before the nonce", b"c=biws,x=1,r=NS,p=AAAA"),
570            ("empty extension value", b"c=biws,r=NS,x=,p=AAAA"),
571            ("extension without value", b"c=biws,r=NS,x,p=AAAA"),
572            ("non-alphabetic extension key", b"c=biws,r=NS,x1=a,p=AAAA"),
573            (
574                "non-ASCII extension value",
575                "c=biws,r=NS,x=\u{e9},p=AAAA".as_bytes(),
576            ),
577            ("channel binding not base64", b"c=bi*s,r=NS,p=AAAA"),
578            ("channel binding unpadded", b"c=biw,r=NS,p=AAAA"),
579            ("proof not base64", b"c=biws,r=NS,p=AA-A"),
580            ("proof unpadded", b"c=biws,r=NS,p=AAAAA"),
581            ("proof over-padded", b"c=biws,r=NS,p=A==="),
582            ("proof padding mid-string", b"c=biws,r=NS,p=AA==AAAA"),
583            ("empty nonce", b"c=biws,r=,p=AAAA"),
584            ("nonce with a space", b"c=biws,r=N S,p=AAAA"),
585            ("nonce with a control byte", b"c=biws,r=N\x7f,p=AAAA"),
586            ("trailing comma", b"c=biws,r=NS,p=AAAA,"),
587            ("trailing newline", b"c=biws,r=NS,p=AAAA\n"),
588            ("leading space", b" c=biws,r=NS,p=AAAA"),
589            ("not UTF-8", b"c=biws,r=\xff,p=AAAA"),
590        ] {
591            check!(
592                ScramClientFinal::parse(message) == Err(AuthError::MalformedMessage),
593                "case {case}"
594            );
595        }
596    }
597
598    #[test]
599    fn saslname_encoding_round_trips() {
600        for (name, encoded) in [
601            ("alice", "alice"),
602            ("a,b", "a=2Cb"),
603            ("a=b", "a=3Db"),
604            ("=,", "=3D=2C"),
605        ] {
606            check!(
607                (encode_saslname(name), decode_saslname(encoded))
608                    == (encoded.to_owned(), Ok(name.to_owned()))
609            );
610        }
611    }
612}