1use std::fmt;
8use std::io::Write;
9
10use percent_encoding::percent_decode_str;
11use thiserror::Error;
12
13use crate::types::DeviceId;
14
15pub const PHONE_AUTHENTICATION_MAX_QUERY_BYTES: usize = 1_024;
17pub const PHONE_AUTHENTICATION_MAX_USER_ID_BYTES: usize = 128;
19pub const PHONE_AUTHENTICATION_MAX_PASSWORD_BYTES: usize = 256;
21pub const PHONE_AUTHENTICATION_MAX_RESPONSE_BYTES: usize = 256;
23
24const AUTHORIZED: &[u8] = b"AUTHORIZED";
25const UNAUTHORIZED: &[u8] = b"UN-AUTHORIZED";
26
27macro_rules! redacted_authentication_value {
28 ($(#[$meta:meta])* $name:ident, $kind:literal, $maximum:expr) => {
29 $(#[$meta])*
30 #[derive(Clone, Eq, Hash, PartialEq)]
31 pub struct $name(String);
32
33 impl $name {
34 pub fn new(value: impl Into<String>) -> Result<Self, PhoneAuthenticationError> {
36 let value = value.into();
37 validate_credential($kind, &value, $maximum)?;
38 Ok(Self(value))
39 }
40
41 pub fn expose_secret(&self) -> &str {
43 &self.0
44 }
45 }
46
47 impl fmt::Debug for $name {
48 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49 formatter.write_str(concat!(stringify!($name), "(<redacted>)"))
50 }
51 }
52
53 impl TryFrom<String> for $name {
54 type Error = PhoneAuthenticationError;
55
56 fn try_from(value: String) -> Result<Self, Self::Error> {
57 Self::new(value)
58 }
59 }
60 };
61}
62
63redacted_authentication_value!(
64 PhoneAuthenticationUserId,
66 "authentication user identifier",
67 PHONE_AUTHENTICATION_MAX_USER_ID_BYTES
68);
69
70redacted_authentication_value!(
71 PhoneAuthenticationPassword,
73 "authentication password",
74 PHONE_AUTHENTICATION_MAX_PASSWORD_BYTES
75);
76
77#[derive(Clone, Eq, PartialEq)]
82pub struct PhoneAuthenticationRequest {
83 pub user_id: PhoneAuthenticationUserId,
84 pub password: PhoneAuthenticationPassword,
85 pub device_id: DeviceId,
86}
87
88impl PhoneAuthenticationRequest {
89 pub fn parse_query(query: &[u8]) -> Result<Self, PhoneAuthenticationError> {
92 if query.len() > PHONE_AUTHENTICATION_MAX_QUERY_BYTES {
93 return Err(PhoneAuthenticationError::QueryExceedsLimit);
94 }
95 let query =
96 std::str::from_utf8(query).map_err(|_| PhoneAuthenticationError::InvalidEncoding)?;
97 validate_encoded_form(query)?;
98 Self::from_fields(
99 form_urlencoded::parse(query.as_bytes())
100 .map(|(name, value)| (name.into_owned(), value.into_owned())),
101 )
102 }
103
104 pub fn from_fields<I, N, V>(fields: I) -> Result<Self, PhoneAuthenticationError>
106 where
107 I: IntoIterator<Item = (N, V)>,
108 N: AsRef<str>,
109 V: AsRef<str>,
110 {
111 let mut user_id = None;
112 let mut password = None;
113 let mut device_id = None;
114 for (name, value) in fields {
115 let name = name.as_ref();
116 let value = value.as_ref();
117 match name {
118 "UserID" => set_once(
119 &mut user_id,
120 "UserID",
121 PhoneAuthenticationUserId::new(value)?,
122 )?,
123 "Password" => set_once(
124 &mut password,
125 "Password",
126 PhoneAuthenticationPassword::new(value)?,
127 )?,
128 "devicename" => {
129 if value.trim() != value || value.chars().any(char::is_control) {
130 return Err(PhoneAuthenticationError::InvalidDeviceName);
131 }
132 let parsed = DeviceId::new(value)
133 .map_err(|_| PhoneAuthenticationError::InvalidDeviceName)?;
134 set_once(&mut device_id, "devicename", parsed)?;
135 }
136 _ => return Err(PhoneAuthenticationError::UnknownField),
137 }
138 }
139 Ok(Self {
140 user_id: user_id.ok_or(PhoneAuthenticationError::MissingField("UserID"))?,
141 password: password.ok_or(PhoneAuthenticationError::MissingField("Password"))?,
142 device_id: device_id.ok_or(PhoneAuthenticationError::MissingField("devicename"))?,
143 })
144 }
145}
146
147impl fmt::Debug for PhoneAuthenticationRequest {
148 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149 formatter
150 .debug_struct("PhoneAuthenticationRequest")
151 .field("user_id", &"<redacted>")
152 .field("password", &"<redacted>")
153 .field("device_id", &self.device_id)
154 .finish()
155 }
156}
157
158#[derive(Clone, Eq, PartialEq)]
160pub struct OpaquePhoneAuthenticationResponse(Vec<u8>);
161
162impl OpaquePhoneAuthenticationResponse {
163 pub fn new(value: Vec<u8>) -> Result<Self, PhoneAuthenticationError> {
165 validate_response_size(value.len())?;
166 Ok(Self(value))
167 }
168
169 pub fn as_bytes(&self) -> &[u8] {
170 &self.0
171 }
172}
173
174impl fmt::Debug for OpaquePhoneAuthenticationResponse {
175 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
176 formatter
177 .debug_struct("OpaquePhoneAuthenticationResponse")
178 .field("bytes", &self.0.len())
179 .finish()
180 }
181}
182
183#[derive(Clone, Eq, PartialEq)]
185pub enum PhoneAuthenticationResponse {
186 Authorized,
187 Unauthorized,
188 Opaque(OpaquePhoneAuthenticationResponse),
190}
191
192impl PhoneAuthenticationResponse {
193 pub fn from_bytes(value: &[u8]) -> Result<Self, PhoneAuthenticationError> {
195 validate_response_size(value.len())?;
196 let trimmed = value.trim_ascii();
197 Ok(match trimmed {
198 AUTHORIZED => Self::Authorized,
199 UNAUTHORIZED => Self::Unauthorized,
200 _ => Self::Opaque(OpaquePhoneAuthenticationResponse(value.to_vec())),
201 })
202 }
203
204 pub fn as_bytes(&self) -> &[u8] {
206 match self {
207 Self::Authorized => AUTHORIZED,
208 Self::Unauthorized => UNAUTHORIZED,
209 Self::Opaque(value) => value.as_bytes(),
210 }
211 }
212
213 pub fn to_bytes(&self) -> Vec<u8> {
214 self.as_bytes().to_vec()
215 }
216
217 pub fn write_to(&self, mut writer: impl Write) -> Result<(), PhoneAuthenticationError> {
219 writer
220 .write_all(self.as_bytes())
221 .map_err(|_| PhoneAuthenticationError::Write)
222 }
223}
224
225impl fmt::Debug for PhoneAuthenticationResponse {
226 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
227 match self {
228 Self::Authorized => formatter.write_str("Authorized"),
229 Self::Unauthorized => formatter.write_str("Unauthorized"),
230 Self::Opaque(value) => formatter.debug_tuple("Opaque").field(value).finish(),
231 }
232 }
233}
234
235#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
237pub enum PhoneAuthenticationError {
238 #[error("phone authentication query exceeds its byte limit")]
240 QueryExceedsLimit,
241 #[error("phone authentication form is not valid UTF-8 or percent encoding")]
243 InvalidEncoding,
244 #[error("phone authentication form contains an unknown field")]
245 UnknownField,
246 #[error("phone authentication form repeats field {0}")]
247 DuplicateField(&'static str),
248 #[error("phone authentication form is missing field {0}")]
249 MissingField(&'static str),
250 #[error("phone authentication credential {field} exceeds its bound or contains controls")]
252 InvalidCredential { field: &'static str },
253 #[error("phone authentication device name is invalid")]
255 InvalidDeviceName,
256 #[error("phone authentication response exceeds its byte limit")]
258 ResponseExceedsLimit,
259 #[error("unable to write phone authentication response")]
260 Write,
261}
262
263fn validate_credential(
264 field: &'static str,
265 value: &str,
266 maximum: usize,
267) -> Result<(), PhoneAuthenticationError> {
268 if value.len() > maximum || value.chars().any(char::is_control) {
269 return Err(PhoneAuthenticationError::InvalidCredential { field });
270 }
271 Ok(())
272}
273
274fn validate_response_size(actual: usize) -> Result<(), PhoneAuthenticationError> {
275 if actual > PHONE_AUTHENTICATION_MAX_RESPONSE_BYTES {
276 return Err(PhoneAuthenticationError::ResponseExceedsLimit);
277 }
278 Ok(())
279}
280
281fn set_once<T>(
282 target: &mut Option<T>,
283 field: &'static str,
284 value: T,
285) -> Result<(), PhoneAuthenticationError> {
286 if target.replace(value).is_some() {
287 return Err(PhoneAuthenticationError::DuplicateField(field));
288 }
289 Ok(())
290}
291
292fn validate_encoded_form(query: &str) -> Result<(), PhoneAuthenticationError> {
293 if query.is_empty() {
294 return Ok(());
295 }
296 for field in query.split('&') {
297 if field.is_empty() {
298 return Err(PhoneAuthenticationError::InvalidEncoding);
299 }
300 let (name, value) = field.split_once('=').unwrap_or((field, ""));
301 validate_percent_triplets(name)?;
302 validate_percent_triplets(value)?;
303 for component in [name, value] {
304 let decoded = percent_decode_str(component)
305 .decode_utf8()
306 .map_err(|_| PhoneAuthenticationError::InvalidEncoding)?;
307 if decoded.chars().any(char::is_control) {
308 return Err(PhoneAuthenticationError::InvalidEncoding);
309 }
310 }
311 }
312 Ok(())
313}
314
315fn validate_percent_triplets(value: &str) -> Result<(), PhoneAuthenticationError> {
316 let bytes = value.as_bytes();
317 let mut index = 0;
318 while index < bytes.len() {
319 if bytes[index] == b'%' {
320 let Some(pair) = bytes.get(index + 1..index + 3) else {
321 return Err(PhoneAuthenticationError::InvalidEncoding);
322 };
323 if !pair.iter().all(u8::is_ascii_hexdigit) {
324 return Err(PhoneAuthenticationError::InvalidEncoding);
325 }
326 index += 3;
327 } else {
328 index += 1;
329 }
330 }
331 Ok(())
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337 use std::io;
338
339 #[test]
340 fn request_decodes_exact_form_fields_and_redacts_credentials() {
341 let request = PhoneAuthenticationRequest::parse_query(
342 b"UserID=alex%40example.test&Password=p%40ss+word%26more&devicename=sep001122334455",
343 )
344 .unwrap();
345 assert_eq!(request.user_id.expose_secret(), "alex@example.test");
346 assert_eq!(request.password.expose_secret(), "p@ss word&more");
347 assert_eq!(request.device_id.as_str(), "SEP001122334455");
348
349 let debug = format!("{request:?}");
350 assert!(!debug.contains("alex"));
351 assert!(!debug.contains("p@ss"));
352 assert!(debug.contains("<redacted>"));
353 assert_eq!(
354 format!("{:?}", request.user_id),
355 "PhoneAuthenticationUserId(<redacted>)"
356 );
357 assert_eq!(
358 format!("{:?}", request.password),
359 "PhoneAuthenticationPassword(<redacted>)"
360 );
361 }
362
363 #[test]
364 fn request_requires_exact_unique_fields_and_secret_safe_bounds() {
365 for query in [
366 "UserId=private-user&Password=private-pass&devicename=SEP001122334455",
367 "UserID=private-user&password=private-pass&devicename=SEP001122334455",
368 "UserID=private-user&Password=private-pass&DeviceName=SEP001122334455",
369 "UserID=private-user&Password=private-pass",
370 "UserID=private-user&UserID=other&Password=private-pass&devicename=SEP001122334455",
371 "UserID=private%Q0user&Password=private-pass&devicename=SEP001122334455",
372 "UserID=private%0Auser&Password=private-pass&devicename=SEP001122334455",
373 "UserID=private-user&Password=private-pass&devicename=../../secret",
374 ] {
375 let error = PhoneAuthenticationRequest::parse_query(query.as_bytes()).unwrap_err();
376 let text = error.to_string();
377 assert!(!text.contains("private-user"), "{text}");
378 assert!(!text.contains("private-pass"), "{text}");
379 }
380
381 let oversized = format!(
382 "UserID={}&Password=secret&devicename=SEP001122334455",
383 "u".repeat(PHONE_AUTHENTICATION_MAX_USER_ID_BYTES + 1)
384 );
385 let error = PhoneAuthenticationRequest::parse_query(oversized.as_bytes()).unwrap_err();
386 assert!(!error.to_string().contains(&"u".repeat(32)));
387 let oversized = format!(
388 "UserID=user&Password={}&devicename=SEP001122334455",
389 "p".repeat(PHONE_AUTHENTICATION_MAX_PASSWORD_BYTES + 1)
390 );
391 let error = PhoneAuthenticationRequest::parse_query(oversized.as_bytes()).unwrap_err();
392 assert!(!error.to_string().contains(&"p".repeat(32)));
393 assert!(matches!(
394 PhoneAuthenticationRequest::parse_query(&vec![
395 b'x';
396 PHONE_AUTHENTICATION_MAX_QUERY_BYTES + 1
397 ]),
398 Err(PhoneAuthenticationError::QueryExceedsLimit)
399 ));
400 assert!(matches!(
401 PhoneAuthenticationRequest::parse_query(&[0xff]),
402 Err(PhoneAuthenticationError::InvalidEncoding)
403 ));
404 }
405
406 #[test]
407 fn empty_credentials_are_typed_for_policy_driven_denial() {
408 let request = PhoneAuthenticationRequest::parse_query(
409 b"UserID=&Password=&devicename=SEP001122334455",
410 )
411 .unwrap();
412 assert!(request.user_id.expose_secret().is_empty());
413 assert!(request.password.expose_secret().is_empty());
414 }
415
416 #[test]
417 fn response_round_trips_exact_tokens_and_preserves_unknown_bodies_opaquely() {
418 for expected in [
419 PhoneAuthenticationResponse::Authorized,
420 PhoneAuthenticationResponse::Unauthorized,
421 ] {
422 assert_eq!(
423 PhoneAuthenticationResponse::from_bytes(expected.as_bytes()).unwrap(),
424 expected
425 );
426 }
427 assert_eq!(
428 PhoneAuthenticationResponse::from_bytes(b"AUTHORIZED\r\n").unwrap(),
429 PhoneAuthenticationResponse::Authorized
430 );
431
432 for unknown in [
433 b"MAYBE".as_slice(),
434 b"<!DOCTYPE auth [<!ENTITY secret 'private'>]><auth>&secret;</auth>".as_slice(),
435 b"<auth><nested><result>AUTHORIZED</result></nested></auth>".as_slice(),
436 b"<auth><".as_slice(),
437 &[0xff],
438 ] {
439 let response = PhoneAuthenticationResponse::from_bytes(unknown).unwrap();
440 let PhoneAuthenticationResponse::Opaque(value) = response else {
441 panic!("unknown authentication body must remain opaque");
442 };
443 assert_eq!(value.as_bytes(), unknown);
444 let debug = format!("{value:?}");
445 assert!(debug.contains(&unknown.len().to_string()));
446 assert!(!debug.contains("private"));
447 assert!(!debug.contains("AUTHORIZED"));
448 }
449 let nested = format!("<auth>{}{}</auth>", "<n>".repeat(33), "</n>".repeat(33));
450 assert!(nested.len() <= PHONE_AUTHENTICATION_MAX_RESPONSE_BYTES);
451 assert!(matches!(
452 PhoneAuthenticationResponse::from_bytes(nested.as_bytes()).unwrap(),
453 PhoneAuthenticationResponse::Opaque(_)
454 ));
455 assert!(matches!(
456 PhoneAuthenticationResponse::from_bytes(&vec![
457 b'x';
458 PHONE_AUTHENTICATION_MAX_RESPONSE_BYTES
459 + 1
460 ]),
461 Err(PhoneAuthenticationError::ResponseExceedsLimit)
462 ));
463 }
464
465 #[test]
466 fn response_writer_propagates_failures_without_body_data() {
467 #[derive(Debug)]
468 struct FailingWriter;
469 impl Write for FailingWriter {
470 fn write(&mut self, _buffer: &[u8]) -> io::Result<usize> {
471 Err(io::Error::other("sensitive downstream context"))
472 }
473
474 fn flush(&mut self) -> io::Result<()> {
475 Ok(())
476 }
477 }
478
479 let mut body = Vec::new();
480 PhoneAuthenticationResponse::Authorized
481 .write_to(&mut body)
482 .unwrap();
483 assert_eq!(body, AUTHORIZED);
484 let error = PhoneAuthenticationResponse::Unauthorized
485 .write_to(FailingWriter)
486 .unwrap_err();
487 assert_eq!(error, PhoneAuthenticationError::Write);
488 assert!(!error.to_string().contains("sensitive"));
489 }
490}