1use 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
25pub(super) const JAVA_B64: GeneralPurpose = GeneralPurpose::new(
28 &alphabet::STANDARD,
29 GeneralPurposeConfig::new().with_decode_allow_trailing_bits(true),
30);
31
32pub const TOKEN_AUTH_EXTENSION: &str = "tokenauth";
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct ScramClientFirst {
39 pub gs2_header: String,
41 pub authzid: Option<String>,
43 pub username: String,
45 pub nonce: String,
47 pub extensions: Vec<(String, String)>,
50 pub bare: String,
63}
64
65impl ScramClientFirst {
66 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 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 #[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 #[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
163fn 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 extensions
176 .iter()
177 .filter_map(|(key, _)| last.remove(key.as_str()).map(|value| (key.as_str(), value)))
178 .collect()
179}
180
181#[derive(Debug, Clone, PartialEq, Eq)]
188pub(super) struct ScramClientFinal<'a> {
189 pub channel_binding: &'a str,
191 pub nonce: &'a str,
193 pub extensions: Vec<(&'a str, &'a str)>,
196 pub proof: &'a str,
198 pub without_proof: String,
205}
206
207impl<'a> ScramClientFinal<'a> {
208 pub(super) fn parse(bytes: &'a [u8]) -> Result<Self, AuthError> {
214 let message = from_utf8(bytes).map_err(|_| AuthError::MalformedMessage)?;
215 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
254fn 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#[must_use]
270pub fn encode_saslname(username: &str) -> String {
271 username.replace('=', "=3D").replace(',', "=2C")
272}
273
274pub 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
301pub(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 #[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 "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}