git.lucas.co / cce-remote
remote trackpad and keyboard server
git clone https://git.lucas.co/cce-remote.git

commit0155d7b17aef743e92ffacd3eef0e2858ee41061
parent7ff9af79c3
authorLucas Galante <[email protected]>
date2026-08-22 13:20
feat: rate-limit PIN attempts per source address

A 6-digit PIN is ~20 bits compared with ==, and nothing throttled guesses, so
an unattended socket on the LAN could walk the whole space in a couple of
hours. The comparison was never what made it a credential; this is.

RateLimiter is a per-source-IP token bucket over FAILED attempts: five
back-to-back, then one recovered every 30s, which caps sustained guessing at
~2/min and turns those hours into the order of a year. All three gates consult
it (WS auth frame, /shot header, /stream header-or-query), and a peer whose
address cannot be resolved is refused rather than exempted — a socket that
cannot be held accountable for its guesses does not get to make them.

Three properties it has to keep, each pinned by a test:

- Only failures are charged and a success clears the record. The page
  reconnects its stream on every hiccup, each time presenting a correct PIN; if
  that consumed budget a working client would throttle itself off.
- Refill caps at the burst, or an idle attacker banks attempts and the limit is
  only an average. The test asserts this on refilled() DIRECTLY: routed through
  the public API it passes against a build with the cap removed, because
  record_failure prunes recovered peers and re-creates them at full. It was
  written the weak way first and caught by mutation.
- The table cannot grow without bound, or the limiter is its own
  memory-exhaustion vector. Recovered peers are pruned on write, with a hard
  cap behind that which evicts whoever is closest to recovered.

A rate-limited WS connection is closed WITHOUT "auth fail": that message makes
the page discard its stored PIN and prompt, so sending it would punish a
correctly-paired client for someone else's guessing from the same address. It
reconnects on close and succeeds once the bucket refills. HTTP answers 429 with
Retry-After.

Verified live: five wrong PINs give 403, the sixth 429; the CORRECT PIN is then
429 too from that address, which is what proves it is the limiter and not the
credential; the unauthenticated page keeps serving 200; and ~48s later /shot
and /stream take the correct PIN again while a wrong one is back to 403.

Co-Authored-By: Claude Opus 5 <[email protected]>

 CLAUDE.md   |  42 +++++++++--
 README.md   |  10 ++-
 src/main.rs | 243 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 3 files changed, 280 insertions(+), 15 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index f530b67..9460b48 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -71,13 +71,41 @@ regenerates on an empty file so it should be unreachable, but that is a property
 *different* function, and if it lapsed, a bare `X-Pin:` header would authenticate
 everything. Gate on the dangerous state, don't trust the caller.
 
-Be honest about the resulting model rather than treating the PIN as security: it is
-**~20 bits, compared with `==` (not constant-time), over plain HTTP on 0.0.0.0**, cached
-in `localStorage`, and for `/stream` it travels in a URL — where it lands in any proxy
-or browser history that sees it. Nothing rate-limits attempts. It is pairing, i.e. it
-stops the other devices on a trusted LAN from steering the desktop by accident. It is
-not a defense against someone who is on that network on purpose. For a hostile network
-the answer is a tunnel, not a longer PIN.
+### The rate limiter is what makes 20 bits a credential
+
+A 6-digit PIN is ~20 bits compared with `==`. What keeps that from being walked in an
+afternoon is not the comparison, it is `RateLimiter`: a **per-source-IP token bucket
+over failed attempts**, 5 back-to-back then one recovered per 30s. That caps sustained
+guessing at ~2/min, which turns a couple of hours into the order of a year. All three
+gates consult it, and an unresolvable peer address is refused rather than exempted.
+
+Three properties it must keep, each with a test:
+
+- **Only failures are charged, and a success clears the record.** The page reconnects
+  its stream on every hiccup — an `error` event, the 20s watchdog — each time presenting
+  a correct PIN. If those consumed budget, a working client would throttle itself off.
+- **Refill caps at the burst.** Otherwise an idle attacker banks attempts and the limit
+  is only an average. Note the test asserts this on `refilled()` *directly*: going
+  through the public API hides a missing cap, because `record_failure` prunes recovered
+  peers and re-creates them at full.
+- **The table cannot grow without bound**, or the limiter becomes its own
+  memory-exhaustion vector. Recovered peers are pruned on write (a full bucket is
+  indistinguishable from an absent one) with a hard cap behind that, evicting whoever is
+  closest to recovered.
+
+On the WS side, a rate-limited connection is closed **without** sending `auth fail` —
+that message makes the page discard its stored PIN and prompt, so sending it would
+punish a correctly-paired client for someone else's guessing from the same address. The
+page reconnects on close and succeeds once the bucket refills. HTTP answers `429` with
+`Retry-After`.
+
+Be honest about what is left rather than treating the PIN as security: it still travels
+over **plain HTTP on 0.0.0.0**, is compared non-constant-time, is cached in
+`localStorage`, and for `/stream` it rides in a URL, where it lands in any proxy or
+history that sees it. Limiting is per-IP, so a peer with many addresses gets many
+budgets. It is pairing — it stops other devices on a trusted LAN from steering the
+desktop by accident, and now also stops casual brute force. It is not a defense against
+someone who is on that network on purpose. For a hostile network the answer is a tunnel.
 
 ## Framing: the control socket is one-shot
 
