rendezvous: optional bearer-token auth (TETHER_RENDEZVOUS_TOKEN)

When TETHER_RENDEZVOUS_TOKEN is set, register/peers require
`Authorization: Bearer <token>` (healthz stays open); empty token = open, so the
first cross-CGNAT test needs no token but production can gate. IrohTransport
sends the token from the same env var, so agent/CLI clients pick it up
automatically (iOS needs a token field — small Swift follow-up).

Verified: 401 without/with wrong token, 200 with the right one, healthz open; and
the full engine flow (text + 200KB photo) still syncs end-to-end through a
token-gated rendezvous. Default ship build untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 01:31:37 -05:00
parent 2c1419d18c
commit eefd58cf5b
4 changed files with 83 additions and 17 deletions

View File

@@ -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

View File

@@ -99,7 +99,14 @@ async fn rendezvous_register(server: &str, room: &str, my_id: &str) -> Vec<Endpo
}
let url = format!("{}/rendezvous/register", server.trim_end_matches('/'));
let body = serde_json::json!({ "room": room, "id": my_id });
let resp = match reqwest::Client::new().post(&url).json(&body).send().await {
let mut req = reqwest::Client::new().post(&url).json(&body);
// Optional bearer token, matching the rendezvous's TETHER_RENDEZVOUS_TOKEN.
if let Ok(token) = std::env::var("TETHER_RENDEZVOUS_TOKEN") {
if !token.is_empty() {
req = req.bearer_auth(token);
}
}
let resp = match req.send().await {
Ok(r) => r,
Err(_) => return Vec::new(),
};

View File

@@ -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 <token>` 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.

View File

@@ -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<Mutex<HashMap<String, HashMap<String, Instant>>>>;
/// Shared state: the room table + an optional bearer token. When the token is
/// non-empty, register/peers require `Authorization: Bearer <token>`; 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<String>,
}
/// 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<String> {
.collect()
}
async fn register(State(rooms): State<Rooms>, Json(r): Json<Register>) -> Json<Peers> {
async fn register(
State(st): State<AppState>,
headers: HeaderMap,
Json(r): Json<Register>,
) -> Result<Json<Peers>, 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<Rooms>, Query(q): Query<PeersQuery>) -> Json<Peers> {
Json(Peers { peers: live_peers(&rooms, &q.room, None) })
async fn peers(
State(st): State<AppState>,
headers: HeaderMap,
Query(q): Query<PeersQuery>,
) -> Result<Json<Peers>, 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;