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 644eaf70..3f5f8985 100644 Binary files a/fixtures/golden/sync-flow-x3.png and b/fixtures/golden/sync-flow-x3.png differ diff --git a/fixtures/golden/sync-flow.png b/fixtures/golden/sync-flow.png index 134bd3a9..b25bb975 100644 Binary files a/fixtures/golden/sync-flow.png and b/fixtures/golden/sync-flow.png differ diff --git a/fixtures/golden/sync-portal-qr-x3.png b/fixtures/golden/sync-portal-qr-x3.png index fdffd455..7782a0fb 100644 Binary files a/fixtures/golden/sync-portal-qr-x3.png and b/fixtures/golden/sync-portal-qr-x3.png differ diff --git a/fixtures/golden/sync-portal-qr.png b/fixtures/golden/sync-portal-qr.png index 0ac38c4d..69941df7 100644 Binary files a/fixtures/golden/sync-portal-qr.png and b/fixtures/golden/sync-portal-qr.png differ 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");