Skip to main content

broadcast_auth/
credentials.rs

1//! The scheme-agnostic credential model shared by every auth-facing client.
2
3/// Credentials for one of the supported auth schemes.
4///
5/// `Basic` and `Digest` both carry a username/password and are treated
6/// identically by [`crate::respond`]/[`crate::Authenticator`]: which wire
7/// scheme is actually used is decided by the server's challenge, not by which
8/// variant was constructed (RFC 7235 content negotiation — a server may offer
9/// either, or both, in `WWW-Authenticate`). Use [`Credentials::new`] for the
10/// common password case; construct `Basic`/`Digest` directly only when the
11/// caller must pin one scheme.
12#[non_exhaustive]
13#[derive(Clone)]
14pub enum Credentials {
15    /// Username/password for HTTP Basic auth (RFC 7617) — or RTSP's reuse of
16    /// it (RFC 2326 §14/§16).
17    Basic {
18        /// Account username.
19        username: String,
20        /// Account password.
21        password: String,
22    },
23    /// Username/password for HTTP Digest auth (RFC 7616) — or RTSP's reuse of
24    /// it (RFC 2326 §14).
25    Digest {
26        /// Account username.
27        username: String,
28        /// Account password.
29        password: String,
30    },
31    /// A bearer token (RFC 6750) — sent verbatim as `Authorization: Bearer
32    /// <token>` with no challenge round-trip required.
33    Bearer {
34        /// The opaque bearer token.
35        token: String,
36    },
37}
38
39/// Constant-time in the secret (`password`/`token`) bytes via
40/// `subtle::ConstantTimeEq`, so comparing two credentials does not leak the
41/// length of a matching prefix (issue #1142). Usernames are not secret. Two
42/// different variants are never equal.
43impl PartialEq for Credentials {
44    fn eq(&self, other: &Self) -> bool {
45        use subtle::ConstantTimeEq;
46        let ct =
47            |a: &str, b: &str| a.len() == b.len() && bool::from(a.as_bytes().ct_eq(b.as_bytes()));
48        match (self, other) {
49            (
50                Credentials::Basic {
51                    username: ua,
52                    password: pa,
53                },
54                Credentials::Basic {
55                    username: ub,
56                    password: pb,
57                },
58            )
59            | (
60                Credentials::Digest {
61                    username: ua,
62                    password: pa,
63                },
64                Credentials::Digest {
65                    username: ub,
66                    password: pb,
67                },
68            ) => ua == ub && ct(pa, pb),
69            (Credentials::Bearer { token: a }, Credentials::Bearer { token: b }) => ct(a, b),
70            _ => false,
71        }
72    }
73}
74
75impl Eq for Credentials {}
76
77/// Wipe the password/token on drop (issue #1142) so it does not linger in
78/// freed memory.
79impl Drop for Credentials {
80    fn drop(&mut self) {
81        use zeroize::Zeroize;
82        match self {
83            Credentials::Basic { password, .. } | Credentials::Digest { password, .. } => {
84                password.zeroize();
85            }
86            Credentials::Bearer { token } => token.zeroize(),
87        }
88    }
89}
90
91impl Credentials {
92    /// Convenience constructor for the common password-based case.
93    ///
94    /// Does not commit to Basic or Digest: [`crate::respond`]/
95    /// [`crate::Authenticator::from_challenge`] answer whichever scheme the
96    /// server's `WWW-Authenticate` challenge advertises. Internally this
97    /// builds a `Digest` value (the superset — `http-auth`'s challenge
98    /// parser inspects the challenge itself, not this variant, to pick the
99    /// wire scheme), so `new("u", "p")` behaves identically to a
100    /// hand-constructed `Basic`/`Digest` with the same username/password.
101    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
102        Credentials::Digest {
103            username: username.into(),
104            password: password.into(),
105        }
106    }
107
108    /// Constructs a bearer-token credential (RFC 6750).
109    pub fn bearer(token: impl Into<String>) -> Self {
110        Credentials::Bearer {
111            token: token.into(),
112        }
113    }
114
115    /// Returns the `(username, password)` pair for a password-based scheme,
116    /// or `None` for `Bearer`.
117    pub(crate) fn username_password(&self) -> Option<(&str, &str)> {
118        match self {
119            Credentials::Basic { username, password }
120            | Credentials::Digest { username, password } => Some((username, password)),
121            Credentials::Bearer { .. } => None,
122        }
123    }
124}
125
126/// Manual `Debug` (rather than `#[derive(Debug)]`): every scheme carries a
127/// secret (`password`/`token`) that must never render verbatim. Usernames are
128/// not secret and are shown as-is to keep the output useful for diagnostics.
129impl core::fmt::Debug for Credentials {
130    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
131        match self {
132            Credentials::Basic { username, .. } => f
133                .debug_struct("Credentials::Basic")
134                .field("username", username)
135                .field("password", &"***")
136                .finish(),
137            Credentials::Digest { username, .. } => f
138                .debug_struct("Credentials::Digest")
139                .field("username", username)
140                .field("password", &"***")
141                .finish(),
142            Credentials::Bearer { .. } => f
143                .debug_struct("Credentials::Bearer")
144                .field("token", &"***")
145                .finish(),
146        }
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    // Equality is constant-time in the secret (#1142) but must stay exact: a
155    // difference in the last byte, in length, in username, or in variant is
156    // never equal.
157    #[test]
158    fn equality_is_exact_across_secret_username_and_variant() {
159        let base = Credentials::new("admin", "s3cr3t-password");
160        assert_eq!(base, Credentials::new("admin", "s3cr3t-password"));
161        assert_ne!(base, Credentials::new("admin", "s3cr3t-passworD"));
162        assert_ne!(base, Credentials::new("admin", "s3cr3t-passwor"));
163        assert_ne!(base, Credentials::new("admin2", "s3cr3t-password"));
164        assert_ne!(
165            base,
166            Credentials::Basic {
167                username: "admin".into(),
168                password: "s3cr3t-password".into(),
169            }
170        );
171        assert_eq!(Credentials::bearer("tok"), Credentials::bearer("tok"));
172        assert_ne!(Credentials::bearer("tok"), Credentials::bearer("tol"));
173        assert_ne!(Credentials::bearer("tok"), base);
174    }
175
176    // Security-blocker regression (pre-release audit): `Credentials` must
177    // never render a secret via `{:?}`. Must FAIL if `Debug` reverts to a
178    // plain `#[derive(Debug)]`.
179    #[test]
180    fn basic_debug_redacts_password_but_keeps_username() {
181        let creds = Credentials::Basic {
182            username: "admin".to_string(),
183            password: "s3cr3t-password".to_string(),
184        };
185        let debug = format!("{creds:?}");
186        assert!(!debug.contains("s3cr3t-password"), "leaked: {debug}");
187        assert!(
188            debug.contains("admin"),
189            "username should be visible: {debug}"
190        );
191        assert!(debug.contains("***"), "expected redaction marker: {debug}");
192    }
193
194    #[test]
195    fn digest_debug_redacts_password_but_keeps_username() {
196        let creds = Credentials::Digest {
197            username: "camera-user".to_string(),
198            password: "hunter2-super-secret".to_string(),
199        };
200        let debug = format!("{creds:?}");
201        assert!(!debug.contains("hunter2-super-secret"), "leaked: {debug}");
202        assert!(
203            debug.contains("camera-user"),
204            "username should be visible: {debug}"
205        );
206        assert!(debug.contains("***"), "expected redaction marker: {debug}");
207    }
208
209    #[test]
210    fn bearer_debug_redacts_token() {
211        let creds = Credentials::bearer("super-secret-bearer-token-xyz");
212        let debug = format!("{creds:?}");
213        assert!(
214            !debug.contains("super-secret-bearer-token-xyz"),
215            "leaked: {debug}"
216        );
217        assert!(debug.contains("***"), "expected redaction marker: {debug}");
218    }
219}