返回 CodeWhale
mobile.rs
根目录 / crates / tui / src / runtime_api / mobile.rs
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
365 lines RUST