hns_browser_loopback_proxy/chromium/
auth.rs1use 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
16pub 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 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 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 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}