diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 75cb01e..1d5490a 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -448,10 +448,16 @@ multi-modal payloads (text inline, audio/photo/data via `iroh-blobs`). Receiving ./build-apple.sh` assembles + vendors the iroh xcframework (4,891 iroh symbols in the iOS staticlib); Swift bindings unchanged, so the app builds with no Swift changes. + - [x] **Rendezvous auth** — optional bearer token + (`TETHER_RENDEZVOUS_TOKEN`) on register/peers; client sends it + via the same env var. Off by default (open) so the first test + needs no token; set one before public exposure. Verified 401/200. - [ ] Remaining: deploy the rendezvous + a self-hosted iroh-relay - publicly (homelab) for the on-device cross-CGNAT test; make - webrtc/mdns/reqwest optional for a lean iroh/wasm build; A/B vs - RTC+LAN; flip the feature on by default. + publicly (homelab) for the on-device cross-CGNAT test (set the + token; iOS needs a token field); per-IP rate-limiting; make + webrtc/mdns/reqwest optional for a lean iroh/wasm build (note: + also needs a wasm-compatible runtime — Engine uses tokio + multi-thread today); A/B vs RTC+LAN; flip the feature default. - [ ] Spike **Automerge** as the Sync tier; re-express the clipboard as a tiny app over replicated state to validate the App API. - [ ] Durable device keypairs + authenticated handshake (Noise/`snow`, or diff --git a/core/src/iroh.rs b/core/src/iroh.rs index 2e6d689..80efe43 100644 --- a/core/src/iroh.rs +++ b/core/src/iroh.rs @@ -99,7 +99,14 @@ async fn rendezvous_register(server: &str, room: &str, my_id: &str) -> Vec r, Err(_) => return Vec::new(), }; diff --git a/rendezvous/README.md b/rendezvous/README.md index b925a29..fd3a56b 100644 --- a/rendezvous/README.md +++ b/rendezvous/README.md @@ -69,9 +69,15 @@ RestartSec=2 WantedBy=multi-user.target ``` -## Security note +## Auth -Registration is unauthenticated — anyone who knows a `room` (a hash) can register -an id or enumerate members. That's metadata only and content stays E2E, but a -production deployment should add a token/auth gate and rate-limiting (the -`MAX_PER_ROOM` cap is only a flood blunt). Tracked as a follow-up. +Set `TETHER_RENDEZVOUS_TOKEN` to require `Authorization: Bearer ` on +`register`/`peers` (`healthz` stays open). Clients send it via the same env var +(`TETHER_RENDEZVOUS_TOKEN`), so the agent/CLI pick it up automatically; the iOS +app needs a field for it (small Swift follow-up). **Empty token = open** — fine +for a LAN/test run, but set one before exposing it publicly. Without a token, +anyone who knows a `room` (a hash) can register/enumerate — metadata only, content +stays E2E, but still worth gating. + +Remaining hardening (follow-up): per-IP rate-limiting (the `MAX_PER_ROOM` cap is +only a flood blunt); behind Caddy use `X-Forwarded-For` for the real client IP. diff --git a/rendezvous/src/main.rs b/rendezvous/src/main.rs index 547aa33..6777a75 100644 --- a/rendezvous/src/main.rs +++ b/rendezvous/src/main.rs @@ -24,6 +24,7 @@ use std::time::{Duration, Instant}; use axum::{ extract::{Query, State}, + http::{HeaderMap, StatusCode}, routing::{get, post}, Json, Router, }; @@ -37,6 +38,33 @@ const MAX_PER_ROOM: usize = 64; type Rooms = Arc>>>; +/// Shared state: the room table + an optional bearer token. When the token is +/// non-empty, register/peers require `Authorization: Bearer `; when empty +/// (default) the endpoints are open — fine for a LAN/test run, but set a token +/// before exposing it publicly. +#[derive(Clone)] +struct AppState { + rooms: Rooms, + token: Arc, +} + +/// Constant-time-ish bearer check (length + byte compare). Empty token → open. +fn authorized(headers: &HeaderMap, token: &str) -> bool { + if token.is_empty() { + return true; + } + let Some(auth) = headers.get("authorization").and_then(|v| v.to_str().ok()) else { + return false; + }; + let presented = auth.strip_prefix("Bearer ").unwrap_or(""); + presented.len() == token.len() + && presented + .bytes() + .zip(token.bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + #[derive(Deserialize)] struct Register { room: String, @@ -65,36 +93,55 @@ fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec { .collect() } -async fn register(State(rooms): State, Json(r): Json) -> Json { +async fn register( + State(st): State, + headers: HeaderMap, + Json(r): Json, +) -> Result, StatusCode> { + if !authorized(&headers, &st.token) { + return Err(StatusCode::UNAUTHORIZED); + } if !r.room.is_empty() && !r.id.is_empty() { let now = Instant::now(); - let mut map = rooms.lock().unwrap(); + let mut map = st.rooms.lock().unwrap(); let members = map.entry(r.room.clone()).or_default(); members.retain(|_, seen| now.duration_since(*seen) < TTL); if members.len() < MAX_PER_ROOM || members.contains_key(&r.id) { members.insert(r.id.clone(), now); } } - Json(Peers { peers: live_peers(&rooms, &r.room, Some(&r.id)) }) + Ok(Json(Peers { peers: live_peers(&st.rooms, &r.room, Some(&r.id)) })) } -async fn peers(State(rooms): State, Query(q): Query) -> Json { - Json(Peers { peers: live_peers(&rooms, &q.room, None) }) +async fn peers( + State(st): State, + headers: HeaderMap, + Query(q): Query, +) -> Result, StatusCode> { + if !authorized(&headers, &st.token) { + return Err(StatusCode::UNAUTHORIZED); + } + Ok(Json(Peers { peers: live_peers(&st.rooms, &q.room, None) })) } #[tokio::main] async fn main() { let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8765); - let rooms: Rooms = Arc::new(Mutex::new(HashMap::new())); + let token = std::env::var("TETHER_RENDEZVOUS_TOKEN").unwrap_or_default(); + let state = AppState { + rooms: Arc::new(Mutex::new(HashMap::new())), + token: Arc::new(token.clone()), + }; let app = Router::new() .route("/rendezvous/register", post(register)) .route("/rendezvous/peers", get(peers)) .route("/healthz", get(|| async { "ok" })) - .with_state(rooms); + .with_state(state); let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await.unwrap(); - println!("tether-rendezvous listening on 0.0.0.0:{port}"); + let auth = if token.is_empty() { "OPEN (no token — set TETHER_RENDEZVOUS_TOKEN before public exposure)" } else { "token-gated" }; + println!("tether-rendezvous listening on 0.0.0.0:{port} — {auth}"); axum::serve(listener, app) .with_graceful_shutdown(async { let _ = tokio::signal::ctrl_c().await;