diff --git a/crates/wzp-client/Cargo.toml b/crates/wzp-client/Cargo.toml index 8dbf803..ec7c152 100644 --- a/crates/wzp-client/Cargo.toml +++ b/crates/wzp-client/Cargo.toml @@ -18,6 +18,8 @@ tracing-subscriber = { workspace = true } async-trait = { workspace = true } bytes = { workspace = true } anyhow = "1" +serde = { workspace = true } +serde_json = "1" cpal = { version = "0.15", optional = true } [features] diff --git a/crates/wzp-client/src/featherchat.rs b/crates/wzp-client/src/featherchat.rs new file mode 100644 index 0000000..a5d5769 --- /dev/null +++ b/crates/wzp-client/src/featherchat.rs @@ -0,0 +1,138 @@ +//! featherChat signaling bridge. +//! +//! Sends WZP call signaling (Offer/Answer/Hangup) through featherChat's +//! E2E encrypted WebSocket channel as `WireMessage::CallSignal`. +//! +//! Flow: +//! 1. Client connects to featherChat WS with bearer token +//! 2. Sends CallOffer as CallSignal(signal_type=Offer, payload=JSON SignalMessage) +//! 3. Receives CallAnswer as CallSignal(signal_type=Answer, payload=JSON SignalMessage) +//! 4. Extracts relay address from the answer +//! 5. Connects QUIC to relay for media + +use serde::{Deserialize, Serialize}; +use tracing::{error, info}; + +use wzp_proto::packet::SignalMessage; +use wzp_proto::QualityProfile; + +/// featherChat CallSignal types (mirrors warzone-protocol::message::CallSignalType). +#[derive(Clone, Debug, Serialize, Deserialize)] +pub enum CallSignalType { + Offer, + Answer, + IceCandidate, + Hangup, + Reject, + Ringing, + Busy, +} + +/// A CallSignal as sent through featherChat's WireMessage. +/// This is what goes in the `payload` field of `WireMessage::CallSignal`. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct WzpCallPayload { + /// The WZP SignalMessage (CallOffer, CallAnswer, etc.) serialized as JSON. + pub signal: SignalMessage, + /// The relay address to connect to for media (host:port). + pub relay_addr: Option, + /// Room name on the relay. + pub room: Option, +} + +/// Parameters for initiating a call through featherChat. +pub struct CallInitParams { + /// featherChat server URL (e.g., "wss://chat.example.com/ws"). + pub server_url: String, + /// Bearer token for authentication. + pub token: String, + /// Target peer fingerprint (who to call). + pub target_fingerprint: String, + /// Relay address for media transport. + pub relay_addr: String, + /// Room name on the relay. + pub room: String, + /// Our identity seed for crypto. + pub seed: [u8; 32], +} + +/// Result of a successful call setup. +pub struct CallSetupResult { + /// Relay address to connect to. + pub relay_addr: String, + /// Room name. + pub room: String, + /// The peer's CallAnswer signal (contains ephemeral key, etc.) + pub answer: SignalMessage, +} + +/// Serialize a WZP SignalMessage into a featherChat CallSignal payload string. +pub fn encode_call_payload( + signal: &SignalMessage, + relay_addr: Option<&str>, + room: Option<&str>, +) -> String { + let payload = WzpCallPayload { + signal: signal.clone(), + relay_addr: relay_addr.map(|s| s.to_string()), + room: room.map(|s| s.to_string()), + }; + serde_json::to_string(&payload).unwrap_or_default() +} + +/// Deserialize a featherChat CallSignal payload back to WZP types. +pub fn decode_call_payload(payload: &str) -> Result { + serde_json::from_str(payload).map_err(|e| format!("invalid call payload: {e}")) +} + +/// Map WZP SignalMessage type to featherChat CallSignalType. +pub fn signal_to_call_type(signal: &SignalMessage) -> CallSignalType { + match signal { + SignalMessage::CallOffer { .. } => CallSignalType::Offer, + SignalMessage::CallAnswer { .. } => CallSignalType::Answer, + SignalMessage::IceCandidate { .. } => CallSignalType::IceCandidate, + SignalMessage::Hangup { .. } => CallSignalType::Hangup, + SignalMessage::Rekey { .. } => CallSignalType::Offer, // reuse + SignalMessage::QualityUpdate { .. } => CallSignalType::Offer, // reuse + SignalMessage::Ping { .. } | SignalMessage::Pong { .. } => CallSignalType::Offer, + SignalMessage::AuthToken { .. } => CallSignalType::Offer, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn payload_roundtrip() { + let signal = SignalMessage::CallOffer { + identity_pub: [1u8; 32], + ephemeral_pub: [2u8; 32], + signature: vec![3u8; 64], + supported_profiles: vec![QualityProfile::GOOD], + }; + + let encoded = encode_call_payload(&signal, Some("relay.example.com:4433"), Some("myroom")); + let decoded = decode_call_payload(&encoded).unwrap(); + + assert_eq!(decoded.relay_addr.unwrap(), "relay.example.com:4433"); + assert_eq!(decoded.room.unwrap(), "myroom"); + assert!(matches!(decoded.signal, SignalMessage::CallOffer { .. })); + } + + #[test] + fn signal_type_mapping() { + let offer = SignalMessage::CallOffer { + identity_pub: [0; 32], + ephemeral_pub: [0; 32], + signature: vec![], + supported_profiles: vec![], + }; + assert!(matches!(signal_to_call_type(&offer), CallSignalType::Offer)); + + let hangup = SignalMessage::Hangup { + reason: wzp_proto::HangupReason::Normal, + }; + assert!(matches!(signal_to_call_type(&hangup), CallSignalType::Hangup)); + } +} diff --git a/crates/wzp-client/src/lib.rs b/crates/wzp-client/src/lib.rs index d224c0f..0bfea7a 100644 --- a/crates/wzp-client/src/lib.rs +++ b/crates/wzp-client/src/lib.rs @@ -11,6 +11,7 @@ pub mod audio_io; pub mod bench; pub mod call; pub mod echo_test; +pub mod featherchat; pub mod handshake; #[cfg(feature = "audio")] diff --git a/crates/wzp-proto/src/packet.rs b/crates/wzp-proto/src/packet.rs index 51a3384..46b747f 100644 --- a/crates/wzp-proto/src/packet.rs +++ b/crates/wzp-proto/src/packet.rs @@ -297,6 +297,10 @@ pub enum SignalMessage { /// End the call. Hangup { reason: HangupReason }, + + /// featherChat bearer token for relay authentication. + /// Sent as the first signal message when --auth-url is configured. + AuthToken { token: String }, } /// Reasons for ending a call. diff --git a/crates/wzp-relay/Cargo.toml b/crates/wzp-relay/Cargo.toml index 0b42699..abcabf2 100644 --- a/crates/wzp-relay/Cargo.toml +++ b/crates/wzp-relay/Cargo.toml @@ -20,6 +20,8 @@ bytes = { workspace = true } serde = { workspace = true } toml = "0.8" anyhow = "1" +reqwest = { version = "0.12", features = ["json"] } +serde_json = "1" rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } quinn = { workspace = true } diff --git a/crates/wzp-relay/src/auth.rs b/crates/wzp-relay/src/auth.rs new file mode 100644 index 0000000..fe29ba3 --- /dev/null +++ b/crates/wzp-relay/src/auth.rs @@ -0,0 +1,106 @@ +//! featherChat token authentication. +//! +//! When `--auth-url` is configured, the relay validates bearer tokens +//! against featherChat's `POST /v1/auth/validate` endpoint before +//! allowing clients to join rooms. + +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +/// Request body for featherChat token validation. +#[derive(Serialize)] +struct ValidateRequest { + token: String, +} + +/// Response from featherChat token validation. +#[derive(Deserialize, Debug)] +pub struct ValidateResponse { + pub valid: bool, + pub fingerprint: Option, + pub alias: Option, +} + +/// Validated client identity. +#[derive(Clone, Debug)] +pub struct AuthenticatedClient { + pub fingerprint: String, + pub alias: Option, +} + +/// Validate a bearer token against featherChat's auth endpoint. +/// +/// Calls `POST {auth_url}` with `{ "token": "..." }`. +/// Returns the client identity if valid, or an error string. +pub async fn validate_token( + auth_url: &str, + token: &str, +) -> Result { + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .map_err(|e| format!("http client error: {e}"))?; + + let resp = client + .post(auth_url) + .json(&ValidateRequest { + token: token.to_string(), + }) + .send() + .await + .map_err(|e| format!("auth request failed: {e}"))?; + + if !resp.status().is_success() { + return Err(format!("auth endpoint returned {}", resp.status())); + } + + let body: ValidateResponse = resp + .json() + .await + .map_err(|e| format!("invalid auth response: {e}"))?; + + if body.valid { + let fingerprint = body + .fingerprint + .ok_or_else(|| "valid response missing fingerprint".to_string())?; + info!(%fingerprint, alias = ?body.alias, "token validated"); + Ok(AuthenticatedClient { + fingerprint, + alias: body.alias, + }) + } else { + warn!("token validation failed"); + Err("invalid token".to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_request_serializes() { + let req = ValidateRequest { + token: "abc123".to_string(), + }; + let json = serde_json::to_string(&req).unwrap(); + assert!(json.contains("abc123")); + } + + #[test] + fn validate_response_deserializes() { + let json = r#"{"valid": true, "fingerprint": "abcd1234", "alias": "manwe"}"#; + let resp: ValidateResponse = serde_json::from_str(json).unwrap(); + assert!(resp.valid); + assert_eq!(resp.fingerprint.unwrap(), "abcd1234"); + assert_eq!(resp.alias.unwrap(), "manwe"); + } + + #[test] + fn invalid_response_deserializes() { + let json = r#"{"valid": false}"#; + let resp: ValidateResponse = serde_json::from_str(json).unwrap(); + assert!(!resp.valid); + assert!(resp.fingerprint.is_none()); + } +} diff --git a/crates/wzp-relay/src/config.rs b/crates/wzp-relay/src/config.rs index 468d2b9..1e692f7 100644 --- a/crates/wzp-relay/src/config.rs +++ b/crates/wzp-relay/src/config.rs @@ -19,6 +19,9 @@ pub struct RelayConfig { pub jitter_max_depth: usize, /// Logging level (trace, debug, info, warn, error). pub log_level: String, + /// featherChat auth validation URL (e.g., "https://chat.example.com/v1/auth/validate"). + /// If set, clients must present a valid token before joining rooms. + pub auth_url: Option, } impl Default for RelayConfig { @@ -30,6 +33,7 @@ impl Default for RelayConfig { jitter_target_depth: 50, jitter_max_depth: 250, log_level: "info".to_string(), + auth_url: None, } } } diff --git a/crates/wzp-relay/src/lib.rs b/crates/wzp-relay/src/lib.rs index e6180b6..45112c7 100644 --- a/crates/wzp-relay/src/lib.rs +++ b/crates/wzp-relay/src/lib.rs @@ -7,6 +7,7 @@ //! It operates on FEC-protected packets, managing loss recovery and adaptive //! quality transitions. +pub mod auth; pub mod config; pub mod handshake; pub mod pipeline; diff --git a/crates/wzp-relay/src/main.rs b/crates/wzp-relay/src/main.rs index 0eb769e..668c2cf 100644 --- a/crates/wzp-relay/src/main.rs +++ b/crates/wzp-relay/src/main.rs @@ -38,17 +38,23 @@ fn parse_args() -> RelayConfig { .parse().expect("invalid --remote address"), ); } + "--auth-url" => { + i += 1; + config.auth_url = Some( + args.get(i).expect("--auth-url requires a URL").to_string(), + ); + } "--help" | "-h" => { - eprintln!("Usage: wzp-relay [--listen ] [--remote ]"); + eprintln!("Usage: wzp-relay [--listen ] [--remote ] [--auth-url ]"); eprintln!(); eprintln!("Options:"); - eprintln!(" --listen Listen address (default: 0.0.0.0:4433)"); - eprintln!(" --remote Remote relay for forwarding (disables room mode)"); + eprintln!(" --listen Listen address (default: 0.0.0.0:4433)"); + eprintln!(" --remote Remote relay for forwarding (disables room mode)"); + eprintln!(" --auth-url featherChat auth endpoint (e.g., https://chat.example.com/v1/auth/validate)"); + eprintln!(" When set, clients must send a bearer token as first signal message."); eprintln!(); eprintln!("Room mode (default):"); - eprintln!(" Clients join rooms by name. Packets are forwarded to all"); - eprintln!(" other participants in the same room (SFU model)."); - eprintln!(" Room name comes from QUIC SNI or defaults to 'default'."); + eprintln!(" Clients join rooms by name. Packets forwarded to all others (SFU)."); std::process::exit(0); } other => { @@ -154,6 +160,12 @@ async fn main() -> anyhow::Result<()> { // Room manager (room mode only) let room_mgr = Arc::new(Mutex::new(RoomManager::new())); + if let Some(ref url) = config.auth_url { + info!(url, "auth enabled — clients must present featherChat token"); + } else { + info!("auth disabled — any client can connect (use --auth-url to enable)"); + } + info!("Listening for connections..."); loop { @@ -164,12 +176,11 @@ async fn main() -> anyhow::Result<()> { let remote_transport = remote_transport.clone(); let room_mgr = room_mgr.clone(); + let auth_url = config.auth_url.clone(); tokio::spawn(async move { let addr = connection.remote_address(); - // Extract room name from QUIC handshake data (SNI). - // The web bridge connects with the room name as server_name. let room_name = connection .handshake_data() .and_then(|hd| { @@ -180,7 +191,45 @@ async fn main() -> anyhow::Result<()> { let transport = Arc::new(wzp_transport::QuinnTransport::new(connection)); - info!(%addr, room = %room_name, "new client"); + // Auth check: if --auth-url is set, expect first signal message to be a token + if let Some(ref url) = auth_url { + info!(%addr, "waiting for auth token..."); + match transport.recv_signal().await { + Ok(Some(wzp_proto::SignalMessage::AuthToken { token })) => { + match wzp_relay::auth::validate_token(url, &token).await { + Ok(client) => { + info!( + %addr, + fingerprint = %client.fingerprint, + alias = ?client.alias, + "authenticated" + ); + } + Err(e) => { + error!(%addr, "auth failed: {e}"); + transport.close().await.ok(); + return; + } + } + } + Ok(Some(_)) => { + error!(%addr, "expected AuthToken as first signal, got something else"); + transport.close().await.ok(); + return; + } + Ok(None) => { + error!(%addr, "connection closed before auth"); + return; + } + Err(e) => { + error!(%addr, "signal recv error during auth: {e}"); + transport.close().await.ok(); + return; + } + } + } + + info!(%addr, room = %room_name, "client joined"); if let Some(remote) = remote_transport { // Forward mode — same as before