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

commit7311383983a066e8a8cfa0e7768223b439e75293
parent66befd2423
authorLucas Galante <[email protected]>
date2026-07-21 19:46
fix iOS backspace; add pairing-PIN auth

Backspace: iOS never fires deleteContentBackward on an empty field — the
hidden textarea now keeps sentinel padding (cursor at end, re-armed on
focus and drift) so delete events always fire; deleteWordBackward maps
to backspace too.

Auth: a persistent 6-digit PIN (generated on first run, 0600 at
~/.config/cce/cce-remote.pin, printed at startup) must arrive as the
first WS frame (auth <pin>) within 10s; anything else closes the
connection before any input can be injected. The page prompts once per
device and remembers the PIN in localStorage, re-prompting on failure.

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

 README.md   |  9 +++++---
 index.html  | 43 +++++++++++++++++++++++++++-------
 src/main.rs | 77 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
 3 files changed, 114 insertions(+), 15 deletions(-)

diff --git a/README.md b/README.md
index 3ef6c32..8f37634 100644
--- a/README.md
+++ b/README.md
@@ -32,6 +32,9 @@ Home Screen for a fullscreen app feel.
 
 ## Security
 
-LAN-trust only: anyone who can reach the port controls the desktop. Run it
-on a trusted network, or bind it behind a firewall / tunnel. There is no
-authentication in v1.
+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.
diff --git a/index.html b/index.html
index 40ce3c4..acbd0e1 100644
--- a/index.html
+++ b/index.html
@@ -53,16 +53,35 @@
 <textarea id="kb" autocapitalize="off" autocomplete="off" autocorrect="off" spellcheck="false"></textarea>
 <script>
 "use strict";
-// ── WebSocket ────────────────────────────────────────────────────────
-let ws = null;
+// ── WebSocket + pairing ──────────────────────────────────────────────
+// First frame must be `auth <pin>` (the PIN cce-remote prints at startup).
+// Remembered in localStorage after the first successful pairing.
+let ws = null, authed = false;
 const status = document.getElementById("status");
+function askPin(msg) {
+  const pin = window.prompt(msg || "cce-remote pairing PIN (shown in the server terminal):");
+  if (pin) localStorage.setItem("cce-remote-pin", pin.trim());
+  return pin ? pin.trim() : null;
+}
 function connect() {
+  authed = false;
   ws = new WebSocket("ws://" + location.host + "/ws");
-  ws.onopen = () => { status.textContent = "●"; status.style.color = "#5fbf6f"; };
-  ws.onclose = () => { status.textContent = "○"; status.style.color = "#bf5f5f"; setTimeout(connect, 1000); };
+  ws.onopen = () => {
+    const pin = localStorage.getItem("cce-remote-pin") || askPin();
+    if (pin) ws.send("auth " + pin); else ws.close();
+  };
+  ws.onmessage = e => {
+    if (e.data === "auth ok") { authed = true; status.textContent = "●"; status.style.color = "#5fbf6f"; }
+    else if (e.data === "auth fail") {
+      localStorage.removeItem("cce-remote-pin");
+      askPin("Wrong PIN — try again:");
+      // server closed on failure; onclose reconnects with the new PIN
+    }
+  };
+  ws.onclose = () => { authed = false; status.textContent = "○"; status.style.color = "#bf5f5f"; setTimeout(connect, 1000); };
 }
 connect();
-function send(s) { if (ws && ws.readyState === 1) ws.send(s); }
+function send(s) { if (authed && ws && ws.readyState === 1) ws.send(s); }
 
 // ── Trackpad ─────────────────────────────────────────────────────────
 const pad = document.getElementById("pad");
@@ -141,6 +160,13 @@ const kb = document.getElementById("kb");
 document.getElementById("kbtn").addEventListener("click", () => {
   if (document.activeElement === kb) { kb.blur(); } else { kb.focus(); }
 });
