From cbb851e3105797dec69f83e5ec348dbac19de37a Mon Sep 17 00:00:00 2001 From: Kyle Chong Date: Thu, 30 Jul 2026 11:45:53 -0700 Subject: [PATCH] Put /upload and /delete behind a per-session token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/upload`, `/delete` and `/list` answered anything on the LAN. While a wireless session was up, any device on the same network could add or delete books on the card, with no step in between. This closes that. A token is minted per serving session from the hardware RNG — the same rejection-sampled draw and the same unambiguous alphabet as the portal PSK — and every endpoint moves behind it as the first path segment: `/`, `//list`, `//upload?name=...`. It is never persisted; the reset at session end retires it. The obvious cheap version — embed the token in the page served at `/` — protects nothing, because that page is exactly what an unauthorized client fetches first. The secret has to be something the user has and a stray LAN client does not, so it goes on the device: the serving screen now renders a QR of `http:///`, with the same address printed under it for phones that cannot scan. That is why the alphabet excludes look-alike glyphs — someone is going to read six characters off an e-ink panel. The page itself stays a build-time constant. Its fetches derive the token from `location.pathname`, which the browser already has, so the HTML never carries the secret and templating it per request never has to exist. In `app_core::UploadToken::strip_path_prefix`, not in the server loop. It is parsing, it decides who may write to the card, and `fw` has no host tests to hold it — the same argument that moved the reader cache into its own crate. The server calls it and re-slices past the returned prefix, so the dispatch below sees exactly the paths it always did, query strings and offsets included. A refused request gets `404 Not Found`, the same answer a nonexistent path gets, before any upload machinery is touched — a wrong token must not open a storage session, and must not tell the caller it was close. Comparison is over the full length rather than short-circuiting. `the_gate_admits_exactly_the_urls_the_screen_shows` pins both directions against the concrete URLs: the four the page produces, and eleven a LAN client would reach for unaided — bare `/list`, `/upload?name=...`, wrong case, truncated and overlong segments, and the token appearing as a *later* segment (`/upload/k7mfqx`), which a check that scanned instead of anchoring would wrongly admit. It also pins that stripping leaves the query string intact, which is what keeps the delete and upload handlers' offsets valid. Mutation-checked: dropping the leading-`/` anchor and loosening the length comparison to `>` are both caught. Token minting is pinned separately — every alphabet byte must be mintable, and nothing a URL or QR payload would need escaped may be. Flash +2,140 B text; `.data` and `.bss` unchanged. `sync-flow` goldens re-blessed on both boards, deliberately: the serving screen gained the QR and the address line. Reviewed the rendered frames rather than blessing blind. **Device verification is still owed:** phone scans the QR and reaches the shelf; upload and delete work through it; the same endpoints without the token return 404 from another machine on the LAN; and the printed address is legible enough to type off the panel, which is the one thing no host test can answer. --- app-core/src/lib.rs | 199 +++++++++++++++++++++++++- fixtures/golden/sync-flow-x3.png | Bin 8835 -> 14834 bytes fixtures/golden/sync-flow.png | Bin 8424 -> 14501 bytes fixtures/golden/sync-portal-qr-x3.png | Bin 19289 -> 20719 bytes fixtures/golden/sync-portal-qr.png | Bin 19136 -> 20555 bytes fw/src/tasks/wifi.rs | 68 ++++++++- tools/emulator/src/scenario.rs | 7 +- tools/web-emulator/src/lib.rs | 5 +- ui/src/app_render.rs | 2 +- ui/src/join_qr.rs | 27 +++- ui/src/lib.rs | 2 +- ui/src/render.rs | 59 ++++++-- 12 files changed, 338 insertions(+), 31 deletions(-) diff --git a/app-core/src/lib.rs b/app-core/src/lib.rs index 1afbb9ed..f87abc1c 100644 --- a/app-core/src/lib.rs +++ b/app-core/src/lib.rs @@ -1167,6 +1167,84 @@ impl core::fmt::Debug for PortalPsk { } } +/// Gate on `/upload`, `/delete` and `/list` for one serving session. +/// +/// Those endpoints were reachable by anything on the LAN: any device on the +/// same network could add or delete books while a session was up. The fix +/// has to be something the *user* knows and a stray LAN client does not, so +/// the token is minted per session, shown on the device screen, and carried +/// as the first path segment of every request. Embedding it in the page +/// served at `/` instead would hand it to exactly the clients it excludes. +/// +/// Six characters of [`PSK_ALPHABET`] is ~34 bits. Short enough to type off +/// the screen when a phone cannot scan the QR, and the session is short and +/// LAN-scoped — an attacker gets no offline guessing, only online attempts +/// against an ESP32 that answers one connection at a time. +#[derive(Clone, Copy, PartialEq, Eq)] +pub struct UploadToken { + bytes: [u8; UploadToken::LEN], +} + +impl core::fmt::Debug for UploadToken { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.debug_struct("UploadToken") + .field("bytes", &"[REDACTED]") + .finish() + } +} + +impl UploadToken { + pub const LEN: usize = 6; + + /// Fixed value for the emulators, so golden frames render + /// deterministically. Never minted on a device. + pub const EMULATOR_DEMO: Self = Self { bytes: *b"k7mfqx" }; + + /// `None` unless every byte is from [`PSK_ALPHABET`], which is what + /// keeps a token safe to splice into a URL and a QR payload unescaped. + pub fn new(bytes: [u8; Self::LEN]) -> Option { + if bytes.iter().all(|byte| PSK_ALPHABET.contains(byte)) { + Some(Self { bytes }) + } else { + None + } + } + + pub fn as_str(&self) -> &str { + // Every byte came from PSK_ALPHABET, which is ASCII. + core::str::from_utf8(&self.bytes).unwrap_or("") + } + + /// Strip the leading `/` segment from a request path, returning + /// how many bytes it occupied so the caller can re-slice past it. + /// + /// `None` is every request the session must refuse. This is the gate + /// itself, so it lives here rather than in the server loop: it is + /// parsing, it decides who may write to the card, and `fw` has no host + /// tests to hold it. + pub fn strip_path_prefix(&self, path: &[u8]) -> Option { + let segment = path.strip_prefix(b"/")?; + let end = segment + .iter() + .position(|byte| *byte == b'/' || *byte == b'?') + .unwrap_or(segment.len()); + self.matches(&segment[..end]).then_some(1 + end) + } + + /// Whether `candidate` is this token, compared over the whole length + /// so a wrong guess cannot be narrowed by how long the answer took. + pub fn matches(&self, candidate: &[u8]) -> bool { + if candidate.len() != Self::LEN { + return false; + } + let mut diff = 0u8; + for (a, b) in self.bytes.iter().zip(candidate) { + diff |= a ^ b; + } + diff == 0 + } +} + /// Alphabet for the per-session portal PSK: ASCII alphanumerics minus /// the hand-typing-ambiguous 0/O/1/I/l/i/o (phones that cannot scan /// type it from the screen) and nothing the `WIFI:` QR payload needs @@ -1726,7 +1804,7 @@ pub enum SyncStatus { PortalUp(PortalPsk), /// Connected and the book server answers at this address until the /// session ends. - Serving([u8; 4]), + Serving([u8; 4], UploadToken), /// The portal captured and stored credentials; a fresh session will /// use them after the reset. CredentialsSaved, @@ -1757,7 +1835,7 @@ pub enum SyncEvent { Connected([u8; 4]), /// The onboarding hotspot is up, secured with this session's PSK. PortalUp(PortalPsk), - Serving([u8; 4]), + Serving([u8; 4], UploadToken), CredentialsSaved(WifiSsid), Failed(SyncError), } @@ -2190,7 +2268,7 @@ impl ReaderState { next.wifi_ssid_len = 0; next.sync_status = SyncStatus::NotConfigured; } - SyncStatus::CredentialsSaved | SyncStatus::Serving(_) => { + SyncStatus::CredentialsSaved | SyncStatus::Serving(..) => { next.view = AppView::Home; next.selection = 0; next.sync_status = next.wireless_entry_status(); @@ -2488,7 +2566,7 @@ impl ReaderState { SyncEvent::Connecting => SyncStatus::Connecting, SyncEvent::Connected(ip) => SyncStatus::Connected(ip), SyncEvent::PortalUp(psk) => SyncStatus::PortalUp(psk), - SyncEvent::Serving(ip) => SyncStatus::Serving(ip), + SyncEvent::Serving(ip, token) => SyncStatus::Serving(ip, token), SyncEvent::CredentialsSaved(ssid) => { self.wifi_ssid = ssid.bytes; self.wifi_ssid_len = ssid.len; @@ -2850,6 +2928,104 @@ fn next_font_family(family: FontFamily, custom_available: bool) -> FontFamily { #[cfg(test)] mod tests { + + #[test] + fn an_upload_token_only_accepts_its_own_exact_value() { + let token = UploadToken::EMULATOR_DEMO; + assert!(token.matches(token.as_str().as_bytes())); + assert_eq!(token.as_str().len(), UploadToken::LEN); + + // The gate strips one path segment and hands it here, so these are + // the shapes a wrong or truncated URL actually produces. + assert!(!token.matches(b"")); + assert!(!token.matches(b"k7mfq")); + assert!(!token.matches(b"k7mfqxx")); + assert!(!token.matches(b"K7MFQX")); + assert!(!token.matches(b"upload")); + } + + #[test] + fn the_gate_admits_exactly_the_urls_the_screen_shows() { + let token = UploadToken::EMULATOR_DEMO; + let prefix = 1 + UploadToken::LEN; + + // The address rendered on the serving screen, and the three requests + // the page derives from it. These are the whole authorized surface. + assert_eq!(token.strip_path_prefix(b"/k7mfqx"), Some(prefix)); + assert_eq!(token.strip_path_prefix(b"/k7mfqx/list"), Some(prefix)); + assert_eq!( + token.strip_path_prefix(b"/k7mfqx/upload?name=book.epub"), + Some(prefix) + ); + assert_eq!( + token.strip_path_prefix(b"/k7mfqx/delete?name=BOOK.EPB&root=1"), + Some(prefix) + ); + // Stripping must leave the path the dispatch already understood, + // query string and all — that is what keeps the offsets the delete + // and upload handlers index into valid. + let path = b"/k7mfqx/upload?name=book.epub"; + assert_eq!(&path[prefix..], b"/upload?name=book.epub"); + + // Everything a LAN client would reach for unaided. + for refused in [ + &b"/"[..], + b"/list", + b"/upload?name=book.epub", + b"/delete?name=BOOK.EPB", + b"/favicon.ico", + b"", + // Right length, wrong value; and the token as a *later* segment, + // which a prefix check that scanned instead of anchoring would + // wrongly admit. + b"/k7mfqy/upload", + b"/upload/k7mfqx", + b"/K7MFQX/list", + // Truncated and overlong first segments. + b"/k7mfq/list", + b"/k7mfqxx/list", + ] { + assert_eq!( + token.strip_path_prefix(refused), + None, + "{:?} must be refused", + core::str::from_utf8(refused) + ); + } + } + + #[test] + fn an_upload_token_refuses_bytes_a_url_would_need_escaped() { + // The token is spliced into a URL and a QR payload unescaped, so + // the alphabet is the guarantee that it needs no escaping. + assert!(UploadToken::new(*b"k7mfqx").is_some()); + for bad in [*b"k7mf/x", *b"k7mf?x", *b"k7mf x", *b"k7mf;x", *b"k7mfq0"] { + assert!( + UploadToken::new(bad).is_none(), + "{:?} must not mint", + core::str::from_utf8(&bad) + ); + } + // Every alphabet byte must be mintable, or the draw silently loses + // part of its range. + for byte in PSK_ALPHABET { + assert!(UploadToken::new([*byte; UploadToken::LEN]).is_some()); + } + } + + #[test] + fn serving_carries_the_token_the_screen_has_to_show() { + let state = with_saved_network(ReaderState::boot()) + .apply_sync_event(SyncEvent::Connected([192, 168, 0, 233])) + .apply_sync_event(SyncEvent::Serving( + [192, 168, 0, 233], + UploadToken::EMULATOR_DEMO, + )); + assert_eq!( + state.sync_status, + SyncStatus::Serving([192, 168, 0, 233], UploadToken::EMULATOR_DEMO) + ); + } use super::*; const CTX: ReducerContext = ReducerContext::new(1, 3); @@ -3592,8 +3768,14 @@ mod tests { let state = with_saved_network(ReaderState::boot()); let state = press(press(state, Button::Previous), Button::Confirm) .apply_sync_event(SyncEvent::Connected([192, 168, 0, 233])) - .apply_sync_event(SyncEvent::Serving([192, 168, 0, 233])); - assert_eq!(state.sync_status, SyncStatus::Serving([192, 168, 0, 233])); + .apply_sync_event(SyncEvent::Serving( + [192, 168, 0, 233], + UploadToken::EMULATOR_DEMO, + )); + assert_eq!( + state.sync_status, + SyncStatus::Serving([192, 168, 0, 233], UploadToken::EMULATOR_DEMO) + ); // The screen labels Confirm "done" while serving, so it must exit // exactly like Back does (the wifi task defers the reset past any // in-flight transfer either way). @@ -4658,7 +4840,10 @@ mod tests { assert_eq!(held.sync_status, SyncStatus::Connecting); let state = state.apply_sync_event(SyncEvent::Connected([192, 168, 1, 23])); assert_eq!(state.sync_status, SyncStatus::Connected([192, 168, 1, 23])); - let state = state.apply_sync_event(SyncEvent::Serving([192, 168, 1, 23])); + let state = state.apply_sync_event(SyncEvent::Serving( + [192, 168, 1, 23], + UploadToken::EMULATOR_DEMO, + )); // The done press returns Home with the entry status restored. let state = press(state, Button::Confirm); diff --git a/fixtures/golden/sync-flow-x3.png b/fixtures/golden/sync-flow-x3.png index 644eaf70fa8478411f83cbfb97608ae307b0c815..3f5f8985ad98b79ecdafb296b6c8b9d7b4d90485 100644 GIT binary patch literal 14834 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 Xcfs!u*XYZf*Dt)`x=q*KvgW@5_BR&v literal 8835 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 z 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 diff --git a/fixtures/golden/sync-portal-qr-x3.png b/fixtures/golden/sync-portal-qr-x3.png index fdffd455a0a9c4619674ee03e456a77268759db6..7782a0fb47525b574fa3f0bd17e9efecba51edc9 100644 GIT binary patch literal 20719 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 literal 19289 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 DgF4sL literal 19136 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 < zJtfUsZhpHetdV_b>>o*zT|lPf_LedO+F`Q_^)43p+!XV9>k|;nIOQ_vR0}$zjXW0U zl;6FWz_67cq){y)!3J+pGQMb&WmGtp`-H*aUCD+_oy{7N-8}3 zV)Rx&+17lxA@o6eUygL#%WM`H#VW-w^W8zej}w``-t9gHc-6-T_lLPtSzodde8%VH zvk(eZSh#jR%ops#EL6o46O{DPXa{#?#~xruNMYFMEk(O$kpr7RHtYHreS<1XmW&j_ zp^O^;XFtqlI1Te;#-VNjGCOLlXkbYM%DLdcI!5$I92OqE*wOCsfL#}Z2kk_N_oytv zIkvHMdq^dAa81;cqL?)+Wi5b(^DZp9{*i?-Td*uFkR8_SRE2Z_$t^b!8+Vkg{$WIN z@CPW2X(;|s(i0gW%>@vGHTZinh6?#jipx|10b7KhLJqLSVk1cJvfj-Arz%?rA3i+` z%+OAS*J9^+rv+Dpa9U6MQQn8!8z_D@4@Zt6$>S|uA?mFYr zp)U^GMdx8vYhBHhg-oRtc89xkjHw1L$US>5{%a=T{Reyv*L0ERVOH!1#M^+jN}5~WeUX}h8f;y`NsCdr3>*!2=cq6!}MY&51fv74VMy254&?gDZuM2b)Al9>vK509ZLPq-Q)G+cVT}}UKoJ2`u;(6 zrX=zXBZjoRz^GK8dm8r4=CS0y@yNgOpDDY|e>^RA+84VpG5)D$61(7<+#biTs1!18 z4#ljY;CGD8&8?47vUNO!!G&mFn_KSVNZ)yUT*7&F8TM(nb660LTWZL3xktK??ypkw zmv}CQOsb#D8c6@n?VMjF1zE2ny5ydA4IevkHu(vJJqX8y>X%G}Hpz=>J4^60T$ZPS z(HxOp^uugn7(aHy^CS|oma^xa6BvR3OR3B15t>@TC17PvpXP$JHwdqGJum@@HnPRY zNO`oAKS^x18$YgNgN_;O57>Gh-^^Z%W)%G8y?3L%@swL%Pxhq;Z&Oh- z-sWL9j}#lGZ*dlc%~q#JeeP>0v4kgi@Y=a4P)}n42wR<~J~vm7K0zT~=S}ti4nEKY z6cE>6N>~^$qpW0+pa&PAQ5gD!Cv*TXpkqDYWwFY?dZ5dR>|e6S0MzOAM{>gS>1=&p zY0i3{lS2NlnQ(pDKTTr+pF!jHQm^Y9>=_*lfaXVMxyN*%9~8kUS`{BagTJ0^ST_(~ zfpl6!u|kP12?Ki@0`}NU#a)rWKxM`PVz-AGs^o zzcGnc8OV5xTLof2s$2e7sZX-;yhS+%4Jp z+yQ9jn|2FFi1=}(dw2@!225%jrWdI5|HR}qAC7=bj&rTDxvYwk=-3BOZ+XaZA-SHX p;!s1I{|A0}Q~c1V>`5QpvT*#t)nC7kkEXij%ImJ!c-hVO{tqG>783vf diff --git a/fw/src/tasks/wifi.rs b/fw/src/tasks/wifi.rs index af4b6cec..9a70627e 100644 --- a/fw/src/tasks/wifi.rs +++ b/fw/src/tasks/wifi.rs @@ -91,6 +91,25 @@ fn mint_portal_psk(rng: Rng) -> app_core::PortalPsk { app_core::PortalPsk::new(bytes).expect("minted PSK must be valid") } +/// Mints this serving session's upload token from the hardware RNG, the +/// same way and from the same alphabet as the portal PSK: unambiguous +/// glyphs, because a phone that cannot scan the QR reads it off the screen. +fn mint_upload_token(rng: Rng) -> app_core::UploadToken { + let mut bytes = [0u8; app_core::UploadToken::LEN]; + let mut filled = 0; + while filled < bytes.len() { + for byte in rng.random().to_le_bytes() { + let draw = (byte & 0x3F) as usize; + if draw < PSK_ALPHABET.len() && filled < bytes.len() { + bytes[filled] = PSK_ALPHABET[draw]; + filled += 1; + } + } + } + // Every byte was drawn from PSK_ALPHABET, so validation cannot fail. + app_core::UploadToken::new(bytes).expect("minted token must be valid") +} + /// Compile-time station credentials for the dev phase: /// `XTEINK_WIFI_SSID=... XTEINK_WIFI_PASS=... cargo build ...` pub fn credentials() -> Option<(&'static str, &'static str)> { @@ -217,6 +236,10 @@ pub async fn run(spawner: Spawner, wifi: WIFI<'static>) { // First Start already consumed; later Starts are Confirm retries // from the error screen. A successful join falls through to the // book server, which runs until the session's reset. + // Minted per session and never persisted: it exists only for as long as + // this server runs, and the reset at session end retires it. + let upload_token = mint_upload_token(rng); + let ip = loop { match session .attempt(creds.ssid(), creds.password(), &mut hint, stored_hint) @@ -234,10 +257,18 @@ pub async fn run(spawner: Spawner, wifi: WIFI<'static>) { let stack = session.stack; esp_println::println!("upload: serving at {}.{}.{}.{}", ip[0], ip[1], ip[2], ip[3]); - send_event(SyncEvent::Serving(ip)); + send_event(SyncEvent::Serving(ip, upload_token)); select( exit_after_uploads(), - upload_server(stack, tcp_rx, tcp_tx, http_a, http_b, catalog_len), + upload_server( + stack, + tcp_rx, + tcp_tx, + http_a, + http_b, + catalog_len, + upload_token, + ), ) .await; unreachable!() @@ -298,12 +329,18 @@ const UPLOAD_PAGE: &str = concat!( r##"queue=document.getElementById('queue'),"##, r##"drop=document.getElementById('drop'),"##, r##"input=document.getElementById('files');"##, + // The session token is the first path segment of whatever URL the + // client used to reach this page, so every request can be rebuilt from + // it. Deriving it here rather than templating it into the HTML keeps + // this page a build-time constant, and keeps the token out of the + // document source for anything that later saves or shares it. + r##"const T=location.pathname.replace(/\/+$/,'');"##, r##"function row(label){const li=document.createElement('li');"##, r##"const span=document.createElement('span');span.textContent=label;"##, r##"li.appendChild(span);return li}"##, r##"async function load(){let text=null;"##, r##"for(let i=0;i<10&&text===null;i++){try{"##, - r##"const r=await fetch('/list');if(r.ok)text=await r.text();}"##, + r##"const r=await fetch(T+'/list');if(r.ok)text=await r.text();}"##, r##"catch(e){}if(text===null)await new Promise(d=>setTimeout(d,800))}"##, r##"if(text===null){shelf.textContent='';"##, r##"shelf.appendChild(row('— the card did not answer —'));return}"##, @@ -315,7 +352,7 @@ const UPLOAD_PAGE: &str = concat!( r##"const a=document.createElement('a');a.className='del';"##, r##"a.textContent='remove';a.onclick=async()=>{"##, r##"if(!confirm('Remove '+(label||open)+' from the card?'))return;"##, - r##"const r=await fetch('/delete?name='+encodeURIComponent(open)+"##, + r##"const r=await fetch(T+'/delete?name='+encodeURIComponent(open)+"##, r##"(flag==='R'?'&root=1':''),"##, r##"{method:'POST'});if(r.ok)li.remove()};li.appendChild(a);"##, r##"shelf.appendChild(li)}}"##, @@ -323,7 +360,7 @@ const UPLOAD_PAGE: &str = concat!( r##"const li=row(f.name);const bar=document.createElement('progress');"##, r##"bar.max=1;bar.value=0;li.appendChild(bar);queue.appendChild(li);"##, r##"const xhr=new XMLHttpRequest();"##, - r##"xhr.open('POST','/upload?name='+encodeURIComponent(f.name));"##, + r##"xhr.open('POST',T+'/upload?name='+encodeURIComponent(f.name));"##, r##"xhr.upload.onprogress=e=>{if(e.lengthComputable)bar.value=e.loaded/e.total};"##, r##"xhr.onloadend=()=>{bar.remove();"##, r##"li.appendChild(document.createTextNode(xhr.status===200?' ✓':' — failed'));"##, @@ -347,6 +384,7 @@ async fn upload_server( request_buf: &'static mut [u8], catalog: &'static mut [u8], catalog_len: usize, + token: app_core::UploadToken, ) -> ! { // Staging ping-pong buffers live in the loaned heap. let mut pool: heapless::Vec<&'static mut [u8], 2> = heapless::Vec::new(); @@ -412,6 +450,26 @@ async fn upload_server( // Reborrow the pieces by index so the buffer stays usable for the // body bytes that arrived with the headers. let path_at = method_len + 1; + // Every endpoint sits behind this session's token, carried as the + // first path segment: `/`, `//list`, + // `//upload?name=...`. Without it any device on the LAN could + // add and delete books. Stripping it here means the dispatch below + // sees exactly the paths it always did, including the query offsets + // the delete and upload handlers index into. + let stripped = request_buf + .get(path_at..path_at + path_len) + .and_then(|path| token.strip_path_prefix(path)) + .map(|prefix| (path_at + prefix, path_len - prefix)); + let Some((path_at, path_len)) = stripped else { + // Deliberately the same answer a nonexistent path gets, and + // before any upload machinery is touched: a wrong token must + // not start a storage session or tell the caller it was close. + let _ = write_http_response(&mut socket, "404 Not Found", "not found").await; + socket.close(); + let _ = with_timeout(Duration::from_secs(2), socket.flush()).await; + continue; + }; + let is_upload_post = request_buf .get(..method_len) .map(|m| m == b"POST") diff --git a/tools/emulator/src/scenario.rs b/tools/emulator/src/scenario.rs index 7c8cf73b..be874def 100644 --- a/tools/emulator/src/scenario.rs +++ b/tools/emulator/src/scenario.rs @@ -277,7 +277,10 @@ fn parse_sync_event(kind: &str, step: &Step) -> Result { // firmware mints, so the join QR renders deterministically for // the goldens. "PortalUp" | "portal-up" => Ok(SyncEvent::PortalUp(app_core::PortalPsk::EMULATOR_DEMO)), - "Serving" | "serving" => Ok(SyncEvent::Serving(step.ip.unwrap_or([192, 168, 0, 233]))), + "Serving" | "serving" => Ok(SyncEvent::Serving( + step.ip.unwrap_or([192, 168, 0, 233]), + app_core::UploadToken::EMULATOR_DEMO, + )), "NetworkSaved" | "network-saved" => Ok(SyncEvent::NetworkSaved( app_core::WifiSsid::new(step.ssid.as_deref().unwrap_or("HOME-WIFI")) .ok_or_else(|| "bad ssid".to_string())?, @@ -312,7 +315,7 @@ fn sync_status_name(status: SyncStatus) -> &'static str { SyncStatus::Connecting => "connecting", SyncStatus::Connected(_) => "connected", SyncStatus::PortalUp(_) => "portal-up", - SyncStatus::Serving(_) => "serving", + SyncStatus::Serving(..) => "serving", SyncStatus::CredentialsSaved => "credentials-saved", SyncStatus::Error(_) => "error", } diff --git a/tools/web-emulator/src/lib.rs b/tools/web-emulator/src/lib.rs index 4eca53e9..2457aefc 100644 --- a/tools/web-emulator/src/lib.rs +++ b/tools/web-emulator/src/lib.rs @@ -191,7 +191,10 @@ impl WebEmulator { self.ops .push((now + 1600.0, Op::Sync(SyncEvent::Connected([192, 168, 1, 27])))); self.ops - .push((now + 2600.0, Op::Sync(SyncEvent::Serving([192, 168, 1, 27])))); + .push((now + 2600.0, Op::Sync(SyncEvent::Serving( + [192, 168, 1, 27], + app_core::UploadToken::EMULATOR_DEMO, + )))); } else { // No saved network: the onboarding hotspot comes up (with // the fixed demo PSK in place of the per-session one the diff --git a/ui/src/app_render.rs b/ui/src/app_render.rs index 82d3aa40..4314f03a 100644 --- a/ui/src/app_render.rs +++ b/ui/src/app_render.rs @@ -108,7 +108,7 @@ fn ui_sync_status(status: SyncStatus) -> UiSyncStatus { SyncStatus::Connecting => UiSyncStatus::Connecting, SyncStatus::Connected(ip) => UiSyncStatus::Connected(ip), SyncStatus::PortalUp(psk) => UiSyncStatus::PortalUp(psk), - SyncStatus::Serving(ip) => UiSyncStatus::Serving(ip), + SyncStatus::Serving(ip, token) => UiSyncStatus::Serving(ip, token), SyncStatus::CredentialsSaved => UiSyncStatus::CredentialsSaved, SyncStatus::Error(error) => UiSyncStatus::Error(sync_error_label(error)), } diff --git a/ui/src/join_qr.rs b/ui/src/join_qr.rs index 765ef242..0ca6b9f5 100644 --- a/ui/src/join_qr.rs +++ b/ui/src/join_qr.rs @@ -43,10 +43,35 @@ pub fn encode<'a>( psk: &str, temp: &mut [u8; BUFFER_LEN], out: &'a mut [u8; BUFFER_LEN], +) -> Option> { + encode_parts(&["WIFI:T:WPA;S:", PORTAL_SSID, ";P:", psk, ";;"], temp, out) +} + +/// Encodes the serving session's browser address — +/// `http:///` — the same way. The address is what carries the +/// upload token, so the QR is how a phone gets it without anyone typing +/// it; the printed line under it is the fallback. +/// +/// At most 15 bytes of dotted quad plus a 6-byte token and 8 of +/// scaffolding is 29 bytes, comfortably inside version 3's 42 at EC M — +/// so this never approaches [`MAX_VERSION`]. +pub fn encode_upload_url<'a>( + ip: &str, + token: &str, + temp: &mut [u8; BUFFER_LEN], + out: &'a mut [u8; BUFFER_LEN], +) -> Option> { + encode_parts(&["http://", ip, "/", token], temp, out) +} + +fn encode_parts<'a>( + parts: &[&str], + temp: &mut [u8; BUFFER_LEN], + out: &'a mut [u8; BUFFER_LEN], ) -> Option> { let mut payload = [0u8; 64]; let mut len = 0; - for part in ["WIFI:T:WPA;S:", PORTAL_SSID, ";P:", psk, ";;"] { + for part in parts { let bytes = part.as_bytes(); if len + bytes.len() > payload.len() { return None; diff --git a/ui/src/lib.rs b/ui/src/lib.rs index 410b051f..0cd36a15 100644 --- a/ui/src/lib.rs +++ b/ui/src/lib.rs @@ -52,7 +52,7 @@ pub enum UiSyncStatus { /// rather than raw bytes so its redacted `Debug` keeps the live /// password out of any formatted UI state. PortalUp(PortalPsk), - Serving([u8; 4]), + Serving([u8; 4], app_core::UploadToken), CredentialsSaved, Error(&'static str), } diff --git a/ui/src/render.rs b/ui/src/render.rs index 37ba289b..d9cbb278 100644 --- a/ui/src/render.rs +++ b/ui/src/render.rs @@ -739,7 +739,7 @@ fn render_wireless(fb: &mut Framebuffer, shell: &UiShell<'_>) { UiSyncStatus::NotConfigured => dash_key(fb, layout, 1, "set up", true), UiSyncStatus::ForgetPending => dash_key(fb, layout, 1, "forget", true), UiSyncStatus::Error(_) => dash_key(fb, layout, 1, "again", true), - UiSyncStatus::CredentialsSaved | UiSyncStatus::Serving(_) => { + UiSyncStatus::CredentialsSaved | UiSyncStatus::Serving(..) => { dash_key(fb, layout, 1, "done", true) } _ => dash_unused(fb, layout, 1), @@ -814,7 +814,7 @@ fn render_wireless(fb: &mut Framebuffer, shell: &UiShell<'_>) { let mut temp = [0u8; join_qr::BUFFER_LEN]; let mut out = [0u8; join_qr::BUFFER_LEN]; // 140 + 33 modules * 5 px + the 20 px quiet zone ends at - // y 325; the first caption baseline at 352 keeps its + // y 325; the first caption baseline at 356 keeps its // ascenders out of the cleared band. if let Some(qr) = join_qr::encode(psk_text, &mut temp, &mut out) { draw_qr(fb, &qr, layout.heading_cx, 140, 5); @@ -826,10 +826,10 @@ fn render_wireless(fb: &mut Framebuffer, shell: &UiShell<'_>) { push_str(&mut buf, &mut cursor, "\u{201d}"); draw_text_centered( fb, - literata_small(FontStyle::Regular), + literata(FontStyle::Regular), text_in(&buf, cursor), layout.heading_cx, - 352, + 356, ); let mut buf = [0u8; 32]; let mut cursor = 0; @@ -837,26 +837,59 @@ fn render_wireless(fb: &mut Framebuffer, shell: &UiShell<'_>) { push_str(&mut buf, &mut cursor, psk_text); draw_text_centered( fb, - literata_small(FontStyle::Regular), + literata(FontStyle::Regular), text_in(&buf, cursor), layout.heading_cx, - 384, + 392, ); draw_text_centered( fb, literata_small(FontStyle::Italic), "then enter your wi-fi in the page that opens \u{00b7} http://192.168.4.1", layout.heading_cx, - 416, + 424, ); } - UiSyncStatus::Serving(ip) => { - let mut buf = [0u8; 56]; + UiSyncStatus::Serving(ip, token) => { + // The address carries the session's upload token, which is what + // keeps the rest of the LAN out of /upload and /delete. The QR + // is how a phone gets it without anyone reading six characters + // off a screen; the printed line under it is the fallback, and + // is why the token alphabet excludes look-alike glyphs. + let mut address = [0u8; 22]; + let mut address_len = 0; + push_ipv4(&mut address, &mut address_len, ip); + let address = text_in(&address, address_len); + + let mut temp = [0u8; join_qr::BUFFER_LEN]; + let mut out = [0u8; join_qr::BUFFER_LEN]; + // Same geometry as the portal QR above: 33 modules at 5 px from + // y 140 plus the quiet zone ends clear of the caption baseline. + if let Some(qr) = + join_qr::encode_upload_url(address, token.as_str(), &mut temp, &mut out) + { + draw_qr(fb, &qr, layout.heading_cx, 140, 5); + } + let mut buf = [0u8; 64]; let mut cursor = 0; - push_str(&mut buf, &mut cursor, "visit "); - push_ipv4(&mut buf, &mut cursor, ip); - push_str(&mut buf, &mut cursor, " to add and remove books"); - centered_note(fb, layout, text_in(&buf, cursor)); + push_str(&mut buf, &mut cursor, "http://"); + push_str(&mut buf, &mut cursor, address); + push_str(&mut buf, &mut cursor, "/"); + push_str(&mut buf, &mut cursor, token.as_str()); + draw_text_centered( + fb, + literata(FontStyle::Regular), + text_in(&buf, cursor), + layout.heading_cx, + 356, + ); + draw_text_centered( + fb, + literata_small(FontStyle::Italic), + "scan or type this to add and remove books", + layout.heading_cx, + 392, + ); } UiSyncStatus::CredentialsSaved => { centered_note(fb, layout, "wi-fi saved");