diff --git a/README.md b/README.md
index e523356..3f5fc30 100644
--- a/README.md
+++ b/README.md
@@ -45,5 +45,11 @@ Pairing PIN: a persistent 6-digit PIN is generated on first run (printed at
 startup, stored 0600 in `~/.config/cce/cce-remote.pin`). The page asks for
 it once per device and remembers it (localStorage); the server closes any
 WebSocket whose first frame isn't `auth <pin>`, so no input can be injected
-without pairing. Delete the PIN file to rotate it. Traffic is plain HTTP on
-the LAN — for hostile networks, tunnel it.
+without pairing. Delete the PIN file to rotate it.
+
+Wrong PINs are rate-limited per source address — five in a row, then one more
+every 30 seconds (HTTP replies `429 Too Many Requests`) — so the 6-digit space
+can't be walked. Correct PINs cost nothing and a success clears the peer's
+record, so a phone reconnecting its stream is never throttled.
+
+Traffic is plain HTTP on the LAN — for hostile networks, tunnel it.
diff --git a/src/main.rs b/src/main.rs
index 7f056ac..fd88147 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -147,6 +147,91 @@ fn query_pin_ok(request_head: &str, pin: &str) -> bool {
         })
 }
 
+/// Failed PIN attempts allowed back-to-back from one peer before it must wait.
+/// Sized for a human mistyping a PIN, not for a client retry loop.
+const PIN_ATTEMPT_BURST: f64 = 5.0;
+/// Sustained rate a peer recovers attempts at: one per 30s. That caps a
+/// brute-force at ~2/min, so walking a 6-digit space takes on the order of a
+/// year rather than the couple of hours an unthrottled LAN socket allows.
+const PIN_ATTEMPT_REFILL_PER_SEC: f64 = 1.0 / 30.0;
+/// Backstop on the tracking table so the limiter cannot itself be turned into
+/// a memory-exhaustion vector by cycling source addresses.
+const PIN_MAX_TRACKED_PEERS: usize = 4096;
+
+#[derive(Clone, Copy, Debug)]
+struct Bucket {
+    tokens: f64,
+    last: std::time::Instant,
+}
+
+/// Per-peer token bucket over failed PIN attempts.
+///
+/// The PIN is ~20 bits and every gate compares it with `==`, so the thing that
+/// actually makes it a credential is that an attacker cannot try often. Only
+/// FAILURES consume tokens — a paired client reconnecting its stream, which
+/// the page does on every hiccup, must never be throttled — and a success
+/// clears the peer's record entirely.
+///
+/// `now` is a parameter rather than read inside, so the behavior is testable
+/// without sleeping.
+struct RateLimiter {
+    peers: std::sync::Mutex<std::collections::HashMap<std::net::IpAddr, Bucket>>,
+}
+
+impl RateLimiter {
+    fn new() -> Self {
+        Self { peers: std::sync::Mutex::new(std::collections::HashMap::new()) }
+    }
+
+    fn refilled(bucket: Bucket, now: std::time::Instant) -> Bucket {
+        let elapsed = now.saturating_duration_since(bucket.last).as_secs_f64();
+        Bucket {
+            tokens: (bucket.tokens + elapsed * PIN_ATTEMPT_REFILL_PER_SEC).min(PIN_ATTEMPT_BURST),
+            last: now,
+        }
+    }
+
+    /// May this peer attempt a PIN right now? Does not consume anything —
+    /// a correct PIN costs nothing.
+    fn allow(&self, ip: std::net::IpAddr, now: std::time::Instant) -> bool {
+        let peers = self.peers.lock().unwrap();
+        match peers.get(&ip) {
+            Some(&b) => Self::refilled(b, now).tokens >= 1.0,
+            None => true,
+        }
+    }
+
+    /// Charge this peer for a wrong PIN.
+    fn record_failure(&self, ip: std::net::IpAddr, now: std::time::Instant) {
+        let mut peers = self.peers.lock().unwrap();
+        // A fully refilled bucket is indistinguishable from an absent one, so
+        // dropping those keeps the table proportional to peers currently being
+        // penalized rather than to every peer ever seen.
+        peers.retain(|_, b| Self::refilled(*b, now).tokens < PIN_ATTEMPT_BURST);
+        if peers.len() >= PIN_MAX_TRACKED_PEERS && !peers.contains_key(&ip) {
+            // At capacity: evict whoever is closest to having recovered.
+            if let Some(&victim) = peers
+                .iter()
+                .max_by(|a, b| a.1.tokens.total_cmp(&b.1.tokens))
+                .map(|(k, _)| k)
+            {
+                peers.remove(&victim);
+            }
+        }
+        let entry = peers
+            .entry(ip)
+            .or_insert(Bucket { tokens: PIN_ATTEMPT_BURST, last: now });
+        let mut b = Self::refilled(*entry, now);
+        b.tokens = (b.tokens - 1.0).max(0.0);
+        *entry = b;
+    }
+
+    /// A correct PIN clears the peer's record.
+    fn record_success(&self, ip: std::net::IpAddr) {
+        self.peers.lock().unwrap().remove(&ip);
+    }
+}
+
 /// `f64::from_str` accepts "NaN" / "inf" / "infinity", and `{:.2}` formats them
 /// straight back out, so without this a frame of `m NaN NaN` would reach the
 /// compositor's pointer math verbatim. Reject rather than clamp: no legitimate
