Skip to main content

sccp_protocol/phone/
authentication.rs

1//! Typed, secret-safe values for the phone HTTP authentication exchange.
2//!
3//! This exchange is form-encoded HTTP with a plain-text decision token. It is
4//! intentionally separate from the phone XML models because neither the
5//! request nor the response is an XML document.
6
7use std::fmt;
8use std::io::Write;
9
10use percent_encoding::percent_decode_str;
11use thiserror::Error;
12
13use crate::types::DeviceId;
14
15/// Maximum encoded size accepted by [`PhoneAuthenticationRequest::parse_query`].
16pub const PHONE_AUTHENTICATION_MAX_QUERY_BYTES: usize = 1_024;
17/// Maximum UTF-8 byte length of an authentication user identifier.
18pub const PHONE_AUTHENTICATION_MAX_USER_ID_BYTES: usize = 128;
19/// Maximum UTF-8 byte length of an authentication password.
20pub const PHONE_AUTHENTICATION_MAX_PASSWORD_BYTES: usize = 256;
21/// Maximum response size retained or emitted by this module.
22pub 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            /// Validates and wraps a credential without exposing it through diagnostics.
35            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            /// Exposes the credential only to an authentication policy implementation.
42            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    /// User identifier forwarded by the phone to its authentication service.
65    PhoneAuthenticationUserId,
66    "authentication user identifier",
67    PHONE_AUTHENTICATION_MAX_USER_ID_BYTES
68);
69
70redacted_authentication_value!(
71    /// Password forwarded by the phone to its authentication service.
72    PhoneAuthenticationPassword,
73    "authentication password",
74    PHONE_AUTHENTICATION_MAX_PASSWORD_BYTES
75);
76
77/// The three fields supplied to the configured phone authentication URL.
78///
79/// Debug output redacts the user ID and password while retaining the device ID
80/// for session diagnostics.
81#[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    /// Parses an `application/x-www-form-urlencoded` query with exact field
90    /// names `UserID`, `Password`, and `devicename`.
91    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    /// Validates fields already decoded by a standards-based HTTP boundary.
105    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/// A bounded unsupported authentication response retained without inspection.
159#[derive(Clone, Eq, PartialEq)]
160pub struct OpaquePhoneAuthenticationResponse(Vec<u8>);
161
162impl OpaquePhoneAuthenticationResponse {
163    /// Retains an unrecognized response after enforcing the response byte limit.
164    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/// Plain-text decision returned by a phone authentication endpoint.
184#[derive(Clone, Eq, PartialEq)]
185pub enum PhoneAuthenticationResponse {
186    Authorized,
187    Unauthorized,
188    /// A bounded response token not recognized by this version of the crate.
189    Opaque(OpaquePhoneAuthenticationResponse),
190}
191
192impl PhoneAuthenticationResponse {
193    /// Parses a bounded decision token, preserving unrecognized bytes exactly.
194    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    /// Borrows the canonical decision token or the preserved opaque response.
205    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    /// Writes the serialized response without logging or formatting credentials.
218    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/// Validation and I/O failures at the authentication HTTP boundary.
236#[derive(Clone, Copy, Debug, Error, Eq, PartialEq)]
237pub enum PhoneAuthenticationError {
238    /// The encoded query is larger than [`PHONE_AUTHENTICATION_MAX_QUERY_BYTES`].
239    #[error("phone authentication query exceeds its byte limit")]
240    QueryExceedsLimit,
241    /// The query is not a canonical, control-free encoded form.
242    #[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    /// A credential violates its byte bound or contains a control character.
251    #[error("phone authentication credential {field} exceeds its bound or contains controls")]
252    InvalidCredential { field: &'static str },
253    /// The device name is not a valid [`DeviceId`].
254    #[error("phone authentication device name is invalid")]
255    InvalidDeviceName,
256    /// The response exceeds [`PHONE_AUTHENTICATION_MAX_RESPONSE_BYTES`].
257    #[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}