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