+// iOS never fires deleteContentBackward on an EMPTY field — keep sentinel
+// padding in the textarea (cursor at the end) so backspace always has
+// something to "delete". beforeinput preventDefault keeps it untouched;
+// the re-arm below repairs any drift (autocorrect ghosts etc.).
+const SENTINEL = "········";
+function armKb() { kb.value = SENTINEL; kb.setSelectionRange(SENTINEL.length, SENTINEL.length); }
+kb.addEventListener("focus", armKb);
 // US-layout char → [evdev keycode, needs shift]
 const KEYS = {};
 "abcdefghijklmnopqrstuvwxyz".split("").forEach((c, i) => {
@@ -171,12 +197,13 @@ kb.addEventListener("beforeinput", e => {
       for (const ch of (e.data || "")) typeChar(ch);
       break;
     case "insertLineBreak": send("k 28"); break;
-    case "deleteContentBackward": send("k 14"); break;
+    case "deleteContentBackward":
+    case "deleteWordBackward": send("k 14"); break;
     case "deleteContentForward": send("k 111"); break;
   }
 });
-// keep the textarea from accumulating anything sneaky
-setInterval(() => { if (kb.value.length > 0) kb.value = ""; }, 2000);
+// repair sentinel drift while the keyboard is up
+setInterval(() => { if (document.activeElement === kb && kb.value !== SENTINEL) armKb(); }, 1000);
 </script>
 </body>
 </html>
diff --git a/src/main.rs b/src/main.rs
index 0ec2b28..32f8d50 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -15,12 +15,16 @@
 //!   k <keycode>          tap an evdev keycode
 //!   kd <keycode> / ku <keycode>   hold / release (modifiers)
 //!
-//! Security model: LAN-trust. Anyone who can reach the port controls the
-//! desktop — run it on a trusted network, or bind 127.0.0.1 and tunnel.
+//! Security model: pairing PIN. A persistent 6-digit PIN (generated on first
+//! run, stored 0600 under ~/.config/cce/cce-remote.pin, printed at startup)
+//! must arrive as the FIRST WebSocket frame (`auth <pin>`) before any input
+//! event is accepted; anything else closes the connection. The page remembers
+//! the PIN in localStorage after the first pairing.
 
 use std::io::{BufRead, BufReader, Read, Write};
 use std::net::{TcpListener, TcpStream};
 use std::os::unix::net::UnixStream;
+use std::time::Duration;
 
 const INDEX_HTML: &str = include_str!("../index.html");
 const DEFAULT_PORT: u16 = 17017;
@@ -30,6 +34,45 @@ fn control_socket_path() -> String {
     format!("/tmp/cce-{display}.sock")
 }
 
+fn pin_path() -> std::path::PathBuf {
+    let base = std::env::var("XDG_CONFIG_HOME")
+        .map(std::path::PathBuf::from)
+        .unwrap_or_else(|_| {
+            let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
+            std::path::PathBuf::from(home).join(".config")
+        });
+    base.join("cce").join("cce-remote.pin")
+}
+
+/// The pairing PIN: read from disk, or generated (6 digits from /dev/urandom)
+/// and stored 0600 on first run.
+fn load_or_create_pin() -> std::io::Result<String> {
+    let path = pin_path();
+    if let Ok(existing) = std::fs::read_to_string(&path) {
+        let trimmed = existing.trim().to_string();
+        if !trimmed.is_empty() {
+            return Ok(trimmed);
+        }
+    }
+    let mut bytes = [0u8; 4];
+    std::fs::File::open("/dev/urandom")?.read_exact(&mut bytes)?;
+    let pin = format!("{:06}", u32::from_le_bytes(bytes) % 1_000_000);
+    if let Some(dir) = path.parent() {
+        std::fs::create_dir_all(dir)?;
+    }
+    {
+        use std::os::unix::fs::OpenOptionsExt;
+        let mut f = std::fs::OpenOptions::new()
+            .write(true)
+            .create(true)
+            .truncate(true)
+            .mode(0o600)
+            .open(&path)?;
+        writeln!(f, "{pin}")?;
+    }
+    Ok(pin)
+}
+
 /// One persistent line-oriented connection to the compositor's control socket.
 struct Control {
     stream: BufReader<UnixStream>,
@@ -88,8 +131,10 @@ fn translate(frame: &str) -> Option<String> {
     Some(cmd)
 }
 
-fn handle_ws(stream: TcpStream) {
+fn handle_ws(stream: TcpStream, pin: &str) {
     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)));
     let mut ws = match tungstenite::accept(stream) {
         Ok(ws) => ws,
         Err(e) => {
@@ -97,6 +142,21 @@ fn handle_ws(stream: TcpStream) {
             return;
         }
     };
+    // First frame MUST be `auth <pin>` — anything else (or a timeout, or a
+    // wrong PIN) closes the connection before any input can be injected.
+    let authed = matches!(
+        ws.read(),
+        Ok(tungstenite::Message::Text(t))
+            if t.strip_prefix("auth ").map(str::trim) == Some(pin)
+    );
+    if !authed {
+        eprintln!("[cce-remote] auth failed: {peer}");
+        let _ = ws.send(tungstenite::Message::Text("auth fail".into()));
+        let _ = ws.close(None);
+        return;
+    }
+    let _ = ws.get_ref().set_read_timeout(None);
+    let _ = ws.send(tungstenite::Message::Text("auth ok".into()));
     let mut control = match Control::connect() {
         Ok(c) => c,
         Err(e) => {
@@ -147,6 +207,13 @@ fn main() {
         .nth(1)
         .and_then(|a| a.parse::<u16>().ok())
         .unwrap_or(DEFAULT_PORT);
+    let pin = match load_or_create_pin() {
+        Ok(p) => p,
+        Err(e) => {
+            eprintln!("[cce-remote] cannot read/create PIN file {:?}: {e}", pin_path());
+            std::process::exit(1);
+        }
+    };
     let listener = match TcpListener::bind(("0.0.0.0", port)) {
         Ok(l) => l,
         Err(e) => {
@@ -155,12 +222,14 @@ fn main() {
         }
     };
     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());
 
     for stream in listener.incoming() {
         let stream = match stream {
             Ok(s) => s,
             Err(_) => continue,
         };
+        let pin = pin.clone();
         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.
@@ -171,7 +240,7 @@ fn main() {
             };
             let head = String::from_utf8_lossy(&buf[..n]).to_string();
             if head.starts_with("GET /ws") {
-                handle_ws(stream);
+                handle_ws(stream, &pin);
             } else {
                 // Consume the request before replying (keeps curl happy).
                 let mut sink = [0u8; 1024];