返回 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 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
354 lines RUST