| 1 | //! Origin-bound browser session support for the loopback mobile control page. |
| 2 | //! |
| 3 | //! The Runtime bearer is deliberately never represented by these values. A |
| 4 | //! one-time terminal bootstrap (or an explicit bearer header at the session |
| 5 | //! endpoint) creates an opaque, process-local HttpOnly cookie plus browser |
| 6 | //! proofs held in origin-scoped session storage. The cookie is host scoped by |
| 7 | //! HTTP semantics and therefore can reach sibling ports; the proofs cannot. |
| 8 | |
| 9 | use std::collections::HashMap; |
| 10 | use std::net::{IpAddr, SocketAddr}; |
| 11 | use std::sync::{Arc, Mutex}; |
| 12 | use std::time::{Duration, Instant}; |
| 13 | |
| 14 | use uuid::Uuid; |
| 15 | |
| 16 | pub(super) const MOBILE_SESSION_COOKIE_NAME: &str = "codewhale_mobile_session"; |
| 17 | pub(super) const MOBILE_REQUEST_HEADER: &str = "x-codewhale-mobile-request"; |
| 18 | pub(super) const MOBILE_STREAM_TICKET_QUERY: &str = "mobile_stream_ticket"; |
| 19 | pub(super) const BOOTSTRAP_TTL: Duration = Duration::from_secs(10 * 60); |
| 20 | pub(super) const SESSION_TTL: Duration = Duration::from_secs(30 * 60); |
| 21 | pub(super) const STREAM_TICKET_TTL: Duration = Duration::from_secs(5 * 60); |
| 22 | |
| 23 | const BOOTSTRAP_PREFIX: &str = "cwmb_"; |
| 24 | const SESSION_PREFIX: &str = "cwms_"; |
| 25 | const REQUEST_PREFIX: &str = "cwmr_"; |
| 26 | const STREAM_PREFIX: &str = "cwmt_"; |
| 27 | |
| 28 | #[derive(Clone)] |
| 29 | pub(super) struct RuntimeMobileState { |
| 30 | bootstrap: Arc<Mutex<Option<BootstrapCapability>>>, |
| 31 | sessions: Arc<Mutex<HashMap<String, MobileSession>>>, |
| 32 | session_ttl: Duration, |
| 33 | stream_ticket_ttl: Duration, |
| 34 | } |
| 35 | |
| 36 | struct BootstrapCapability { |
| 37 | nonce: String, |
| 38 | expires_at: Instant, |
| 39 | } |
| 40 | |
| 41 | struct MobileSession { |
| 42 | request_proof: String, |
| 43 | stream_ticket: Option<String>, |
| 44 | expires_at: Instant, |
| 45 | stream_ticket_expires_at: Instant, |
| 46 | } |
| 47 | |
| 48 | #[derive(Debug, Clone)] |
| 49 | pub(super) struct MobileSessionBootstrap { |
| 50 | pub(super) session_cookie: String, |
| 51 | pub(super) request_proof: String, |
| 52 | pub(super) stream_ticket: String, |
| 53 | pub(super) session_ttl_seconds: u64, |
| 54 | pub(super) stream_ticket_ttl_seconds: u64, |
| 55 | } |
| 56 | |
| 57 | #[derive(Debug, Clone)] |
| 58 | pub(super) struct MobileStreamTicket { |
| 59 | pub(super) ticket: String, |
| 60 | pub(super) expires_in_seconds: u64, |
| 61 | } |
| 62 | |
| 63 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] |
| 64 | pub(super) enum BootstrapError { |
| 65 | Invalid, |
| 66 | Expired, |
| 67 | NonLoopback, |
| 68 | } |
| 69 | |
| 70 | impl RuntimeMobileState { |
| 71 | pub(super) fn new() -> (Self, String) { |
| 72 | Self::new_with_ttls(BOOTSTRAP_TTL, SESSION_TTL, STREAM_TICKET_TTL) |
| 73 | } |
| 74 | |
| 75 | fn new_with_ttls( |
| 76 | bootstrap_ttl: Duration, |
| 77 | session_ttl: Duration, |
| 78 | stream_ticket_ttl: Duration, |
| 79 | ) -> (Self, String) { |
| 80 | let nonce = random_capability(BOOTSTRAP_PREFIX); |
| 81 | let state = Self { |
| 82 | bootstrap: Arc::new(Mutex::new(Some(BootstrapCapability { |
| 83 | nonce: nonce.clone(), |
| 84 | expires_at: Instant::now() + bootstrap_ttl, |
| 85 | }))), |
| 86 | sessions: Arc::new(Mutex::new(HashMap::new())), |
| 87 | session_ttl, |
| 88 | stream_ticket_ttl, |
| 89 | }; |
| 90 | (state, nonce) |
| 91 | } |
| 92 | |
| 93 | /// Consume the terminal bootstrap once, only from a loopback peer. |
| 94 | pub(super) fn consume_bootstrap( |
| 95 | &self, |
| 96 | nonce: &str, |
| 97 | peer_ip: IpAddr, |
| 98 | ) -> Result<MobileSessionBootstrap, BootstrapError> { |
| 99 | if !peer_ip.is_loopback() { |
| 100 | return Err(BootstrapError::NonLoopback); |
| 101 | } |
| 102 | if !valid_bootstrap_nonce(nonce) { |
| 103 | return Err(BootstrapError::Invalid); |
| 104 | } |
| 105 | |
| 106 | let mut slot = self |
| 107 | .bootstrap |
| 108 | .lock() |
| 109 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 110 | let Some(capability) = slot.as_ref() else { |
| 111 | return Err(BootstrapError::Invalid); |
| 112 | }; |
| 113 | if Instant::now() >= capability.expires_at { |
| 114 | *slot = None; |
| 115 | return Err(BootstrapError::Expired); |
| 116 | } |
| 117 | if !constant_time_eq(nonce.as_bytes(), capability.nonce.as_bytes()) { |
| 118 | return Err(BootstrapError::Invalid); |
| 119 | } |
| 120 | let _capability = slot.take().expect("bootstrap capability checked above"); |
| 121 | drop(slot); |
| 122 | |
| 123 | Ok(self.issue_session()) |
| 124 | } |
| 125 | |
| 126 | /// Create an opaque mobile browser session after explicit bearer proof. |
| 127 | pub(super) fn issue_session(&self) -> MobileSessionBootstrap { |
| 128 | let session_cookie = random_capability(SESSION_PREFIX); |
| 129 | let request_proof = random_capability(REQUEST_PREFIX); |
| 130 | let stream_ticket = random_capability(STREAM_PREFIX); |
| 131 | let now = Instant::now(); |
| 132 | let mut sessions = self |
| 133 | .sessions |
| 134 | .lock() |
| 135 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 136 | sessions.retain(|_, session| session.expires_at > now); |
| 137 | sessions.insert( |
| 138 | session_cookie.clone(), |
| 139 | MobileSession { |
| 140 | request_proof: request_proof.clone(), |
| 141 | stream_ticket: Some(stream_ticket.clone()), |
| 142 | expires_at: now + self.session_ttl, |
| 143 | stream_ticket_expires_at: now + self.stream_ticket_ttl, |
| 144 | }, |
| 145 | ); |
| 146 | MobileSessionBootstrap { |
| 147 | session_cookie, |
| 148 | request_proof, |
| 149 | stream_ticket, |
| 150 | session_ttl_seconds: self.session_ttl.as_secs(), |
| 151 | stream_ticket_ttl_seconds: self.stream_ticket_ttl.as_secs(), |
| 152 | } |
| 153 | } |
| 154 | |
| 155 | /// Validate a cookie plus the origin-scoped proof used by fetch requests. |
| 156 | pub(super) fn matches_request( |
| 157 | &self, |
| 158 | cookie_header: Option<&str>, |
| 159 | request_proof: Option<&str>, |
| 160 | ) -> bool { |
| 161 | let Some(session_cookie) = cookie_value(cookie_header, MOBILE_SESSION_COOKIE_NAME) else { |
| 162 | return false; |
| 163 | }; |
| 164 | let Some(request_proof) = request_proof else { |
| 165 | return false; |
| 166 | }; |
| 167 | let now = Instant::now(); |
| 168 | let mut sessions = self |
| 169 | .sessions |
| 170 | .lock() |
| 171 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 172 | sessions.retain(|_, session| session.expires_at > now); |
| 173 | let Some(session) = sessions.get(session_cookie) else { |
| 174 | return false; |
| 175 | }; |
| 176 | constant_time_eq(request_proof.as_bytes(), session.request_proof.as_bytes()) |
| 177 | } |
| 178 | |
| 179 | /// Consume a short-lived stream ticket. A reconnect must mint a fresh one |
| 180 | /// through the cookie-plus-request-proof endpoint. |
| 181 | pub(super) fn consume_stream_ticket( |
| 182 | &self, |
| 183 | cookie_header: Option<&str>, |
| 184 | stream_ticket: Option<&str>, |
| 185 | ) -> bool { |
| 186 | let Some(session_cookie) = cookie_value(cookie_header, MOBILE_SESSION_COOKIE_NAME) else { |
| 187 | return false; |
| 188 | }; |
| 189 | let Some(stream_ticket) = stream_ticket else { |
| 190 | return false; |
| 191 | }; |
| 192 | let now = Instant::now(); |
| 193 | let mut sessions = self |
| 194 | .sessions |
| 195 | .lock() |
| 196 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 197 | sessions.retain(|_, session| session.expires_at > now); |
| 198 | let Some(session) = sessions.get_mut(session_cookie) else { |
| 199 | return false; |
| 200 | }; |
| 201 | if now >= session.stream_ticket_expires_at { |
| 202 | session.stream_ticket = None; |
| 203 | return false; |
| 204 | } |
| 205 | let matches = session |
| 206 | .stream_ticket |
| 207 | .as_ref() |
| 208 | .is_some_and(|issued| constant_time_eq(stream_ticket.as_bytes(), issued.as_bytes())); |
| 209 | if matches { |
| 210 | session.stream_ticket = None; |
| 211 | } |
| 212 | matches |
| 213 | } |
| 214 | |
| 215 | /// Mint a new single-use stream ticket after a normal origin-bound request. |
| 216 | pub(super) fn refresh_stream_ticket( |
| 217 | &self, |
| 218 | cookie_header: Option<&str>, |
| 219 | request_proof: Option<&str>, |
| 220 | ) -> Option<MobileStreamTicket> { |
| 221 | let session_cookie = cookie_value(cookie_header, MOBILE_SESSION_COOKIE_NAME)?; |
| 222 | let request_proof = request_proof?; |
| 223 | let now = Instant::now(); |
| 224 | let mut sessions = self |
| 225 | .sessions |
| 226 | .lock() |
| 227 | .unwrap_or_else(|poisoned| poisoned.into_inner()); |
| 228 | sessions.retain(|_, session| session.expires_at > now); |
| 229 | let session = sessions.get_mut(session_cookie)?; |
| 230 | if !constant_time_eq(request_proof.as_bytes(), session.request_proof.as_bytes()) { |
| 231 | return None; |
| 232 | } |
| 233 | let ticket = random_capability(STREAM_PREFIX); |
| 234 | session.stream_ticket = Some(ticket.clone()); |
| 235 | session.stream_ticket_expires_at = now + self.stream_ticket_ttl; |
| 236 | Some(MobileStreamTicket { |
| 237 | ticket, |
| 238 | expires_in_seconds: self.stream_ticket_ttl.as_secs(), |
| 239 | }) |
| 240 | } |
| 241 | } |
| 242 | |
| 243 | pub(super) fn bootstrap_url(addr: SocketAddr, nonce: &str) -> String { |
| 244 | format!("http://{addr}/__codewhale/mobile/bootstrap/{nonce}") |
| 245 | } |
| 246 | |
| 247 | pub(super) fn mobile_session_cookie(session_cookie: &str) -> String { |
| 248 | format!( |
| 249 | "{MOBILE_SESSION_COOKIE_NAME}={session_cookie}; Max-Age={}; HttpOnly; SameSite=Strict; Path=/", |
| 250 | SESSION_TTL.as_secs() |
| 251 | ) |
| 252 | } |
| 253 | |
| 254 | fn random_capability(prefix: &str) -> String { |
| 255 | format!( |
| 256 | "{prefix}{}{}", |
| 257 | Uuid::new_v4().simple(), |
| 258 | Uuid::new_v4().simple() |
| 259 | ) |
| 260 | } |
| 261 | |
| 262 | fn cookie_value<'a>(cookie_header: Option<&'a str>, name: &str) -> Option<&'a str> { |
| 263 | cookie_header.and_then(|cookie| { |
| 264 | cookie.split(';').find_map(|pair| { |
| 265 | let (key, value) = pair.trim().split_once('=')?; |
| 266 | (key == name).then_some(value.trim()) |
| 267 | }) |
| 268 | }) |
| 269 | } |
| 270 | |
| 271 | fn valid_bootstrap_nonce(value: &str) -> bool { |
| 272 | value.strip_prefix(BOOTSTRAP_PREFIX).is_some_and(|random| { |
| 273 | random.len() == 64 && random.bytes().all(|byte| byte.is_ascii_hexdigit()) |
| 274 | }) |
| 275 | } |
| 276 | |
| 277 | fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { |
| 278 | if left.len() != right.len() { |
| 279 | return false; |
| 280 | } |
| 281 | left.iter() |
| 282 | .zip(right) |
| 283 | .fold(0_u8, |difference, (left, right)| { |
| 284 | difference | (left ^ right) |
| 285 | }) |
| 286 | == 0 |
| 287 | } |
| 288 | |
| 289 | #[cfg(test)] |
| 290 | mod tests { |
| 291 | use super::*; |
| 292 | |
| 293 | #[test] |
| 294 | fn bootstrap_is_loopback_only_one_time_and_yields_no_runtime_bearer() { |
| 295 | let (state, nonce) = RuntimeMobileState::new_with_ttls( |
| 296 | Duration::from_secs(60), |
| 297 | Duration::from_secs(60), |
| 298 | Duration::from_secs(60), |
| 299 | ); |
| 300 | assert!(matches!( |
| 301 | state.consume_bootstrap(&nonce, "192.0.2.4".parse().unwrap()), |
| 302 | Err(BootstrapError::NonLoopback) |
| 303 | )); |
| 304 | let session = state |
| 305 | .consume_bootstrap(&nonce, "127.0.0.1".parse().unwrap()) |
| 306 | .expect("valid loopback bootstrap"); |
| 307 | assert!(session.session_cookie.starts_with(SESSION_PREFIX)); |
| 308 | assert!(session.request_proof.starts_with(REQUEST_PREFIX)); |
| 309 | assert!(session.stream_ticket.starts_with(STREAM_PREFIX)); |
| 310 | assert_ne!(session.session_cookie, session.request_proof); |
| 311 | assert!(matches!( |
| 312 | state.consume_bootstrap(&nonce, "127.0.0.1".parse().unwrap()), |
| 313 | Err(BootstrapError::Invalid) |
| 314 | )); |
| 315 | } |
| 316 | |
| 317 | #[test] |
| 318 | fn session_requires_origin_scoped_proof_and_stream_tickets_are_single_use() { |
| 319 | let (state, _nonce) = RuntimeMobileState::new_with_ttls( |
| 320 | Duration::from_secs(60), |
| 321 | Duration::from_secs(60), |
| 322 | Duration::from_secs(60), |
| 323 | ); |
| 324 | let session = state.issue_session(); |
| 325 | let cookie = format!("{MOBILE_SESSION_COOKIE_NAME}={}", session.session_cookie); |
| 326 | assert!(!state.matches_request(Some(&cookie), None)); |
| 327 | assert!(!state.matches_request(Some(&cookie), Some("wrong-proof"))); |
| 328 | assert!(state.matches_request(Some(&cookie), Some(&session.request_proof))); |
| 329 | assert!(state.consume_stream_ticket(Some(&cookie), Some(&session.stream_ticket))); |
| 330 | assert!( |
| 331 | !state.consume_stream_ticket(Some(&cookie), Some(&session.stream_ticket)), |
| 332 | "a captured EventSource URL cannot be replayed" |
| 333 | ); |
| 334 | |
| 335 | let replacement = state |
| 336 | .refresh_stream_ticket(Some(&cookie), Some(&session.request_proof)) |
| 337 | .expect("valid browser request can mint one replacement"); |
| 338 | assert!(state.consume_stream_ticket(Some(&cookie), Some(&replacement.ticket))); |
| 339 | } |
| 340 | |
| 341 | #[test] |
| 342 | fn cookie_has_exact_security_attributes_and_no_domain() { |
| 343 | let session_cookie = format!("{SESSION_PREFIX}{}", "01".repeat(32)); |
| 344 | let cookie = mobile_session_cookie(&session_cookie); |
| 345 | assert_eq!( |
| 346 | cookie, |
| 347 | format!( |
| 348 | "{MOBILE_SESSION_COOKIE_NAME}={session_cookie}; Max-Age=1800; HttpOnly; SameSite=Strict; Path=/" |
| 349 | ) |
| 350 | ); |
| 351 | assert!(!cookie.contains("Domain=")); |
| 352 | assert!(!cookie.contains("cwrt_")); |
| 353 | } |
| 354 | |
| 355 | #[test] |
| 356 | fn launcher_url_contains_only_the_one_time_capability() { |
| 357 | let nonce = format!("{BOOTSTRAP_PREFIX}{}", "01".repeat(32)); |
| 358 | let url = bootstrap_url("127.0.0.1:7878".parse().unwrap(), &nonce); |
| 359 | assert!(url.ends_with(&nonce)); |
| 360 | assert!(!url.contains('?')); |
| 361 | assert!(!url.contains('#')); |
| 362 | assert!(!url.contains("cwrt_")); |
| 363 | } |
| 364 | } |
| 365 |