feat: complete all WZP-S integration tasks (S-4/5/6/7/9)

WZP-S-4: Room access control
- hash_room_name() in wzp-crypto: SHA-256("featherchat-group:"+name)[:16]
- CLI --room flag hashes before SNI, web bridge does the same
- RoomManager gains ACL: with_acl(), allow(), is_authorized()
- join() returns Result, rejects unauthorized fingerprints

WZP-S-5: Crypto handshake wired into all live paths
- CLI: perform_handshake() after connect, before any mode
- Relay: accept_handshake() after auth, before room join
- Web bridge: perform_handshake() after auth, before audio
- Relay generates ephemeral identity at startup

WZP-S-6: Web bridge featherChat auth
- --auth-url flag: browsers send {"type":"auth","token":"..."} as first WS msg
- Validates against featherChat, passes token to relay
- --cert/--key flags for production TLS (replaces self-signed)

WZP-S-7: wzp-proto standalone
- Cargo.toml uses explicit versions (no workspace inheritance)
- FC can use as git dependency

WZP-S-9: All 6 hardcoded assumptions resolved
- Auth, hashed rooms, mandatory handshake, real TLS certs,
  profile negotiation, token validation

CLI also gains --room and --token flags.
179 tests passing across all crates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Siavash Sameni
2026-03-28 09:59:05 +04:00
parent 26dc848081
commit 59069bfba2
10 changed files with 380 additions and 110 deletions

View File

@@ -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<String> = 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(

View File

@@ -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<String, Room>,
/// 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<HashMap<String, HashSet<String>>>,
}
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<wzp_transport::QuinnTransport>,
) -> ParticipantId {
fingerprint: Option<&str>,
) -> Result<ParticipantId, String> {
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")));
}
}