1use alloc::string::{String, ToString};
8use alloc::vec::Vec;
9use base64::Engine as _;
10use base64::engine::general_purpose::STANDARD as BASE64;
11
12const CHUNK: usize = 400;
14
15#[derive(Clone, PartialEq, Eq)]
17#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
18#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
19#[cfg_attr(feature = "ts", ts(rename = "SaslCredentials"))]
20#[cfg_attr(feature = "serde", serde(tag = "mechanism", rename_all = "snake_case"))]
21#[non_exhaustive]
22pub enum Credentials {
23 Plain {
25 username: String,
27 password: String,
29 },
30 External,
32 Scram {
38 username: String,
40 password: String,
42 nonce: String,
45 },
46}
47
48impl core::fmt::Debug for Credentials {
49 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
52 match self {
53 Self::Plain { username, .. } => f
54 .debug_struct("Plain")
55 .field("username", username)
56 .field("password", &"<redacted>")
57 .finish(),
58 Self::External => f.write_str("External"),
59 Self::Scram { username, .. } => f
60 .debug_struct("Scram")
61 .field("username", username)
62 .field("password", &"<redacted>")
63 .finish_non_exhaustive(),
64 }
65 }
66}
67
68impl Credentials {
69 pub fn mechanism(&self) -> &'static str {
71 match self {
72 Self::Plain { .. } => "PLAIN",
73 Self::External => "EXTERNAL",
74 Self::Scram { .. } => "SCRAM-SHA-256",
75 }
76 }
77
78 pub fn response(&self) -> Vec<u8> {
83 match self {
84 Self::Plain { username, password } => {
85 let mut out = Vec::new();
86 out.push(0);
87 out.extend_from_slice(username.as_bytes());
88 out.push(0);
89 out.extend_from_slice(password.as_bytes());
90 out
91 }
92 Self::External | Self::Scram { .. } => Vec::new(),
93 }
94 }
95
96 pub fn is_challenge_response(&self) -> bool {
98 matches!(self, Self::Scram { .. })
99 }
100}
101
102pub(crate) fn encode_response(payload: &[u8]) -> Vec<String> {
108 if payload.is_empty() {
109 return alloc::vec!["+".to_string()];
110 }
111 let encoded = BASE64.encode(payload);
112 let mut lines: Vec<String> = encoded
113 .as_bytes()
114 .chunks(CHUNK)
115 .map(|chunk| String::from_utf8_lossy(chunk).into_owned())
116 .collect();
117 if encoded.len().is_multiple_of(CHUNK) {
118 lines.push("+".to_string());
119 }
120 lines
121}
122
123pub(crate) fn decode_challenge(payload: &str) -> Option<Vec<u8>> {
125 if payload == "+" {
126 return Some(Vec::new());
127 }
128 BASE64.decode(payload).ok()
129}
130
131pub(crate) fn offers(advertised: Option<&str>, mechanism: &str) -> bool {
135 match advertised {
136 None | Some("") => true,
137 Some(list) => list.split(',').any(|m| m.eq_ignore_ascii_case(mechanism)),
138 }
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
144#[cfg_attr(feature = "ts", derive(ts_rs::TS), ts(export, export_to = "obby.ts"))]
145#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
146#[non_exhaustive]
147pub enum SaslFailure {
148 Rejected,
150 TooLong,
152 Aborted,
154 AlreadyAuthenticated,
156 NoSharedMechanism,
158 ServerNotVerified,
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
164pub(crate) enum SaslState {
165 #[default]
167 Idle,
168 Offered,
170 Responded,
172 ScramChallenged,
174 ScramProved,
176 Settled,
178}
179
180impl SaslState {
181 pub(crate) fn in_flight(self) -> bool {
183 matches!(
184 self,
185 Self::Offered | Self::Responded | Self::ScramChallenged | Self::ScramProved
186 )
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn plain_frames_the_response_with_null_separators() {
196 let creds = Credentials::Plain {
197 username: "alice".to_string(),
198 password: "hunter2".to_string(),
199 };
200 assert_eq!(creds.mechanism(), "PLAIN");
201 assert_eq!(creds.response(), b"\0alice\0hunter2");
202 }
203
204 #[test]
205 fn external_sends_an_empty_response() {
206 assert_eq!(Credentials::External.mechanism(), "EXTERNAL");
207 assert_eq!(encode_response(&Credentials::External.response()), ["+"]);
208 }
209
210 #[test]
211 fn a_short_response_is_one_line() {
212 let lines = encode_response(b"\0alice\0hunter2");
213 assert_eq!(lines.len(), 1);
214 assert_eq!(
215 decode_challenge(&lines[0]).as_deref(),
216 Some(&b"\0alice\0hunter2"[..])
217 );
218 }
219
220 #[test]
221 fn a_long_response_splits_at_four_hundred() {
222 let lines = encode_response(&[b'x'; 301]);
224 assert_eq!(lines.len(), 2);
225 assert_eq!(lines[0].len(), CHUNK);
226 assert!(lines[1].len() < CHUNK);
227 }
228
229 #[test]
230 fn an_exact_multiple_gets_a_trailing_plus() {
231 let lines = encode_response(&[b'x'; 300]);
233 assert_eq!(lines.len(), 2);
234 assert_eq!(lines[0].len(), CHUNK);
235 assert_eq!(lines[1], "+");
236 }
237
238 #[test]
239 fn the_chunks_rejoin_into_the_original() {
240 let payload: Vec<u8> = (0..1000u32).map(|i| (i % 251) as u8).collect();
241 let joined: String = encode_response(&payload)
242 .into_iter()
243 .filter(|line| line != "+")
244 .collect();
245 assert_eq!(decode_challenge(&joined).as_deref(), Some(&payload[..]));
246 }
247
248 #[test]
249 fn an_empty_challenge_is_a_plus() {
250 assert_eq!(decode_challenge("+"), Some(Vec::new()));
251 assert_eq!(decode_challenge("not base64!"), None);
252 }
253
254 #[test]
255 fn an_unadvertised_mechanism_list_is_not_a_refusal() {
256 assert!(
257 offers(None, "PLAIN"),
258 "no list means the server did not say"
259 );
260 assert!(offers(Some(""), "PLAIN"));
261 assert!(offers(Some("PLAIN,EXTERNAL"), "EXTERNAL"));
262 assert!(
263 offers(Some("plain"), "PLAIN"),
264 "mechanism names are case-insensitive"
265 );
266 assert!(!offers(Some("SCRAM-SHA-256"), "PLAIN"));
267 }
268
269 #[test]
270 fn cap_end_waits_only_while_the_exchange_runs() {
271 assert!(!SaslState::Idle.in_flight());
272 assert!(SaslState::Offered.in_flight());
273 assert!(SaslState::Responded.in_flight());
274 assert!(
275 SaslState::ScramChallenged.in_flight() && SaslState::ScramProved.in_flight(),
276 "a challenge-response mechanism takes several rounds, and CAP END must wait for all of them"
277 );
278 assert!(!SaslState::Settled.in_flight());
279 }
280
281 #[test]
282 fn a_password_never_appears_in_a_debug_rendering() {
283 let plain = Credentials::Plain {
284 username: "alice".to_string(),
285 password: "hunter2".to_string(),
286 };
287 let rendered = alloc::format!("{plain:?}");
288 assert!(!rendered.contains("hunter2"), "got: {rendered}");
289 assert!(rendered.contains("alice"), "the username is not the secret");
290
291 let scram = Credentials::Scram {
292 username: "alice".to_string(),
293 password: "hunter2".to_string(),
294 nonce: "abc".to_string(),
295 };
296 assert!(!alloc::format!("{scram:?}").contains("hunter2"));
297 }
298
299 #[test]
300 fn scram_is_a_challenge_response_mechanism_and_the_others_are_not() {
301 let scram = Credentials::Scram {
302 username: "alice".to_string(),
303 password: "hunter2".to_string(),
304 nonce: "abc".to_string(),
305 };
306 assert_eq!(scram.mechanism(), "SCRAM-SHA-256");
307 assert!(scram.is_challenge_response());
308 assert!(!Credentials::External.is_challenge_response());
309 }
310}