@@ -274,7 +359,7 @@ fn take_screenshot() -> Option<(Vec<u8>, (u64, f64, f64, f64, f64))> {
     Some((bytes?, win))
 }
 
-fn handle_ws(stream: TcpStream, pin: &str) {
+fn handle_ws(stream: TcpStream, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
     let peer = stream.peer_addr().map(|a| a.to_string()).unwrap_or_default();
     // Unauthenticated clients can hold the socket only briefly.
     let _ = stream.set_read_timeout(Some(Duration::from_secs(10)));
@@ -287,16 +372,27 @@ fn handle_ws(stream: TcpStream, pin: &str) {
     };
     // First frame MUST be `auth <pin>` — anything else (or a timeout, or a
     // wrong PIN) closes the connection before any input can be injected.
+    if !limiter.allow(ip, std::time::Instant::now()) {
+        // Close WITHOUT "auth fail": that message makes the page drop its
+        // stored PIN and prompt, so sending it here would punish a correctly
+        // paired client for someone else's guessing on the same address. It
+        // reconnects on close and succeeds once the bucket refills.
+        eprintln!("[cce-remote] auth rate-limited: {peer}");
+        let _ = ws.close(None);
+        return;
+    }
     let authed = matches!(
         ws.read(),
         Ok(tungstenite::Message::Text(t)) if auth_frame_ok(&t, pin)
     );
     if !authed {
+        limiter.record_failure(ip, std::time::Instant::now());
         eprintln!("[cce-remote] auth failed: {peer}");
         let _ = ws.send(tungstenite::Message::Text("auth fail".into()));
         let _ = ws.close(None);
         return;
     }
+    limiter.record_success(ip);
     let _ = ws.get_ref().set_read_timeout(None);
     let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
     println!("[cce-remote] client connected: {peer}");
@@ -342,14 +438,20 @@ fn handle_ws(stream: TcpStream, pin: &str) {
 /// full-size window — the screencopy dominates, not the encode. Runs until
 /// the client closes the socket. PIN via X-Pin header or ?pin= query (an
 /// <img src> can't carry headers).
-fn handle_stream(mut stream: TcpStream, request_head: &str, pin: &str) {
+fn handle_stream(mut stream: TcpStream, request_head: &str, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
+    if !limiter.allow(ip, std::time::Instant::now()) {
+        let _ = write!(stream, "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 30\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+        return;
+    }
     // Header OR query: an <img src> cannot carry a header, so /stream accepts
     // the PIN in the URL. /shot does not — see handle_http.
     let pin_ok = header_pin_ok(request_head, pin) || query_pin_ok(request_head, pin);
     if !pin_ok {
+        limiter.record_failure(ip, std::time::Instant::now());
         let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
         return;
     }
+    limiter.record_success(ip);
     if write!(
         stream,
         "HTTP/1.1 200 OK\r\nContent-Type: multipart/x-mixed-replace; boundary=frame\r\nCache-Control: no-store\r\nConnection: close\r\n\r\n"
@@ -408,20 +510,26 @@ fn handle_stream(mut stream: TcpStream, request_head: &str, pin: &str) {
     }
 }
 
-fn handle_http(mut stream: TcpStream, request_head: &str, pin: &str) {
+fn handle_http(mut stream: TcpStream, request_head: &str, pin: &str, ip: std::net::IpAddr, limiter: &RateLimiter) {
     if request_head.starts_with("GET /stream") {
-        handle_stream(stream, request_head, pin);
+        handle_stream(stream, request_head, pin, ip, limiter);
         return;
     }
     // /shot: the focused window's screenshot, PIN-gated via the X-Pin header
     // (the page fetch()es it — an <img src> couldn't carry a header).
     if request_head.starts_with("GET /shot") {
+        if !limiter.allow(ip, std::time::Instant::now()) {
+            let _ = write!(stream, "HTTP/1.1 429 Too Many Requests\r\nRetry-After: 30\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
+            return;
+        }
         // Header only: the page fetch()es this one, so unlike /stream there is
         // no reason to let the PIN travel in a URL (where it lands in logs).
         if !header_pin_ok(request_head, pin) {
+            limiter.record_failure(ip, std::time::Instant::now());
             let _ = write!(stream, "HTTP/1.1 403 Forbidden\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
             return;
         }
+        limiter.record_success(ip);
         match take_screenshot() {
             Some((bytes, (id, x, y, w, h))) => {
                 let _ = write!(
@@ -469,6 +577,7 @@ fn main() {
             std::process::exit(1);
         }
     };
+    let limiter = std::sync::Arc::new(RateLimiter::new());
     println!("[cce-remote] serving on http://0.0.0.0:{port} (control socket: {})", control_socket_path());
     println!("[cce-remote] pairing PIN: {pin}   (stored in {:?})", pin_path());
 
@@ -477,7 +586,13 @@ fn main() {
             Ok(s) => s,
             Err(_) => continue,
         };
+        // Identify the peer once, here: every PIN gate is rate-limited per
+        // source address, and a socket whose peer cannot be resolved cannot be
+        // held accountable for its guesses, so it is refused rather than
+        // exempted.
+        let Ok(ip) = stream.peer_addr().map(|a| a.ip()) else { continue };
         let pin = pin.clone();
+        let limiter = std::sync::Arc::clone(&limiter);
         std::thread::spawn(move || {
             // Peek the request head without consuming it, so a WS upgrade can
             // be handed to tungstenite with the handshake bytes intact.
@@ -488,13 +603,13 @@ fn main() {
             };
             let head = String::from_utf8_lossy(&buf[..n]).to_string();
             if head.starts_with("GET /ws") {
-                handle_ws(stream, &pin);
+                handle_ws(stream, &pin, ip, &limiter);
             } else {
                 // Consume the request before replying (keeps curl happy).
                 let mut sink = [0u8; 1024];
                 let mut s = stream;
                 let _ = s.read(&mut sink);
-                handle_http(s, &head, &pin);
+                handle_http(s, &head, &pin, ip, &limiter);
             }
         });
     }
@@ -518,6 +633,122 @@ mod tests {
         cmds.into_iter().next().unwrap()
     }
 
+    // ---- rate limiting ----
+    //
+    // What actually makes a 6-digit PIN a credential: not the comparison, but
+    // that a peer cannot try often. Time is injected, so none of this sleeps.
+
+    use std::net::{IpAddr, Ipv4Addr};
+    use std::time::Instant;
+
+    fn ip(last: u8) -> IpAddr {
+        IpAddr::V4(Ipv4Addr::new(192, 168, 1, last))
+    }
+
+    #[test]
+    fn a_peer_gets_a_burst_then_must_wait() {
+        let rl = RateLimiter::new();
+        let t0 = Instant::now();
+        let peer = ip(10);
+
+        for i in 0..PIN_ATTEMPT_BURST as u32 {
+            assert!(rl.allow(peer, t0), "attempt {i} should be allowed");
+            rl.record_failure(peer, t0);
+        }
+        assert!(!rl.allow(peer, t0), "the burst must be exhausted");
+
+        // Still locked out just short of the refill interval, allowed after it.
+        assert!(!rl.allow(peer, t0 + Duration::from_secs(29)));
+        assert!(rl.allow(peer, t0 + Duration::from_secs(31)));
+    }
+
+    #[test]
+    fn recovery_is_capped_at_the_burst_size() {
+        // Asserted on refilled() directly rather than through the public API:
+        // record_failure() prunes recovered peers and re-creates them at full,
+        // which masks a missing cap end-to-end. (It did — this test passed
+        // against a build with the .min() removed until it was written this
+        // way.) Idle time must not bank attempts.
+        let t0 = Instant::now();
+        let drained = Bucket { tokens: 0.0, last: t0 };
+        // Full recovery takes BURST * 30s = 150s; well past that, nothing accrues.
+        for idle in [300u64, 3600, 86_400] {
+            let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(idle));
+            assert_eq!(
+                b.tokens, PIN_ATTEMPT_BURST,
+                "{idle}s idle banked {} attempts", b.tokens
+            );
+        }
+        // Partial recovery is proportional, not all-or-nothing.
+        let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(60));
+        assert!(b.tokens > 1.0);
+        let b = RateLimiter::refilled(drained, t0 + Duration::from_secs(15));
+        assert!(b.tokens < 1.0, "half an interval must not buy a whole attempt");
+    }
+
+    #[test]
+    fn peers_are_limited_independently() {
+        let rl = RateLimiter::new();
+        let t0 = Instant::now();
+        let (attacker, phone) = (ip(20), ip(21));
+        for _ in 0..PIN_ATTEMPT_BURST as u32 {
+            rl.record_failure(attacker, t0);
+        }
+        assert!(!rl.allow(attacker, t0));
+        assert!(rl.allow(phone, t0), "one peer's guessing must not lock out another");
+    }
+
+    #[test]
+    fn a_correct_pin_costs_nothing_and_clears_the_record() {
+        let rl = RateLimiter::new();
+        let t0 = Instant::now();
+        let peer = ip(30);
+
+        // The page reconnects its stream on every hiccup, each time presenting
+        // a correct PIN. If that consumed budget it would throttle itself.
+        for _ in 0..1000 {
+            assert!(rl.allow(peer, t0));
+        }
+
+        for _ in 0..(PIN_ATTEMPT_BURST as u32 - 1) {
+            rl.record_failure(peer, t0);
+        }
+        rl.record_success(peer);
+        for _ in 0..PIN_ATTEMPT_BURST as u32 {
+            assert!(rl.allow(peer, t0), "success should restore the full burst");
+            rl.record_failure(peer, t0);
+        }
+    }
+
+    #[test]
+    fn the_tracking_table_does_not_grow_without_bound() {
+        let rl = RateLimiter::new();
+        let t0 = Instant::now();
+
+        // Recovered peers carry no information and must not be retained.
+        for i in 0..200u8 {
+            rl.record_failure(ip(i), t0);
+        }
+        assert!(rl.peers.lock().unwrap().len() > 1);
+        rl.record_failure(ip(255), t0 + Duration::from_secs(3600));
+        assert_eq!(
+            rl.peers.lock().unwrap().len(),
+            1,
+            "fully refilled peers should have been pruned"
+        );
+
+        // And the table is capped even when every entry is still penalized.
+        let mut rl2 = RateLimiter::new();
+        // One failure per peer is enough to create (and hold) an entry.
+        for i in 0..(PIN_MAX_TRACKED_PEERS + 50) {
+            rl2.record_failure(IpAddr::V4(Ipv4Addr::from((i as u32).to_be_bytes())), t0);
+        }
+        assert!(
+            rl2.peers.get_mut().unwrap().len() <= PIN_MAX_TRACKED_PEERS,
+            "table exceeded its cap"
+        );
+    }
+
     // ---- the pairing PIN ----
     //
     // The other half of the security model: translate() decides what a paired