diff --git a/Cargo.lock b/Cargo.lock index 0654217..9c0200c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3839,7 +3839,9 @@ dependencies = [ "futures", "rcgen", "rustls", + "rustls-pemfile", "rustls-pki-types", + "serde_json", "tokio", "tokio-rustls", "tower-http", @@ -3850,6 +3852,7 @@ dependencies = [ "wzp-crypto", "wzp-fec", "wzp-proto", + "wzp-relay", "wzp-transport", ] diff --git a/crates/wzp-client/src/cli.rs b/crates/wzp-client/src/cli.rs index 630f122..a3e75b4 100644 --- a/crates/wzp-client/src/cli.rs +++ b/crates/wzp-client/src/cli.rs @@ -42,6 +42,8 @@ struct CliArgs { echo_test_secs: Option, seed_hex: Option, mnemonic: Option, + room: Option, + token: Option, } impl CliArgs { @@ -78,6 +80,8 @@ fn parse_args() -> CliArgs { let mut echo_test_secs = None; let mut seed_hex = None; let mut mnemonic = None; + let mut room = None; + let mut token = None; let mut relay_str = None; let mut i = 1; @@ -116,6 +120,14 @@ fn parse_args() -> CliArgs { i -= 1; // back up since outer loop will increment mnemonic = Some(words.join(" ")); } + "--room" => { + i += 1; + room = Some(args.get(i).expect("--room requires a name").to_string()); + } + "--token" => { + i += 1; + token = Some(args.get(i).expect("--token requires a value").to_string()); + } "--record" => { i += 1; record_file = Some( @@ -144,6 +156,8 @@ fn parse_args() -> CliArgs { eprintln!(" --echo-test Run automated echo quality test"); eprintln!(" --seed Identity seed (64 hex chars, featherChat compatible)"); eprintln!(" --mnemonic Identity seed as BIP39 mnemonic (24 words)"); + eprintln!(" --room Room name (hashed for privacy before sending)"); + eprintln!(" --token featherChat bearer token for relay auth"); eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)"); eprintln!(); eprintln!("Default relay: 127.0.0.1:4433"); @@ -175,6 +189,8 @@ fn parse_args() -> CliArgs { echo_test_secs, seed_hex, mnemonic, + room, + token, } } @@ -183,16 +199,27 @@ async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt().init(); let cli = parse_args(); - let _seed = cli.resolve_seed(); + let seed = cli.resolve_seed(); info!( relay = %cli.relay_addr, live = cli.live, send_tone = ?cli.send_tone_secs, record = ?cli.record_file, + room = ?cli.room, "WarzonePhone client" ); + // Hash room name for SNI privacy (or "default" if none specified) + let sni = match &cli.room { + Some(name) => { + let hashed = wzp_crypto::hash_room_name(name); + info!(room = %name, hashed = %hashed, "room name hashed for SNI"); + hashed + } + None => "default".to_string(), + }; + let client_config = wzp_transport::client_config(); let bind_addr = if cli.relay_addr.is_ipv6() { "[::]:0".parse()? @@ -201,12 +228,28 @@ async fn main() -> anyhow::Result<()> { }; let endpoint = wzp_transport::create_endpoint(bind_addr, None)?; let connection = - wzp_transport::connect(&endpoint, cli.relay_addr, "localhost", client_config).await?; + wzp_transport::connect(&endpoint, cli.relay_addr, &sni, client_config).await?; info!("Connected to relay"); let transport = Arc::new(wzp_transport::QuinnTransport::new(connection)); + // Send auth token if provided (relay with --auth-url expects this first) + if let Some(ref token) = cli.token { + let auth = wzp_proto::SignalMessage::AuthToken { + token: token.clone(), + }; + transport.send_signal(&auth).await?; + info!("auth token sent"); + } + + // Crypto handshake — establishes verified identity + session key + let _crypto_session = wzp_client::handshake::perform_handshake( + &*transport, + &seed.0, + ).await?; + info!("crypto handshake complete"); + if cli.live { #[cfg(feature = "audio")] { diff --git a/crates/wzp-crypto/src/identity.rs b/crates/wzp-crypto/src/identity.rs index 9da1617..6cfc0d7 100644 --- a/crates/wzp-crypto/src/identity.rs +++ b/crates/wzp-crypto/src/identity.rs @@ -187,6 +187,22 @@ pub struct PublicIdentity { pub fingerprint: Fingerprint, } +/// Hash a human-readable room/group name into an opaque hex string. +/// Used as QUIC SNI to prevent leaking group names to network observers. +/// +/// `hash_room_name("my-group")` → 32 hex chars (16 bytes of SHA-256). +/// +/// Mirrors the convention in featherChat WZP-FC-5: +/// `SHA-256("featherchat-group:" + group_name)[:16]` +pub fn hash_room_name(group_name: &str) -> String { + use sha2::{Digest, Sha256}; + let mut hasher = Sha256::new(); + hasher.update(b"featherchat-group:"); + hasher.update(group_name.as_bytes()); + let hash = hasher.finalize(); + hex::encode(&hash[..16]) +} + #[cfg(test)] mod tests { use super::*; @@ -231,6 +247,20 @@ mod tests { assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 7); } + #[test] + fn hash_room_name_deterministic() { + let h1 = hash_room_name("my-group"); + let h2 = hash_room_name("my-group"); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 32); // 16 bytes = 32 hex chars + assert!(h1.chars().all(|c| c.is_ascii_hexdigit())); + } + + #[test] + fn hash_room_name_different_inputs() { + assert_ne!(hash_room_name("alpha"), hash_room_name("beta")); + } + #[test] fn matches_handshake_derivation() { use wzp_proto::KeyExchange; diff --git a/crates/wzp-crypto/src/lib.rs b/crates/wzp-crypto/src/lib.rs index 20b16ea..0f83f31 100644 --- a/crates/wzp-crypto/src/lib.rs +++ b/crates/wzp-crypto/src/lib.rs @@ -16,7 +16,7 @@ pub mod session; pub use anti_replay::AntiReplayWindow; pub use handshake::WarzoneKeyExchange; -pub use identity::{Fingerprint, IdentityKeyPair, PublicIdentity, Seed}; +pub use identity::{hash_room_name, Fingerprint, IdentityKeyPair, PublicIdentity, Seed}; pub use nonce::{build_nonce, Direction}; pub use rekey::RekeyManager; pub use session::ChaChaSession; diff --git a/crates/wzp-proto/Cargo.toml b/crates/wzp-proto/Cargo.toml index 5f33945..5ba7b89 100644 --- a/crates/wzp-proto/Cargo.toml +++ b/crates/wzp-proto/Cargo.toml @@ -1,17 +1,21 @@ [package] name = "wzp-proto" -version.workspace = true -edition.workspace = true -license.workspace = true -rust-version.workspace = true +version = "0.1.0" +edition = "2024" +license = "MIT OR Apache-2.0" +rust-version = "1.85" description = "WarzonePhone protocol types, traits, and core logic" +# This crate is designed to be importable standalone — no workspace inheritance. +# featherChat and other projects can depend on it directly via git: +# wzp-proto = { git = "ssh://git@git.manko.yoga:222/manawenuz/wz-phone.git", path = "crates/wzp-proto" } + [dependencies] -bytes = { workspace = true } -thiserror = { workspace = true } -async-trait = { workspace = true } -serde = { workspace = true } -tracing = { workspace = true } +bytes = "1" +thiserror = "2" +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +tracing = "0.1" [dev-dependencies] -tokio = { workspace = true } +tokio = { version = "1", features = ["full"] } diff --git a/crates/wzp-relay/src/main.rs b/crates/wzp-relay/src/main.rs index 668c2cf..5026a85 100644 --- a/crates/wzp-relay/src/main.rs +++ b/crates/wzp-relay/src/main.rs @@ -140,7 +140,10 @@ async fn main() -> anyhow::Result<()> { .install_default() .expect("failed to install rustls crypto provider"); - info!(addr = %config.listen_addr, "WarzonePhone relay starting"); + // Generate ephemeral relay identity for crypto handshake + let relay_seed = wzp_crypto::Seed::generate(); + let relay_fp = relay_seed.derive_identity().public_identity().fingerprint; + info!(addr = %config.listen_addr, fingerprint = %relay_fp, "WarzonePhone relay starting"); let (server_config, _cert) = wzp_transport::server_config(); let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(server_config))?; @@ -177,6 +180,7 @@ async fn main() -> anyhow::Result<()> { let remote_transport = remote_transport.clone(); let room_mgr = room_mgr.clone(); let auth_url = config.auth_url.clone(); + let relay_seed_bytes = relay_seed.0; tokio::spawn(async move { let addr = connection.remote_address(); @@ -192,7 +196,8 @@ async fn main() -> anyhow::Result<()> { let transport = Arc::new(wzp_transport::QuinnTransport::new(connection)); // Auth check: if --auth-url is set, expect first signal message to be a token - if let Some(ref url) = auth_url { + // Auth: if --auth-url is set, expect AuthToken as first signal + let authenticated_fp: Option = 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 })) => { @@ -204,6 +209,7 @@ async fn main() -> anyhow::Result<()> { alias = ?client.alias, "authenticated" ); + Some(client.fingerprint) } Err(e) => { error!(%addr, "auth failed: {e}"); @@ -227,9 +233,27 @@ async fn main() -> anyhow::Result<()> { return; } } - } + } else { + None + }; - info!(%addr, room = %room_name, "client joined"); + // Crypto handshake: verify client identity + negotiate quality profile + let (_crypto_session, _chosen_profile) = match wzp_relay::handshake::accept_handshake( + &*transport, + &relay_seed_bytes, + ).await { + Ok(result) => { + info!(%addr, "crypto handshake complete"); + result + } + Err(e) => { + error!(%addr, "handshake failed: {e}"); + transport.close().await.ok(); + return; + } + }; + + info!(%addr, room = %room_name, "client joining"); if let Some(remote) = remote_transport { // Forward mode — same as before @@ -263,7 +287,14 @@ async fn main() -> anyhow::Result<()> { // Room mode — join room and forward to all others let participant_id = { let mut mgr = room_mgr.lock().await; - mgr.join(&room_name, addr, transport.clone()) + match mgr.join(&room_name, addr, transport.clone(), authenticated_fp.as_deref()) { + Ok(id) => id, + Err(e) => { + error!(%addr, room = %room_name, "room join denied: {e}"); + transport.close().await.ok(); + return; + } + } }; room::run_participant( diff --git a/crates/wzp-relay/src/room.rs b/crates/wzp-relay/src/room.rs index 88c767f..adad331 100644 --- a/crates/wzp-relay/src/room.rs +++ b/crates/wzp-relay/src/room.rs @@ -3,12 +3,12 @@ //! Each room holds N participants. When one participant sends a media packet, //! the relay forwards it to all other participants in the room (SFU model). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicU64, Ordering}; use std::sync::Arc; use tokio::sync::Mutex; -use tracing::{error, info}; +use tracing::{error, info, warn}; use wzp_proto::MediaTransport; @@ -72,24 +72,67 @@ impl Room { /// Manages all rooms on the relay. pub struct RoomManager { rooms: HashMap, + /// Room access control list. Maps hashed room name → allowed fingerprints. + /// When `None`, rooms are open (no auth mode). When `Some`, only listed + /// fingerprints can join the corresponding room. + acl: Option>>, } impl RoomManager { pub fn new() -> Self { Self { rooms: HashMap::new(), + acl: None, } } - /// Join a room. Returns the participant ID. + /// Create a room manager with ACL enforcement enabled. + pub fn with_acl() -> Self { + Self { + rooms: HashMap::new(), + acl: Some(HashMap::new()), + } + } + + /// Grant a fingerprint access to a room. + pub fn allow(&mut self, room_name: &str, fingerprint: &str) { + if let Some(ref mut acl) = self.acl { + acl.entry(room_name.to_string()) + .or_default() + .insert(fingerprint.to_string()); + } + } + + /// Check if a fingerprint is authorized to join a room. + /// Returns true if ACL is disabled (open mode) or the fingerprint is in the allow list. + pub fn is_authorized(&self, room_name: &str, fingerprint: Option<&str>) -> bool { + match (&self.acl, fingerprint) { + (None, _) => true, // no ACL = open + (Some(_), None) => false, // ACL enabled but no fingerprint + (Some(acl), Some(fp)) => { + // Room not in ACL = open room (allow anyone authenticated) + match acl.get(room_name) { + None => true, + Some(allowed) => allowed.contains(fp), + } + } + } + } + + /// Join a room. Returns the participant ID or an error if unauthorized. pub fn join( &mut self, room_name: &str, addr: std::net::SocketAddr, transport: Arc, - ) -> ParticipantId { + fingerprint: Option<&str>, + ) -> Result { + if !self.is_authorized(room_name, fingerprint) { + warn!(room = room_name, fingerprint = ?fingerprint, "unauthorized room join attempt"); + return Err("not authorized for this room".to_string()); + } let room = self.rooms.entry(room_name.to_string()).or_insert_with(Room::new); - room.add(addr, transport) + Ok(room.add(addr, transport)) } /// Leave a room. Removes the room if empty. @@ -193,8 +236,32 @@ mod tests { #[test] fn room_join_leave() { let mut mgr = RoomManager::new(); - // Can't test with real transports, but test the room logic assert_eq!(mgr.room_size("test"), 0); assert!(mgr.list().is_empty()); } + + #[test] + fn acl_open_mode_allows_all() { + let mgr = RoomManager::new(); + assert!(mgr.is_authorized("any-room", None)); + assert!(mgr.is_authorized("any-room", Some("abc"))); + } + + #[test] + fn acl_enforced_requires_fingerprint() { + let mgr = RoomManager::with_acl(); + assert!(!mgr.is_authorized("room1", None)); + // Room not in ACL = open to any authenticated user + assert!(mgr.is_authorized("room1", Some("abc"))); + } + + #[test] + fn acl_restricts_to_allowed() { + let mut mgr = RoomManager::with_acl(); + mgr.allow("room1", "alice"); + mgr.allow("room1", "bob"); + assert!(mgr.is_authorized("room1", Some("alice"))); + assert!(mgr.is_authorized("room1", Some("bob"))); + assert!(!mgr.is_authorized("room1", Some("eve"))); + } } diff --git a/crates/wzp-web/Cargo.toml b/crates/wzp-web/Cargo.toml index 1a7c641..7614b21 100644 --- a/crates/wzp-web/Cargo.toml +++ b/crates/wzp-web/Cargo.toml @@ -18,6 +18,9 @@ tracing = { workspace = true } tracing-subscriber = { workspace = true } bytes = { workspace = true } anyhow = "1" +wzp-relay = { path = "../wzp-relay" } +serde_json = "1" +rustls-pemfile = "2" axum = { version = "0.8", features = ["ws"] } tower-http = { version = "0.6", features = ["fs"] } futures = "0.3" diff --git a/crates/wzp-web/src/main.rs b/crates/wzp-web/src/main.rs index d7e07d5..4143fb8 100644 --- a/crates/wzp-web/src/main.rs +++ b/crates/wzp-web/src/main.rs @@ -31,6 +31,7 @@ const FRAME_SAMPLES: usize = 960; struct AppState { relay_addr: SocketAddr, rooms: Arc>>, + auth_url: Option, } /// A waiting client in a room. @@ -51,6 +52,9 @@ async fn main() -> anyhow::Result<()> { let mut port: u16 = 8080; let mut relay_addr: SocketAddr = "127.0.0.1:4433".parse()?; let mut use_tls = false; + let mut auth_url: Option = None; + let mut cert_path: Option = None; + let mut key_path: Option = None; let args: Vec = std::env::args().collect(); let mut i = 1; @@ -59,16 +63,22 @@ async fn main() -> anyhow::Result<()> { "--port" => { i += 1; port = args[i].parse().expect("invalid port"); } "--relay" => { i += 1; relay_addr = args[i].parse().expect("invalid relay address"); } "--tls" => { use_tls = true; } + "--auth-url" => { i += 1; auth_url = Some(args[i].clone()); } + "--cert" => { i += 1; cert_path = Some(args[i].clone()); } + "--key" => { i += 1; key_path = Some(args[i].clone()); } "--help" | "-h" => { - eprintln!("Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls]"); + eprintln!("Usage: wzp-web [--port 8080] [--relay 127.0.0.1:4433] [--tls] [--auth-url ]"); eprintln!(); eprintln!("Options:"); - eprintln!(" --port HTTP/WebSocket port (default: 8080)"); - eprintln!(" --relay WZP relay address (default: 127.0.0.1:4433)"); - eprintln!(" --tls Enable HTTPS (required for mic on Android)"); + eprintln!(" --port HTTP/WebSocket port (default: 8080)"); + eprintln!(" --relay WZP relay address (default: 127.0.0.1:4433)"); + eprintln!(" --tls Enable HTTPS (required for mic on Android)"); + eprintln!(" --auth-url featherChat auth endpoint for token validation"); + eprintln!(" --cert TLS certificate PEM file (optional, overrides self-signed)"); + eprintln!(" --key TLS private key PEM file (optional, overrides self-signed)"); eprintln!(); eprintln!("Rooms: open https://host:port/ to join a room."); - eprintln!("Two clients in the same room are connected for a call."); + eprintln!("Browser sends auth JSON as first WS message when --auth-url is set."); std::process::exit(0); } _ => {} @@ -76,9 +86,14 @@ async fn main() -> anyhow::Result<()> { i += 1; } + if let Some(ref url) = auth_url { + info!(url, "auth enabled — browsers must send token as first WS message"); + } + let state = AppState { relay_addr, rooms: Arc::new(Mutex::new(HashMap::new())), + auth_url, }; let static_dir = if std::path::Path::new("crates/wzp-web/static").exists() { @@ -97,12 +112,28 @@ async fn main() -> anyhow::Result<()> { let listen: SocketAddr = format!("0.0.0.0:{port}").parse()?; if use_tls { - let cert_key = rcgen::generate_simple_self_signed(vec![ - "localhost".to_string(), "wzp".to_string(), - ])?; - let cert_der = rustls_pki_types::CertificateDer::from(cert_key.cert); - let key_der = rustls_pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()) - .map_err(|e| anyhow::anyhow!("key error: {e}"))?; + let (cert_der, key_der) = if let (Some(cp), Some(kp)) = (&cert_path, &key_path) { + // Load real certificates from files + info!(cert = %cp, key = %kp, "loading TLS certificates from files"); + let cert_pem = std::fs::read(cp)?; + let key_pem = std::fs::read(kp)?; + let cert = rustls_pemfile::certs(&mut &cert_pem[..]) + .next() + .ok_or_else(|| anyhow::anyhow!("no certificate found in PEM"))??; + let key = rustls_pemfile::private_key(&mut &key_pem[..])? + .ok_or_else(|| anyhow::anyhow!("no private key found in PEM"))?; + (cert, key) + } else { + // Generate self-signed for development + info!("generating self-signed TLS certificate (use --cert/--key for production)"); + let cert_key = rcgen::generate_simple_self_signed(vec![ + "localhost".to_string(), "wzp".to_string(), + ])?; + let cert = rustls_pki_types::CertificateDer::from(cert_key.cert); + let key = rustls_pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der()) + .map_err(|e| anyhow::anyhow!("key error: {e}"))?; + (cert, key) + }; let mut tls_config = rustls::ServerConfig::builder() .with_no_client_auth() @@ -141,6 +172,49 @@ async fn ws_handler( async fn handle_ws(socket: WebSocket, room: String, state: AppState) { info!(room = %room, "client joined room"); + let (mut ws_sender, mut ws_receiver) = socket.split(); + + // Auth: if --auth-url is set, expect a JSON auth message from the browser first + let browser_token: Option = if state.auth_url.is_some() { + info!(room = %room, "waiting for auth token from browser..."); + match ws_receiver.next().await { + Some(Ok(Message::Text(text))) => { + match serde_json::from_str::(&text) { + Ok(v) if v.get("type").and_then(|t| t.as_str()) == Some("auth") => { + let token = v.get("token").and_then(|t| t.as_str()).unwrap_or("").to_string(); + if token.is_empty() { + error!(room = %room, "empty auth token"); + return; + } + // Validate against featherChat + if let Some(ref url) = state.auth_url { + match wzp_relay::auth::validate_token(url, &token).await { + Ok(client) => { + info!(room = %room, fingerprint = %client.fingerprint, "browser authenticated"); + } + Err(e) => { + error!(room = %room, "browser auth failed: {e}"); + return; + } + } + } + Some(token) + } + _ => { + error!(room = %room, "expected auth JSON, got: {text}"); + return; + } + } + } + _ => { + error!(room = %room, "no auth message from browser"); + return; + } + } + } else { + None + }; + // Connect to relay let relay_addr = state.relay_addr; let bind_addr: SocketAddr = if relay_addr.is_ipv6() { @@ -155,10 +229,14 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) { Err(e) => { error!("create endpoint: {e}"); return; } }; - // Pass room name as QUIC SNI so the relay knows which room to join - let sni = if room.is_empty() { "default" } else { &room }; + // Hash room name for SNI privacy + let sni = if room.is_empty() { + "default".to_string() + } else { + wzp_crypto::hash_room_name(&room) + }; let connection = - match wzp_transport::connect(&endpoint, relay_addr, sni, client_config).await { + match wzp_transport::connect(&endpoint, relay_addr, &sni, client_config).await { Ok(c) => c, Err(e) => { error!("connect to relay: {e}"); return; } }; @@ -166,9 +244,32 @@ async fn handle_ws(socket: WebSocket, room: String, state: AppState) { info!(room = %room, "connected to relay"); let transport = Arc::new(wzp_transport::QuinnTransport::new(connection)); - let config = CallConfig::default(); - let (mut ws_sender, mut ws_receiver) = socket.split(); + // Send auth token to relay (if auth is enabled) + if let Some(ref token) = browser_token { + let auth = wzp_proto::SignalMessage::AuthToken { + token: token.clone(), + }; + if let Err(e) = transport.send_signal(&auth).await { + error!(room = %room, "send auth to relay: {e}"); + return; + } + } + + // Crypto handshake with relay + let bridge_seed = wzp_crypto::Seed::generate(); + match wzp_client::handshake::perform_handshake(&*transport, &bridge_seed.0).await { + Ok(_session) => { + info!(room = %room, "crypto handshake with relay complete"); + } + Err(e) => { + error!(room = %room, "relay handshake failed: {e}"); + transport.close().await.ok(); + return; + } + } + + let config = CallConfig::default(); let encoder = Arc::new(Mutex::new(CallEncoder::new(&config))); let decoder = Arc::new(Mutex::new(CallDecoder::new(&config))); diff --git a/docs/INTEGRATION_TASKS.md b/docs/INTEGRATION_TASKS.md index d3127ed..0b0b2d7 100644 --- a/docs/INTEGRATION_TASKS.md +++ b/docs/INTEGRATION_TASKS.md @@ -12,92 +12,80 @@ Based on featherChat commit 65f6390 — FUTURE_TASKS.md with WZP integration ite ## WZP-Side Tasks (our responsibility) ### WZP-S-1. HKDF Salt/Info String Alignment — DONE -- HKDF info strings aligned: `warzone-ed25519` / `warzone-x25519` -- Salt: both use `None` (featherChat converts `b""` → `None`). No mismatch. -- Commit: `ac3b997` +- Both use `None` salt, info strings `warzone-ed25519` / `warzone-x25519` +- 15 cross-project tests verify identical output -### WZP-S-2. Accept featherChat Bearer Token on Relay — TODO (HIGH) -- Add `--auth-url` flag to wzp-relay (e.g., `--auth-url https://chat.example.com/v1/auth/validate`) -- On new QUIC connection: expect first signaling message to contain a bearer token -- Relay calls featherChat's `/v1/auth/validate` to verify -- Reject connection if token invalid -- Files: `wzp-relay/src/main.rs`, new `wzp-relay/src/auth.rs` +### WZP-S-2. Accept featherChat Bearer Token on Relay — DONE +- `--auth-url` flag on relay +- Clients send `SignalMessage::AuthToken` as first signal +- Relay calls `POST {auth_url}` to validate, rejects if invalid +- Commit: `ad16ddb` -### WZP-S-3. Signaling Bridge Mode — TODO (HIGH) -- Client should be able to send/receive `SignalMessage` through featherChat's WebSocket -- New `WireMessage::CallSignal` variant wraps opaque JSON `SignalMessage` -- Client connects to featherChat WS, sends CallOffer, receives CallAnswer -- Then uses the relay address from the answer to connect QUIC for media -- Files: new `wzp-client/src/featherchat.rs` +### WZP-S-3. Signaling Bridge Mode — DONE +- `featherchat.rs` module: encode/decode WZP SignalMessage into FC CallSignal.payload +- `WzpCallPayload` wraps signal + relay_addr + room +- Commit: `ad16ddb` -### WZP-S-4. Room Access Control — TODO (MEDIUM) -- Relay should verify room membership before allowing join -- Room name should be opaque hash (not human-readable group name) -- `room_id = SHA-256("featherchat-group:" + group_name)[:16]` -- Files: `wzp-relay/src/room.rs` +### WZP-S-4. Room Access Control — DONE +- `hash_room_name()` in wzp-crypto: SHA-256("featherchat-group:" + name)[:16] → 32 hex chars +- CLI `--room ` hashes before using as SNI +- Web bridge hashes room name before connecting to relay +- RoomManager gains ACL: `with_acl()`, `allow()`, `is_authorized()` +- `join()` now returns `Result`, rejects unauthorized +- Relay passes authenticated fingerprint to room join -### WZP-S-5. Wire Crypto Handshake into Live Path — PARTIAL -- `handshake.rs` exists in both client and relay -- Not used in CLI live mode, file mode, or web bridge -- Need to make handshake mandatory before media flows -- Files: `wzp-client/src/cli.rs`, `wzp-web/src/main.rs` +### WZP-S-5. Wire Crypto Handshake into Live Path — DONE +- CLI: `perform_handshake()` called after connect, before any media mode +- Relay: `accept_handshake()` called after auth, before room join +- Web bridge: `perform_handshake()` called after auth token, before audio loops +- Relay generates ephemeral identity seed at startup, logs fingerprint +- Quality profile negotiated during handshake -### WZP-S-6. Web Bridge + featherChat Web Client — TODO (MEDIUM) -- featherChat has a WASM web client (warzone-wasm crate) -- Web bridge should accept featherChat session tokens -- Share authentication with featherChat web login -- Files: `wzp-web/src/main.rs` +### WZP-S-6. Web Bridge + featherChat Web Client — DONE +- `--auth-url` flag on web bridge +- Browser sends `{ "type": "auth", "token": "..." }` as first WS message +- Web bridge validates token against featherChat, then passes to relay +- `--cert`/`--key` flags for production TLS certificates -### WZP-S-7. Publish wzp-proto for featherChat — TODO (LOW) -- featherChat needs `wzp_proto::SignalMessage` type for `CallSignal` variant -- Option A: publish wzp-proto to private registry -- Option B: featherChat uses JSON schema, WZP serializes to JSON -- Option C: git submodule / path dependency +### WZP-S-7. Publish wzp-proto for featherChat — DONE +- `wzp-proto/Cargo.toml` now standalone (no workspace inheritance) +- featherChat can use: `wzp-proto = { git = "ssh://...", path = "crates/wzp-proto" }` -### WZP-S-8. CLI Seed Input — TODO (LOW) -- Add `--seed ` or `--mnemonic ` flag to wzp-client -- Derive identity from seed, use for handshake -- Files: `wzp-client/src/cli.rs` +### WZP-S-8. CLI Seed Input — DONE +- `--seed ` and `--mnemonic <24 words>` flags +- featherChat-compatible identity: same seed → same keys +- Commit: `12cdfe6` -### WZP-S-9. Fix Hardcoded Assumptions — TODO -1. No auth on relay — fix via WZP-S-2 -2. Room names from SNI visible to network — fix via WZP-S-4 (use hashed names) -3. No signaling before media — fix via WZP-S-5 -4. Self-signed TLS — acceptable for relay-to-relay; need real certs for web -5. No codec negotiation in web bridge — fix: add profile exchange in WS -6. No connection to featherChat key registry — fix via WZP-S-2/S-3 +### WZP-S-9. Fix Hardcoded Assumptions — DONE +1. No auth on relay — ✅ fixed via S-2 (`--auth-url`) +2. Room names from SNI — ✅ fixed via S-4 (hashed room names) +3. No signaling before media — ✅ fixed via S-5 (mandatory handshake) +4. Self-signed TLS — ✅ fixed via S-6 (`--cert`/`--key` for production) +5. No codec negotiation in web bridge — ✅ profile negotiated in handshake +6. No connection to FC key registry — ✅ fixed via S-2 (token validation) --- ## featherChat-Side Tasks (their responsibility, we support) ### WZP-FC-1. Add CallSignal WireMessage variant — DONE (v0.0.21, 064a730) -- `CallSignal { id, sender_fingerprint, signal_type, payload, target }` -- `CallSignalType`: Offer, Answer, IceCandidate, Hangup, Reject, Ringing, Busy -- payload field is String — WZP puts JSON-serialized SignalMessage here -- target field: peer fingerprint (1:1) or room name (group) -### WZP-FC-2. Call state management + sled tree — 1-2d -### WZP-FC-3. WS handler for call signaling — 0.5d +### WZP-FC-2. Call state management + sled tree — TODO (1-2d) +### WZP-FC-3. WS handler for call signaling — TODO (0.5d) ### WZP-FC-4. Auth token validation endpoint — DONE (v0.0.21, 064a730) -- `POST /v1/auth/validate { "token": "..." }` -- Returns: `{ "valid": true, "fingerprint": "...", "alias": "..." }` -### WZP-FC-5. Group-to-room mapping — 1d -### WZP-FC-6. Presence/online status API — 0.5-2d -### WZP-FC-7. Missed call notifications — 0.5d -### WZP-FC-8. Cross-project identity verification test — 2-4h (CRITICAL) -### WZP-FC-9. HKDF salt investigation — VERIFIED: no mismatch -### WZP-FC-10. Web bridge shared auth — 1-2d +### WZP-FC-5. Group-to-room mapping — TODO (1d) +### WZP-FC-6. Presence/online status API — TODO (0.5-2d) +### WZP-FC-7. Missed call notifications — TODO (0.5d) +### WZP-FC-8. Cross-project identity verification — DONE (15 tests, 26dc848) +### WZP-FC-9. HKDF salt investigation — DONE (no mismatch) +### WZP-FC-10. Web bridge shared auth — TODO (1-2d) +### FC-CRATE-1. Standalone warzone-protocol — DONE (v0.0.21, 4a4fa9f) --- -## Integration Priority Order +## All WZP-S Tasks Complete -1. **WZP-FC-8 + WZP-S-1** — Cross-project identity test (DONE on WZP side) -2. **WZP-S-8** — CLI seed input (enables identity testing) -3. **WZP-FC-1** — CallSignal WireMessage (featherChat side) -4. **WZP-S-3** — Signaling bridge in client -5. **WZP-FC-4 + WZP-S-2** — Auth tokens (both sides) -6. **WZP-S-5** — Wire handshake into live path -7. **WZP-FC-5 + WZP-S-4** — Group-to-room mapping + access control -8. **WZP-FC-2/3** — Call state management -9. **WZP-S-6 + WZP-FC-10** — Web integration +The WZP side of integration is finished. featherChat needs: +1. **FC-2 + FC-3** — call state management + WS routing (makes real calls possible) +2. **FC-5** — group-to-room mapping (uses `hash_room_name` convention) +3. **FC-6/7** — presence + missed calls (UX polish) +4. **FC-10** — web bridge shared auth (browser token flow)