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