Skip to main content

sioul_sync/
loopback.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! The door a browser comes back to after a sign-in on a provider's page, as
5//! native apps should have it (OAuth 2.0 for native apps, RFC 8252 §7.3): a
6//! port of this device's loopback address, chosen by the system, open for one
7//! sign-in. Google's sign-in (`google.rs`) and Microsoft's (`microsoft.rs`)
8//! share it; each provider says what an answer means (`Verdict`) and which
9//! page the browser then shows, in your language.
10//!
11//! What counts is the request line, `GET /?state=…&code=… HTTP/1.1`; the
12//! request is read whole, its headers too, at most `LINE_BYTES` of it,
13//! within `LINE_TIME` of the connection, Cancel heard meanwhile: a
14//! connection that says nothing, or says too much, is dropped, and cannot
15//! hold the door past Cancel or the code's minute (the review of 15 October
16//! 2026, L1). Read whole, so that closing it after the page is an orderly
17//! end: unread headers would make it a reset, and the browser's tab might
18//! say "connection reset" (the re-check, L1-bis). A request without a query
19//! (a favicon) gets a 404 and the wait goes on. The state is compared in constant time.
20//! An answer that waits in the system's queue is read even once the time is
21//! up (a phone freezes Sioul while the browser is in front, and its clock
22//! then jumps), for `LATE_CONNECTIONS` connections at most.
23//!
24//! Also here, what both providers' requests need: PKCE's verifier and
25//! challenge (RFC 7636), a random state, and the form encoding of a query.
26
27use crate::SyncError;
28use base64::Engine;
29use base64::engine::general_purpose::URL_SAFE_NO_PAD;
30use sha2::{Digest, Sha256};
31use std::collections::HashMap;
32use std::io::{Read, Write};
33use std::net::{TcpListener, TcpStream};
34use std::sync::atomic::{AtomicBool, Ordering};
35use std::time::{Duration, Instant};
36
37/// Opens the door: `127.0.0.1` on a port the system chooses; else, when
38/// `ipv6` allows it, `[::1]` (Google takes either; Microsoft takes no IPv6
39/// loopback). The listener, and the redirect address the provider is given
40/// (no path: `http://127.0.0.1:<port>`).
41pub(crate) fn open(ipv6: bool) -> Result<(TcpListener, String), SyncError> {
42    let bound = TcpListener::bind("127.0.0.1:0");
43    let listener = match bound {
44        Ok(listener) => listener,
45        Err(_) if ipv6 => TcpListener::bind("[::1]:0").map_err(|e| SyncError::Network(e.to_string()))?,
46        Err(e) => return Err(SyncError::Network(e.to_string())),
47    };
48    let address = listener.local_addr().map_err(|e| SyncError::Network(e.to_string()))?;
49    let redirect = if address.is_ipv6() { format!("http://[::1]:{}", address.port()) } else { format!("http://127.0.0.1:{}", address.port()) };
50    Ok((listener, redirect))
51}
52
53/// Random bytes (version 4 UUIDs' randomness), as URL-safe base64 without padding.
54pub(crate) fn random(bytes: usize) -> String {
55    let mut out = Vec::with_capacity(bytes);
56    while out.len() < bytes {
57        out.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
58    }
59    out.truncate(bytes);
60    URL_SAFE_NO_PAD.encode(out)
61}
62
63/// PKCE's challenge for a verifier: SHA-256, as URL-safe base64 without padding (S256).
64pub(crate) fn challenge(verifier: &str) -> String {
65    URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
66}
67
68/// `key=value&…`, each value percent-encoded.
69pub(crate) fn query(pairs: &[(&str, &str)]) -> String {
70    pairs.iter().map(|(k, v)| format!("{k}={}", encode(v))).collect::<Vec<_>>().join("&")
71}
72
73/// Percent-encodes a value: everything but RFC 3986's unreserved characters.
74pub(crate) fn encode(text: &str) -> String {
75    text.bytes().map(|b| if b.is_ascii_alphanumeric() || b"-_.~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }).collect()
76}
77
78/// A query's value as it was before being encoded (`+` a space).
79pub(crate) fn decode(text: &str) -> String {
80    let bytes = text.as_bytes();
81    let mut out = Vec::with_capacity(bytes.len());
82    let mut i = 0;
83    while i < bytes.len() {
84        match bytes[i] {
85            b'%' if i + 2 < bytes.len() => {
86                let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).ok().and_then(|h| u8::from_str_radix(h, 16).ok());
87                match hex {
88                    Some(b) => {
89                        out.push(b);
90                        i += 3;
91                    }
92                    None => {
93                        out.push(b'%');
94                        i += 1;
95                    }
96                }
97            }
98            b'+' => {
99                out.push(b' ');
100                i += 1;
101            }
102            b => {
103                out.push(b);
104                i += 1;
105            }
106        }
107    }
108    String::from_utf8_lossy(&out).to_string()
109}
110
111/// One answer at the door: its query's values, and whether its `state` is
112/// the sign-in's (compared in constant time; false when it has none).
113pub(crate) struct Answer {
114    pub params: HashMap<String, String>,
115    pub state_ok: bool,
116}
117
118impl Answer {
119    pub fn get(&self, key: &str) -> Option<&str> {
120        self.params.get(key).map(String::as_str)
121    }
122}
123
124/// What a provider makes of an answer: the page the browser shows (a
125/// message id), and either the wait's outcome or nothing (the wait goes on).
126pub(crate) enum Verdict {
127    /// This sign-in's answer, or one that ends it: the wait is over.
128    Done(&'static str, Result<String, SyncError>),
129    /// Not this sign-in's: said in the browser, and the wait goes on.
130    Ignored(&'static str),
131}
132
133/// How a wait ends without an answer: Cancel pressed, or the time up.
134pub(crate) struct Ends {
135    pub cancelled: SyncError,
136    pub timed_out: SyncError,
137}
138
139/// The most a request may hold, its line and headers: a browser's answer,
140/// its code and state included, and its headers (cookies for 127.0.0.1
141/// among them) are a few kilobytes at most.
142const LINE_BYTES: usize = 16 * 1024;
143/// The most time a connection has to say its request: a browser says it at once.
144const LINE_TIME: Duration = Duration::from_secs(2);
145/// Connections still read once the time is up, as long as they wait in the
146/// queue (the browser's answer among them, after a phone's freeze); past
147/// them the wait ends, however many more keep coming.
148const LATE_CONNECTIONS: usize = 16;
149
150/// A connection's request, read whole (its line, then its headers up to the
151/// blank line that ends them) within `LINE_TIME` and `LINE_BYTES`, Cancel
152/// heard between reads: its request line. None when it does not come whole
153/// in time, or when Cancel is pressed meanwhile. A request that ends with
154/// the connection's own end after its line (a tool, not a browser) counts whole.
155fn request_line(stream: &TcpStream, stop: &AtomicBool) -> Option<String> {
156    let started = Instant::now();
157    let mut head: Vec<u8> = Vec::with_capacity(4096);
158    let mut chunk = [0u8; 4096];
159    let line = |head: &[u8]| head.iter().position(|&b| b == b'\n').map(|end| String::from_utf8_lossy(&head[..end]).trim_end_matches('\r').to_string());
160    loop {
161        if stop.load(Ordering::Relaxed) {
162            return None;
163        }
164        let left = LINE_TIME.checked_sub(started.elapsed())?;
165        // Each read waits at most a fifth of a second, so that Cancel is heard.
166        stream.set_read_timeout(Some(left.clamp(Duration::from_millis(1), Duration::from_millis(200)))).ok()?;
167        match (&*stream).read(&mut chunk) {
168            Ok(0) => return line(&head),
169            Ok(read) => {
170                // From a little before what came, for a blank line split between two reads.
171                let from = head.len().saturating_sub(3);
172                head.extend_from_slice(&chunk[..read]);
173                let tail = &head[from..];
174                if tail.windows(4).any(|w| w == b"\r\n\r\n") || tail.windows(2).any(|w| w == b"\n\n") {
175                    return line(&head);
176                }
177                if head.len() > LINE_BYTES {
178                    return None;
179                }
180            }
181            Err(e) if matches!(e.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut | std::io::ErrorKind::Interrupted) => continue,
182            Err(_) => return None,
183        }
184    }
185}
186
187/// Waits at the door for the provider's answer, at most `deadline`, or until
188/// `stop` (Cancel): `judge` says what each answer means and which page the
189/// browser shows. The code, else why there is none.
190pub(crate) fn wait(listener: &TcpListener, state: &str, deadline: Duration, stop: &AtomicBool, ends: Ends, judge: impl Fn(&Answer) -> Verdict) -> Result<String, SyncError> {
191    listener.set_nonblocking(true).map_err(|e| SyncError::Network(e.to_string()))?;
192    let until = Instant::now() + deadline;
193    let mut late = 0;
194    loop {
195        if stop.load(Ordering::Relaxed) {
196            return Err(ends.cancelled);
197        }
198        // An answer waiting is read before the time is called: a phone
199        // freezes Sioul while the browser is in front (its clock then jumps),
200        // and the browser's request waits meanwhile in the system's queue.
201        let (mut stream, _) = match listener.accept() {
202            Ok(accepted) => accepted,
203            Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => {
204                if Instant::now() > until {
205                    return Err(ends.timed_out);
206                }
207                std::thread::sleep(Duration::from_millis(200));
208                continue;
209            }
210            Err(e) => return Err(SyncError::Network(e.to_string())),
211        };
212        // Past the time, only so many: a stream of strangers cannot keep the wait going.
213        if Instant::now() > until {
214            late += 1;
215            if late > LATE_CONNECTIONS {
216                return Err(ends.timed_out);
217            }
218        }
219        let _ = stream.set_nonblocking(false);
220        let _ = stream.set_write_timeout(Some(LINE_TIME));
221        let Some(line) = request_line(&stream, stop) else { continue };
222        // "GET /?state=…&code=…&scope=… HTTP/1.1"; anything else (a favicon) is not it.
223        let target = line.split_whitespace().nth(1).unwrap_or("");
224        let Some(params) = target.strip_prefix("/?") else {
225            let _ = stream.write_all(b"HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
226            continue;
227        };
228        let params: HashMap<String, String> = params.split('&').filter_map(|p| p.split_once('=')).map(|(k, v)| (k.to_string(), decode(v))).collect();
229        let state_ok = params.get("state").is_some_and(|said| subtle::ConstantTimeEq::ct_eq(said.as_bytes(), state.as_bytes()).into());
230        let (said, outcome) = match judge(&Answer { params, state_ok }) {
231            Verdict::Done(said, outcome) => (said, Some(outcome)),
232            Verdict::Ignored(said) => (said, None),
233        };
234        // The page your browser shows, in your language.
235        let body = crate::translator().text(said, None).replace('&', "&amp;").replace('<', "&lt;");
236        let page = format!("<!doctype html><meta charset=utf-8><title>Sioul</title><p style=\"font:1.2em sans-serif;margin:3em\">{body}</p>");
237        let _ = stream.write_all(format!("HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{page}", page.len()).as_bytes());
238        if let Some(outcome) = outcome {
239            return outcome;
240        }
241    }
242}
243
244/// A browser coming back to the door at `port`: `GET <target>` with the
245/// headers a browser sends (Chrome's), a cookie making the request `size`
246/// bytes long when it is shorter; what the door answered, read to its end,
247/// or the error that ended it (a reset). For the tests of both providers.
248#[cfg(test)]
249pub(crate) fn browser_answer(port: u16, target: &str, size: usize) -> std::io::Result<String> {
250    let head = format!(
251        "GET {target} HTTP/1.1\r\nHost: 127.0.0.1:{port}\r\nConnection: keep-alive\r\nUpgrade-Insecure-Requests: 1\r\nUser-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7\r\nSec-Fetch-Site: cross-site\r\nSec-Fetch-Mode: navigate\r\nSec-Fetch-Dest: document\r\nsec-ch-ua: \"Chromium\";v=\"141\", \"Not?A_Brand\";v=\"8\"\r\nsec-ch-ua-mobile: ?0\r\nsec-ch-ua-platform: \"Linux\"\r\nAccept-Encoding: gzip, deflate, br, zstd\r\nAccept-Language: fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7\r\n"
252    );
253    let cookie = |pad: usize| format!("Cookie: pad={}\r\n", "c".repeat(pad));
254    let pad = size.saturating_sub(head.len() + cookie(0).len() + 2);
255    let request = format!("{head}{}\r\n", if pad > 0 { cookie(pad) } else { String::new() });
256    let mut stream = std::net::TcpStream::connect(("127.0.0.1", port))?;
257    stream.set_read_timeout(Some(Duration::from_secs(10)))?;
258    stream.write_all(request.as_bytes())?;
259    let mut answer = Vec::new();
260    stream.read_to_end(&mut answer)?;
261    Ok(String::from_utf8_lossy(&answer).to_string())
262}
263
264#[cfg(test)]
265mod tests {
266    use super::*;
267
268    /// The re-check of 15 October 2026, L1-bis: the browser's whole request,
269    /// its headers included (800 to 3,000 bytes, a cookie or two), is read
270    /// before the door answers and closes, so the browser gets its page and
271    /// an orderly end, never a reset ("connection reset" in its tab).
272    #[test]
273    fn a_browser_gets_its_page_and_an_orderly_end() {
274        let judge = |answer: &Answer| match (answer.state_ok, answer.get("code")) {
275            (true, Some(code)) => Verdict::Done("google-page-granted", Ok(code.to_string())),
276            _ => Verdict::Ignored("google-page-foreign"),
277        };
278        for size in (800..=3000).step_by(110) {
279            let (listener, _) = open(false).unwrap();
280            let port = listener.local_addr().unwrap().port();
281            let browser = std::thread::spawn(move || browser_answer(port, "/?state=the-state&code=the-code&scope=openid", size));
282            let ends = Ends { cancelled: SyncError::Message("cancelled".into()), timed_out: SyncError::Message("timed out".into()) };
283            assert_eq!(wait(&listener, "the-state", Duration::from_secs(10), &AtomicBool::new(false), ends, judge).unwrap(), "the-code", "{size} bytes");
284            let answer = browser.join().unwrap();
285            assert!(answer.as_ref().is_ok_and(|page| page.starts_with("HTTP/1.1 200") && page.contains("</p>")), "{size} bytes: {answer:?}");
286        }
287    }
288
289    #[test]
290    fn values_go_and_come_back_whole() {
291        assert_eq!(encode("a b/c@d.example"), "a%20b%2Fc%40d.example");
292        assert_eq!(decode("a%20b+c%2Fd%zz"), "a b c/d%zz");
293        assert_eq!(query(&[("a", "1 2"), ("b", "x&y")]), "a=1%202&b=x%26y");
294        // RFC 7636: 43 characters at least for the verifier; its challenge is SHA-256's.
295        let verifier = random(48);
296        assert_eq!(verifier.len(), 64);
297        assert_eq!(challenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"), "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", "RFC 7636's example, appendix B");
298    }
299
300    /// The review of 15 October 2026, L1: a stranger who connects first and
301    /// dribbles, or sends a line without end, holds the door two seconds at
302    /// most, and Cancel is heard meanwhile; the browser's answer, behind it,
303    /// is read; a stream of strangers past the time does not keep the wait going.
304    #[test]
305    fn a_slow_or_endless_stranger_does_not_hold_the_door() {
306        let judge = |answer: &Answer| match (answer.state_ok, answer.get("code")) {
307            (true, Some(code)) => Verdict::Done("google-page-granted", Ok(code.to_string())),
308            _ => Verdict::Ignored("google-page-foreign"),
309        };
310        let ends = || Ends { cancelled: SyncError::Message("cancelled".into()), timed_out: SyncError::Message("timed out".into()) };
311        // A stranger dribbling a byte every 300 ms, never ending its line; Cancel at half a second.
312        let (listener, _) = open(false).unwrap();
313        let port = listener.local_addr().unwrap().port();
314        let dribbler = std::thread::spawn(move || {
315            let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
316            for byte in b"GET /?state=".iter().cycle().take(40) {
317                if stream.write_all(&[*byte]).is_err() {
318                    break;
319                }
320                std::thread::sleep(Duration::from_millis(300));
321            }
322        });
323        let stop = std::sync::Arc::new(AtomicBool::new(false));
324        let stopper = {
325            let stop = std::sync::Arc::clone(&stop);
326            std::thread::spawn(move || {
327                std::thread::sleep(Duration::from_millis(500));
328                stop.store(true, Ordering::Relaxed);
329            })
330        };
331        let started = Instant::now();
332        assert_eq!(wait(&listener, "the-state", Duration::from_secs(60), &stop, ends(), judge).err(), Some(SyncError::Message("cancelled".into())));
333        assert!(started.elapsed() < Duration::from_millis(1500), "Cancel heard within a fifth of a second: {:?}", started.elapsed());
334        stopper.join().unwrap();
335        // A stranger sending a line without end (64 KB), then the browser's answer behind it.
336        let (listener, _) = open(false).unwrap();
337        let port = listener.local_addr().unwrap().port();
338        let endless = std::thread::spawn(move || {
339            let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
340            let _ = stream.write_all(&[b'A'; 64 * 1024]);
341            let mut browser = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
342            browser.write_all(b"GET /?state=the-state&code=the-code HTTP/1.1\r\n\r\n").unwrap();
343            let mut answer = String::new();
344            let _ = browser.read_to_string(&mut answer);
345            answer
346        });
347        let started = Instant::now();
348        assert_eq!(wait(&listener, "the-state", Duration::from_secs(60), &AtomicBool::new(false), ends(), judge).unwrap(), "the-code");
349        assert!(started.elapsed() < Duration::from_secs(3), "{:?}", started.elapsed());
350        assert!(endless.join().unwrap().starts_with("HTTP/1.1 200"));
351        dribbler.join().unwrap();
352        // Past the time, twenty strangers waiting: sixteen are read, then the wait ends.
353        let (listener, _) = open(false).unwrap();
354        let port = listener.local_addr().unwrap().port();
355        let strangers: Vec<std::net::TcpStream> = (0..20)
356            .map(|_| {
357                let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
358                stream.write_all(b"GET /?state=another HTTP/1.1\r\n\r\n").unwrap();
359                stream
360            })
361            .collect();
362        let heard = std::sync::atomic::AtomicUsize::new(0);
363        let counting = |answer: &Answer| {
364            heard.fetch_add(1, Ordering::Relaxed);
365            judge(answer)
366        };
367        assert_eq!(wait(&listener, "the-state", Duration::ZERO, &AtomicBool::new(false), ends(), counting).err(), Some(SyncError::Message("timed out".into())));
368        assert_eq!(heard.load(Ordering::Relaxed), LATE_CONNECTIONS);
369        drop(strangers);
370    }
371
372    /// Microsoft takes no IPv6 loopback: without the fallback, the door is
373    /// 127.0.0.1's or none.
374    #[test]
375    fn the_door_is_on_the_loopback_address() {
376        let (listener, redirect) = open(false).unwrap();
377        assert!(listener.local_addr().unwrap().ip().is_loopback());
378        assert_eq!(redirect, format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()));
379    }
380}