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:
+9
-3
@@ -448,10 +448,16 @@ multi-modal payloads (text inline, audio/photo/data via `iroh-blobs`). Receiving
|
|||||||
./build-apple.sh` assembles + vendors the iroh xcframework
|
./build-apple.sh` assembles + vendors the iroh xcframework
|
||||||
(4,891 iroh symbols in the iOS staticlib); Swift bindings
|
(4,891 iroh symbols in the iOS staticlib); Swift bindings
|
||||||
unchanged, so the app builds with no Swift changes.
|
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
|
- [ ] Remaining: deploy the rendezvous + a self-hosted iroh-relay
|
||||||
publicly (homelab) for the on-device cross-CGNAT test; make
|
publicly (homelab) for the on-device cross-CGNAT test (set the
|
||||||
webrtc/mdns/reqwest optional for a lean iroh/wasm build; A/B vs
|
token; iOS needs a token field); per-IP rate-limiting; make
|
||||||
RTC+LAN; flip the feature on by default.
|
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
|
- [ ] Spike **Automerge** as the Sync tier; re-express the clipboard as a tiny
|
||||||
app over replicated state to validate the App API.
|
app over replicated state to validate the App API.
|
||||||
- [ ] Durable device keypairs + authenticated handshake (Noise/`snow`, or
|
- [ ] Durable device keypairs + authenticated handshake (Noise/`snow`, or
|
||||||
|
|||||||
+8
-1
@@ -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 url = format!("{}/rendezvous/register", server.trim_end_matches('/'));
|
||||||
let body = serde_json::json!({ "room": room, "id": my_id });
|
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,
|
Ok(r) => r,
|
||||||
Err(_) => return Vec::new(),
|
Err(_) => return Vec::new(),
|
||||||
};
|
};
|
||||||
|
|||||||
+11
-5
@@ -69,9 +69,15 @@ RestartSec=2
|
|||||||
WantedBy=multi-user.target
|
WantedBy=multi-user.target
|
||||||
```
|
```
|
||||||
|
|
||||||
## Security note
|
## Auth
|
||||||
|
|
||||||
Registration is unauthenticated — anyone who knows a `room` (a hash) can register
|
Set `TETHER_RENDEZVOUS_TOKEN` to require `Authorization: Bearer <token>` on
|
||||||
an id or enumerate members. That's metadata only and content stays E2E, but a
|
`register`/`peers` (`healthz` stays open). Clients send it via the same env var
|
||||||
production deployment should add a token/auth gate and rate-limiting (the
|
(`TETHER_RENDEZVOUS_TOKEN`), so the agent/CLI pick it up automatically; the iOS
|
||||||
`MAX_PER_ROOM` cap is only a flood blunt). Tracked as a follow-up.
|
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.
|
||||||
|
|||||||
+55
-8
@@ -24,6 +24,7 @@ use std::time::{Duration, Instant};
|
|||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Query, State},
|
extract::{Query, State},
|
||||||
|
http::{HeaderMap, StatusCode},
|
||||||
routing::{get, post},
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
@@ -37,6 +38,33 @@ const MAX_PER_ROOM: usize = 64;
|
|||||||
|
|
||||||
type Rooms = Arc<Mutex<HashMap<String, HashMap<String, Instant>>>>;
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct Register {
|
struct Register {
|
||||||
room: String,
|
room: String,
|
||||||
@@ -65,36 +93,55 @@ fn live_peers(rooms: &Rooms, room: &str, exclude: Option<&str>) -> Vec<String> {
|
|||||||
.collect()
|
.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() {
|
if !r.room.is_empty() && !r.id.is_empty() {
|
||||||
let now = Instant::now();
|
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();
|
let members = map.entry(r.room.clone()).or_default();
|
||||||
members.retain(|_, seen| now.duration_since(*seen) < TTL);
|
members.retain(|_, seen| now.duration_since(*seen) < TTL);
|
||||||
if members.len() < MAX_PER_ROOM || members.contains_key(&r.id) {
|
if members.len() < MAX_PER_ROOM || members.contains_key(&r.id) {
|
||||||
members.insert(r.id.clone(), now);
|
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> {
|
async fn peers(
|
||||||
Json(Peers { peers: live_peers(&rooms, &q.room, None) })
|
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]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
let port: u16 = std::env::var("PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(8765);
|
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()
|
let app = Router::new()
|
||||||
.route("/rendezvous/register", post(register))
|
.route("/rendezvous/register", post(register))
|
||||||
.route("/rendezvous/peers", get(peers))
|
.route("/rendezvous/peers", get(peers))
|
||||||
.route("/healthz", get(|| async { "ok" }))
|
.route("/healthz", get(|| async { "ok" }))
|
||||||
.with_state(rooms);
|
.with_state(state);
|
||||||
|
|
||||||
let listener = tokio::net::TcpListener::bind(("0.0.0.0", port)).await.unwrap();
|
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)
|
axum::serve(listener, app)
|
||||||
.with_graceful_shutdown(async {
|
.with_graceful_shutdown(async {
|
||||||
let _ = tokio::signal::ctrl_c().await;
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
|||||||
Reference in New Issue
Block a user