Skip to main content

rama_net/user/credentials/
basic.rs

1use core::{fmt, str::FromStr};
2
3use crate::user::authority::StaticAuthorizer;
4
5use rama_core::error::BoxErrorExt as _;
6use rama_core::error::{BoxError, ErrorContext as _, ErrorExt};
7use rama_core::extensions::Extension;
8use rama_utils::bytes::ct::ct_eq_bytes;
9use rama_utils::str::NonEmptyStr;
10
11#[derive(Clone, Eq, Extension)]
12#[extension(tags(net))]
13/// Basic credentials.
14pub struct Basic {
15    username: NonEmptyStr,
16    password: Option<NonEmptyStr>,
17}
18
19impl PartialEq for Basic {
20    /// Constant-time comparison over the credential bytes.
21    //
22    // Why: `Basic` is used by `StaticAuthorizer`, so a short-circuiting
23    // byte compare leaks the matching prefix length to an attacker who
24    // can probe authentication latency. Verified by
25    // `tests::regression_basic_constant_time_eq`.
26    fn eq(&self, other: &Self) -> bool {
27        let user_eq = ct_eq_bytes(self.username.as_bytes(), other.username.as_bytes());
28        let pwd_eq = match (&self.password, &other.password) {
29            (Some(a), Some(b)) => ct_eq_bytes(a.as_bytes(), b.as_bytes()),
30            (None, None) => true,
31            // The "one side has a password and the other does not" case still
32            // performs a fixed-cost compare so we don't reveal which side it is.
33            (Some(a), None) | (None, Some(a)) => {
34                let _ = ct_eq_bytes(a.as_bytes(), a.as_bytes());
35                false
36            }
37        };
38        user_eq & pwd_eq
39    }
40}
41
42/// Create a [`Basic`] value at const-compile time.
43///
44/// # Panics
45///
46/// Panics in case the username literal is empty.
47#[macro_export]
48#[doc(hidden)]
49macro_rules! __basic {
50    ($username:expr $(,)?) => {
51        $crate::user::credentials::basic!($username, "")
52    };
53    ($username:expr, $password:expr $(,)?) => {{
54        const __BASIC_USERNAME_VALUE: $crate::__private::utils::str::NonEmptyStr =
55            $crate::__private::utils::str::non_empty_str!($username);
56        const __BASIC_PASSWORD_TEXT: &str = $password;
57
58        if __BASIC_PASSWORD_TEXT.is_empty() {
59            $crate::user::credentials::Basic::new_insecure(__BASIC_USERNAME_VALUE)
60        } else {
61            $crate::user::credentials::Basic::new(
62                __BASIC_USERNAME_VALUE,
63                $crate::__private::utils::str::non_empty_str!(__BASIC_PASSWORD_TEXT),
64            )
65        }
66    }};
67}
68
69#[doc(inline)]
70pub use crate::__basic as basic;
71
72impl fmt::Debug for Basic {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        f.debug_struct("Basic")
75            .field("username", &self.username)
76            .field("password", &"***")
77            .finish()
78    }
79}
80
81impl Basic {
82    /// Creates a new [`Basic`] credential.
83    #[must_use]
84    pub const fn new(username: NonEmptyStr, password: NonEmptyStr) -> Self {
85        Self {
86            username,
87            password: Some(password),
88        }
89    }
90
91    #[must_use]
92    pub fn clone_with_new_username(&self, username: NonEmptyStr) -> Self {
93        Self {
94            username,
95            password: self.password.clone(),
96        }
97    }
98
99    #[must_use]
100    pub fn clone_with_new_password(&self, password: NonEmptyStr) -> Self {
101        Self {
102            username: self.username.clone(),
103            password: Some(password),
104        }
105    }
106
107    /// Creates a new [`Basic`] credential.
108    #[must_use]
109    pub const fn new_insecure(username: NonEmptyStr) -> Self {
110        Self {
111            username,
112            password: None,
113        }
114    }
115
116    /// View the decoded username.
117    #[must_use]
118    pub fn username(&self) -> &str {
119        self.username.as_ref()
120    }
121
122    rama_utils::macros::generate_set_and_with! {
123        /// Set or overwrite the username with the given value.
124        pub fn username(mut self, username: NonEmptyStr) -> Self {
125            self.username = username;
126            self
127        }
128    }
129
130    /// View the decoded password.
131    ///
132    /// If Some(str) is returned it is guaranteed to be non-empty.
133    #[must_use]
134    pub fn password(&self) -> Option<&str> {
135        self.password.as_deref()
136    }
137
138    rama_utils::macros::generate_set_and_with! {
139        /// Set or overwrite the password with the given value.
140        pub fn password(mut self, password: NonEmptyStr) -> Self {
141            self.password = Some(password);
142            self
143        }
144    }
145
146    /// Turn itself into a [`StaticAuthorizer`], so it can be used to authorize.
147    ///
148    /// Just a shortcut, QoL.
149    #[must_use]
150    pub fn into_authorizer(self) -> StaticAuthorizer<Self> {
151        StaticAuthorizer::new(self)
152    }
153}
154
155impl core::hash::Hash for Basic {
156    fn hash<H: core::hash::Hasher>(&self, state: &mut H) {
157        self.username().hash(state);
158        ':'.hash(state);
159        self.password().hash(state);
160    }
161}
162
163/// Reject control bytes that have no legitimate place inside HTTP Basic
164/// credentials.
165//
166// Why: an unvalidated CR / LF or NUL byte that round-trips back into an
167// Authorization header lets an attacker inject extra header fields (CRLF
168// injection) or terminate a C-string prematurely in downstream handlers.
169// RFC 7617 (HTTP Basic) does not enumerate these as forbidden, but no
170// real-world deployment relies on them either, so we reject by default
171// for every entry point that constructs a [`Basic`] from external input.
172//
173// Regression: `tests::regression_basic_rejects_crlf_nul`.
174fn validate_basic_field(field: &str, value: &str) -> Result<(), BoxError> {
175    if let Some(idx) = value
176        .as_bytes()
177        .iter()
178        .position(|b| matches!(*b, b'\r' | b'\n' | 0))
179    {
180        return Err(
181            BoxError::from_static_str("basic credential contains forbidden control byte")
182                .context_str_field("field", field)
183                .context_field("byte_index", idx),
184        );
185    }
186    Ok(())
187}
188
189impl TryFrom<&str> for Basic {
190    type Error = BoxError;
191
192    fn try_from(value: &str) -> Result<Self, Self::Error> {
193        validate_basic_field("credential blob", value)?;
194        match value.find(':') {
195            Some(0) => Err(BoxError::from_static_str(
196                "missing username in basic credential",
197            )),
198            Some(n) => Ok(Self {
199                username: NonEmptyStr::try_from(&value[..n])
200                    .context("create username for secure basic credentials")?,
201                password: (n + 1 < value.len())
202                    .then(|| {
203                        NonEmptyStr::try_from(&value[n + 1..])
204                            .context("create password for secure basic credentials")
205                    })
206                    .transpose()?,
207            }),
208            None => Ok(Self {
209                username: NonEmptyStr::try_from(value)
210                    .context("create username for insecure basic credentials")?,
211                password: None,
212            }),
213        }
214    }
215}
216
217impl FromStr for Basic {
218    type Err = BoxError;
219
220    #[inline]
221    fn from_str(s: &str) -> Result<Self, Self::Err> {
222        s.try_into()
223    }
224}
225
226impl fmt::Display for Basic {
227    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
228        write!(
229            f,
230            "{}:{}",
231            self.username(),
232            self.password().unwrap_or_default()
233        )
234    }
235}
236
237#[cfg(test)]
238mod tests {
239    use super::*;
240
241    fn basic(user: &str, pwd: Option<&str>) -> Basic {
242        let username = NonEmptyStr::try_from(user).unwrap();
243        match pwd {
244            Some(p) => Basic::new(username, NonEmptyStr::try_from(p).unwrap()),
245            None => Basic::new_insecure(username),
246        }
247    }
248
249    #[test]
250    fn regression_basic_constant_time_eq() {
251        // Equal credentials match.
252        assert_eq!(
253            basic("alice", Some("hunter2")),
254            basic("alice", Some("hunter2"))
255        );
256        // Differing only in the last byte of the password.
257        assert_ne!(
258            basic("alice", Some("hunter2")),
259            basic("alice", Some("hunter3"))
260        );
261        // Differing only in the first byte of the password.
262        assert_ne!(
263            basic("alice", Some("hunter2")),
264            basic("alice", Some("xunter2"))
265        );
266        // One side has a password, the other does not.
267        assert_ne!(basic("alice", Some("hunter2")), basic("alice", None));
268        assert_ne!(basic("alice", None), basic("alice", Some("hunter2")));
269        // Different usernames, same password.
270        assert_ne!(
271            basic("alice", Some("hunter2")),
272            basic("bob", Some("hunter2"))
273        );
274        // Insecure pair compares.
275        assert_eq!(basic("alice", None), basic("alice", None));
276        assert_ne!(basic("alice", None), basic("bob", None));
277    }
278
279    #[test]
280    fn regression_basic_rejects_crlf_nul() {
281        // CRLF in the username section enables Authorization-header CRLF
282        // injection if the value round-trips back into a header. RFC 7617
283        // does not enumerate this; we reject by default.
284        Basic::try_from("ali\rce:hunter2").unwrap_err();
285        Basic::try_from("ali\nce:hunter2").unwrap_err();
286        // Password section.
287        Basic::try_from("alice:hun\rter2").unwrap_err();
288        Basic::try_from("alice:hun\nter2").unwrap_err();
289        // NUL byte (C-string terminator) in either side.
290        Basic::try_from("ali\0ce:hunter2").unwrap_err();
291        Basic::try_from("alice:hun\0ter2").unwrap_err();
292        // Normal credential still parses.
293        Basic::try_from("alice:hunter2").unwrap();
294    }
295}