Skip to main content

hns_browser_loopback_proxy/chromium/
auth.rs

1use base64::Engine as _;
2use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
3use ring::digest::{SHA256, digest};
4use ring::rand::{SecureRandom, SystemRandom};
5use subtle::ConstantTimeEq;
6use thiserror::Error;
7
8pub const PROXY_AUTHORIZATION_HEADER: &str = "Proxy-Authorization";
9pub const PROXY_AUTHENTICATE_HEADER: &str = "Proxy-Authenticate";
10
11const USERNAME: &str = "hns-browser";
12const REALM_RANDOM_BYTES: usize = 12;
13const PASSWORD_RANDOM_BYTES: usize = 32;
14const MAX_ENCODED_CREDENTIAL_BYTES: usize = 256;
15
16/// Per-instance capability credentials for the loopback proxy.
17///
18/// There is deliberately no public constructor that accepts credentials. A running proxy must
19/// generate a fresh value and require it on every request, including `CONNECT` requests.
20pub struct ProxyAuthorization {
21    realm: String,
22    username: String,
23    password: String,
24    expected_credentials_digest: [u8; 32],
25    expected_credentials_len: usize,
26}
27
28impl ProxyAuthorization {
29    pub fn generate() -> Result<Self, AuthorizationGenerationError> {
30        let random = SystemRandom::new();
31        let realm = format!(
32            "hns-loopback-{}",
33            random_url_token(&random, REALM_RANDOM_BYTES)?
34        );
35        let password = random_url_token(&random, PASSWORD_RANDOM_BYTES)?;
36        Ok(Self::from_generated_parts(realm, password))
37    }
38
39    pub fn realm(&self) -> &str {
40        &self.realm
41    }
42
43    pub fn username(&self) -> &str {
44        &self.username
45    }
46
47    pub fn password(&self) -> &str {
48        &self.password
49    }
50
51    pub fn authorization_header_value(&self) -> String {
52        format!("Basic {}", STANDARD.encode(self.credentials()))
53    }
54
55    pub fn challenge_header_value(&self) -> String {
56        format!("Basic realm=\"{}\"", self.realm)
57    }
58
59    /// Verifies exactly one `Proxy-Authorization` value.
60    ///
61    /// Callers should pass every occurrence of the header so duplicate credentials fail closed.
62    pub fn verify_header_values<'a>(&self, values: impl IntoIterator<Item = &'a str>) -> bool {
63        let mut values = values.into_iter();
64        let Some(value) = values.next() else {
65            return false;
66        };
67        if values.next().is_some() {
68            return false;
69        }
70        self.verify_header_value(value)
71    }
72
73    pub fn verify_header_value(&self, value: &str) -> bool {
74        let Some(encoded) = basic_token(value) else {
75            return false;
76        };
77        if encoded.len() > MAX_ENCODED_CREDENTIAL_BYTES {
78            return false;
79        }
80        let Ok(supplied) = STANDARD.decode(encoded) else {
81            return false;
82        };
83
84        // The digest comparison is fixed-width and constant-time. The separate length comparison
85        // only reveals the public, fixed credential format and prevents accepting a hash collision
86        // between differently sized inputs.
87        let supplied_digest = digest(&SHA256, &supplied);
88        let digest_matches = supplied_digest
89            .as_ref()
90            .ct_eq(&self.expected_credentials_digest)
91            .unwrap_u8();
92        let length_matches = u8::from(supplied.len() == self.expected_credentials_len);
93        (digest_matches & length_matches) == 1
94    }
95
96    /// Limits credential challenge handling to this proxy's only supported bind address.
97    pub fn matches_challenge(&self, host: &str, challenge_realm: &str) -> bool {
98        let host = host.trim();
99        let host = host
100            .strip_prefix('[')
101            .and_then(|value| value.strip_suffix(']'))
102            .unwrap_or(host);
103        host == "127.0.0.1" && challenge_realm == self.realm
104    }
105
106    fn from_generated_parts(realm: String, password: String) -> Self {
107        let username = USERNAME.to_owned();
108        let credentials = format!("{username}:{password}");
109        let credentials_digest = digest(&SHA256, credentials.as_bytes());
110        let mut expected_credentials_digest = [0_u8; 32];
111        expected_credentials_digest.copy_from_slice(credentials_digest.as_ref());
112
113        Self {
114            realm,
115            username,
116            password,
117            expected_credentials_digest,
118            expected_credentials_len: credentials.len(),
119        }
120    }
121
122    fn credentials(&self) -> String {
123        format!("{}:{}", self.username, self.password)
124    }
125}
126
127impl std::fmt::Debug for ProxyAuthorization {
128    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129        formatter
130            .debug_struct("ProxyAuthorization")
131            .field("realm", &"[REDACTED]")
132            .field("username", &"[REDACTED]")
133            .field("password", &"[REDACTED]")
134            .finish_non_exhaustive()
135    }
136}
137
138#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
139#[error("the operating-system random number generator failed")]
140pub struct AuthorizationGenerationError;
141
142fn random_url_token(
143    random: &dyn SecureRandom,
144    byte_count: usize,
145) -> Result<String, AuthorizationGenerationError> {
146    let mut bytes = vec![0_u8; byte_count];
147    random
148        .fill(&mut bytes)
149        .map_err(|_| AuthorizationGenerationError)?;
150    Ok(URL_SAFE_NO_PAD.encode(bytes))
151}
152
153fn basic_token(value: &str) -> Option<&str> {
154    if value
155        .chars()
156        .any(|character| character.is_control() && !matches!(character, ' ' | '\t'))
157    {
158        return None;
159    }
160    let value = value.trim_matches([' ', '\t']);
161    let separator = value.find([' ', '\t'])?;
162    let (scheme, remainder) = value.split_at(separator);
163    if !scheme.eq_ignore_ascii_case("Basic") {
164        return None;
165    }
166
167    let token = remainder.trim_start_matches([' ', '\t']);
168    if token.is_empty() || token.chars().any(char::is_whitespace) {
169        return None;
170    }
171    Some(token)
172}
173
174#[cfg(test)]
175mod tests {
176    use super::*;
177
178    fn authorization() -> ProxyAuthorization {
179        ProxyAuthorization::from_generated_parts(
180            "hns-loopback-test-realm".to_owned(),
181            "test-password-capability".to_owned(),
182        )
183    }
184
185    #[test]
186    fn generated_authorizations_are_fresh_capabilities() {
187        let first = ProxyAuthorization::generate().unwrap();
188        let second = ProxyAuthorization::generate().unwrap();
189
190        assert_eq!(first.username(), USERNAME);
191        assert!(first.realm().starts_with("hns-loopback-"));
192        assert_ne!(first.realm(), second.realm());
193        assert_ne!(first.password(), second.password());
194        assert!(first.verify_header_value(&first.authorization_header_value()));
195    }
196
197    #[test]
198    fn verifies_basic_credentials_and_rejects_substitutions() {
199        let authorization = authorization();
200        let valid = authorization.authorization_header_value();
201        let encoded_wrong = STANDARD.encode("hns-browser:wrong-password");
202
203        assert!(authorization.verify_header_value(&valid));
204        assert!(authorization.verify_header_value(&valid.replacen("Basic", "bAsIc", 1)));
205        assert!(!authorization.verify_header_value(&format!("Basic {encoded_wrong}")));
206        assert!(!authorization.verify_header_value("Bearer abc"));
207        assert!(!authorization.verify_header_value("Basic !!!"));
208        assert!(!authorization.verify_header_value("Basic"));
209        assert!(!authorization.verify_header_value("Basic Zm9v YmFy"));
210        assert!(!authorization.verify_header_value(&format!("{valid}\r\n")));
211    }
212
213    #[test]
214    fn requires_exactly_one_authorization_header() {
215        let authorization = authorization();
216        let valid = authorization.authorization_header_value();
217
218        assert!(!authorization.verify_header_values(std::iter::empty()));
219        assert!(authorization.verify_header_values([valid.as_str()]));
220        assert!(!authorization.verify_header_values([valid.as_str(), valid.as_str()]));
221    }
222
223    #[test]
224    fn debug_output_redacts_every_capability_component() {
225        let authorization = authorization();
226        let debug = format!("{authorization:?}");
227
228        assert!(!debug.contains(authorization.realm()));
229        assert!(!debug.contains(authorization.username()));
230        assert!(!debug.contains(authorization.password()));
231        assert!(debug.contains("[REDACTED]"));
232    }
233
234    #[test]
235    fn challenge_matching_is_restricted_to_ipv4_loopback_and_exact_realm() {
236        let authorization = authorization();
237
238        assert!(authorization.matches_challenge("127.0.0.1", authorization.realm()));
239        assert!(authorization.matches_challenge("[127.0.0.1]", authorization.realm()));
240        assert!(!authorization.matches_challenge("localhost", authorization.realm()));
241        assert!(!authorization.matches_challenge("::1", authorization.realm()));
242        assert!(!authorization.matches_challenge("[[127.0.0.1]]", authorization.realm()));
243        assert!(!authorization.matches_challenge("127.0.0.1", "another-realm"));
244    }
245}