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:
3
Cargo.lock
generated
3
Cargo.lock
generated
@@ -3839,7 +3839,9 @@ dependencies = [
|
|||||||
"futures",
|
"futures",
|
||||||
"rcgen",
|
"rcgen",
|
||||||
"rustls",
|
"rustls",
|
||||||
|
"rustls-pemfile",
|
||||||
"rustls-pki-types",
|
"rustls-pki-types",
|
||||||
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-rustls",
|
"tokio-rustls",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3850,6 +3852,7 @@ dependencies = [
|
|||||||
"wzp-crypto",
|
"wzp-crypto",
|
||||||
"wzp-fec",
|
"wzp-fec",
|
||||||
"wzp-proto",
|
"wzp-proto",
|
||||||
|
"wzp-relay",
|
||||||
"wzp-transport",
|
"wzp-transport",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ struct CliArgs {
|
|||||||
echo_test_secs: Option<u32>,
|
echo_test_secs: Option<u32>,
|
||||||
seed_hex: Option<String>,
|
seed_hex: Option<String>,
|
||||||
mnemonic: Option<String>,
|
mnemonic: Option<String>,
|
||||||
|
room: Option<String>,
|
||||||
|
token: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CliArgs {
|
impl CliArgs {
|
||||||
@@ -78,6 +80,8 @@ fn parse_args() -> CliArgs {
|
|||||||
let mut echo_test_secs = None;
|
let mut echo_test_secs = None;
|
||||||
let mut seed_hex = None;
|
let mut seed_hex = None;
|
||||||
let mut mnemonic = None;
|
let mut mnemonic = None;
|
||||||
|
let mut room = None;
|
||||||
|
let mut token = None;
|
||||||
let mut relay_str = None;
|
let mut relay_str = None;
|
||||||
|
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
@@ -116,6 +120,14 @@ fn parse_args() -> CliArgs {
|
|||||||
i -= 1; // back up since outer loop will increment
|
i -= 1; // back up since outer loop will increment
|
||||||
mnemonic = Some(words.join(" "));
|
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" => {
|
"--record" => {
|
||||||
i += 1;
|
i += 1;
|
||||||
record_file = Some(
|
record_file = Some(
|
||||||
@@ -144,6 +156,8 @@ fn parse_args() -> CliArgs {
|
|||||||
eprintln!(" --echo-test <secs> Run automated echo quality test");
|
eprintln!(" --echo-test <secs> Run automated echo quality test");
|
||||||
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
|
eprintln!(" --seed <hex> Identity seed (64 hex chars, featherChat compatible)");
|
||||||
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
|
eprintln!(" --mnemonic <words...> Identity seed as BIP39 mnemonic (24 words)");
|
||||||
|
eprintln!(" --room <name> Room name (hashed for privacy before sending)");
|
||||||
|
eprintln!(" --token <token> featherChat bearer token for relay auth");
|
||||||
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
eprintln!(" (48kHz mono s16le, play with ffplay -f s16le -ar 48000 -ch_layout mono file.raw)");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Default relay: 127.0.0.1:4433");
|
eprintln!("Default relay: 127.0.0.1:4433");
|
||||||
@@ -175,6 +189,8 @@ fn parse_args() -> CliArgs {
|
|||||||
echo_test_secs,
|
echo_test_secs,
|
||||||
seed_hex,
|
seed_hex,
|
||||||
mnemonic,
|
mnemonic,
|
||||||
|
room,
|
||||||
|
token,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,16 +199,27 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
tracing_subscriber::fmt().init();
|
tracing_subscriber::fmt().init();
|
||||||
|
|
||||||
let cli = parse_args();
|
let cli = parse_args();
|
||||||
let _seed = cli.resolve_seed();
|
let seed = cli.resolve_seed();
|
||||||
|
|
||||||
info!(
|
info!(
|
||||||
relay = %cli.relay_addr,
|
relay = %cli.relay_addr,
|
||||||
live = cli.live,
|
live = cli.live,
|
||||||
send_tone = ?cli.send_tone_secs,
|
send_tone = ?cli.send_tone_secs,
|
||||||
record = ?cli.record_file,
|
record = ?cli.record_file,
|
||||||
|
room = ?cli.room,
|
||||||
"WarzonePhone client"
|
"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 client_config = wzp_transport::client_config();
|
||||||
let bind_addr = if cli.relay_addr.is_ipv6() {
|
let bind_addr = if cli.relay_addr.is_ipv6() {
|
||||||
"[::]:0".parse()?
|
"[::]:0".parse()?
|
||||||
@@ -201,12 +228,28 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
};
|
};
|
||||||
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
let endpoint = wzp_transport::create_endpoint(bind_addr, None)?;
|
||||||
let connection =
|
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");
|
info!("Connected to relay");
|
||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
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 {
|
if cli.live {
|
||||||
#[cfg(feature = "audio")]
|
#[cfg(feature = "audio")]
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -187,6 +187,22 @@ pub struct PublicIdentity {
|
|||||||
pub fingerprint: Fingerprint,
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -231,6 +247,20 @@ mod tests {
|
|||||||
assert_eq!(fp_str.chars().filter(|c| *c == ':').count(), 7);
|
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]
|
#[test]
|
||||||
fn matches_handshake_derivation() {
|
fn matches_handshake_derivation() {
|
||||||
use wzp_proto::KeyExchange;
|
use wzp_proto::KeyExchange;
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ pub mod session;
|
|||||||
|
|
||||||
pub use anti_replay::AntiReplayWindow;
|
pub use anti_replay::AntiReplayWindow;
|
||||||
pub use handshake::WarzoneKeyExchange;
|
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 nonce::{build_nonce, Direction};
|
||||||
pub use rekey::RekeyManager;
|
pub use rekey::RekeyManager;
|
||||||
pub use session::ChaChaSession;
|
pub use session::ChaChaSession;
|
||||||
|
|||||||
@@ -1,17 +1,21 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "wzp-proto"
|
name = "wzp-proto"
|
||||||
version.workspace = true
|
version = "0.1.0"
|
||||||
edition.workspace = true
|
edition = "2024"
|
||||||
license.workspace = true
|
license = "MIT OR Apache-2.0"
|
||||||
rust-version.workspace = true
|
rust-version = "1.85"
|
||||||
description = "WarzonePhone protocol types, traits, and core logic"
|
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]
|
[dependencies]
|
||||||
bytes = { workspace = true }
|
bytes = "1"
|
||||||
thiserror = { workspace = true }
|
thiserror = "2"
|
||||||
async-trait = { workspace = true }
|
async-trait = "0.1"
|
||||||
serde = { workspace = true }
|
serde = { version = "1", features = ["derive"] }
|
||||||
tracing = { workspace = true }
|
tracing = "0.1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { workspace = true }
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|||||||
@@ -140,7 +140,10 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
.install_default()
|
.install_default()
|
||||||
.expect("failed to install rustls crypto provider");
|
.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 (server_config, _cert) = wzp_transport::server_config();
|
||||||
let endpoint = wzp_transport::create_endpoint(config.listen_addr, Some(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 remote_transport = remote_transport.clone();
|
||||||
let room_mgr = room_mgr.clone();
|
let room_mgr = room_mgr.clone();
|
||||||
let auth_url = config.auth_url.clone();
|
let auth_url = config.auth_url.clone();
|
||||||
|
let relay_seed_bytes = relay_seed.0;
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let addr = connection.remote_address();
|
let addr = connection.remote_address();
|
||||||
@@ -192,7 +196,8 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
||||||
|
|
||||||
// Auth check: if --auth-url is set, expect first signal message to be a token
|
// 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...");
|
info!(%addr, "waiting for auth token...");
|
||||||
match transport.recv_signal().await {
|
match transport.recv_signal().await {
|
||||||
Ok(Some(wzp_proto::SignalMessage::AuthToken { token })) => {
|
Ok(Some(wzp_proto::SignalMessage::AuthToken { token })) => {
|
||||||
@@ -204,6 +209,7 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
alias = ?client.alias,
|
alias = ?client.alias,
|
||||||
"authenticated"
|
"authenticated"
|
||||||
);
|
);
|
||||||
|
Some(client.fingerprint)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!(%addr, "auth failed: {e}");
|
error!(%addr, "auth failed: {e}");
|
||||||
@@ -227,9 +233,27 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
return;
|
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 {
|
if let Some(remote) = remote_transport {
|
||||||
// Forward mode — same as before
|
// Forward mode — same as before
|
||||||
@@ -263,7 +287,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
// Room mode — join room and forward to all others
|
// Room mode — join room and forward to all others
|
||||||
let participant_id = {
|
let participant_id = {
|
||||||
let mut mgr = room_mgr.lock().await;
|
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(
|
room::run_participant(
|
||||||
|
|||||||
@@ -3,12 +3,12 @@
|
|||||||
//! Each room holds N participants. When one participant sends a media packet,
|
//! 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).
|
//! 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::atomic::{AtomicU64, Ordering};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
use tracing::{error, info};
|
use tracing::{error, info, warn};
|
||||||
|
|
||||||
use wzp_proto::MediaTransport;
|
use wzp_proto::MediaTransport;
|
||||||
|
|
||||||
@@ -72,24 +72,67 @@ impl Room {
|
|||||||
/// Manages all rooms on the relay.
|
/// Manages all rooms on the relay.
|
||||||
pub struct RoomManager {
|
pub struct RoomManager {
|
||||||
rooms: HashMap<String, Room>,
|
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 {
|
impl RoomManager {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self {
|
Self {
|
||||||
rooms: HashMap::new(),
|
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(
|
pub fn join(
|
||||||
&mut self,
|
&mut self,
|
||||||
room_name: &str,
|
room_name: &str,
|
||||||
addr: std::net::SocketAddr,
|
addr: std::net::SocketAddr,
|
||||||
transport: Arc<wzp_transport::QuinnTransport>,
|
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);
|
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.
|
/// Leave a room. Removes the room if empty.
|
||||||
@@ -193,8 +236,32 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn room_join_leave() {
|
fn room_join_leave() {
|
||||||
let mut mgr = RoomManager::new();
|
let mut mgr = RoomManager::new();
|
||||||
// Can't test with real transports, but test the room logic
|
|
||||||
assert_eq!(mgr.room_size("test"), 0);
|
assert_eq!(mgr.room_size("test"), 0);
|
||||||
assert!(mgr.list().is_empty());
|
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")));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ tracing = { workspace = true }
|
|||||||
tracing-subscriber = { workspace = true }
|
tracing-subscriber = { workspace = true }
|
||||||
bytes = { workspace = true }
|
bytes = { workspace = true }
|
||||||
anyhow = "1"
|
anyhow = "1"
|
||||||
|
wzp-relay = { path = "../wzp-relay" }
|
||||||
|
serde_json = "1"
|
||||||
|
rustls-pemfile = "2"
|
||||||
axum = { version = "0.8", features = ["ws"] }
|
axum = { version = "0.8", features = ["ws"] }
|
||||||
tower-http = { version = "0.6", features = ["fs"] }
|
tower-http = { version = "0.6", features = ["fs"] }
|
||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ const FRAME_SAMPLES: usize = 960;
|
|||||||
struct AppState {
|
struct AppState {
|
||||||
relay_addr: SocketAddr,
|
relay_addr: SocketAddr,
|
||||||
rooms: Arc<Mutex<HashMap<String, RoomSlot>>>,
|
rooms: Arc<Mutex<HashMap<String, RoomSlot>>>,
|
||||||
|
auth_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A waiting client in a room.
|
/// A waiting client in a room.
|
||||||
@@ -51,6 +52,9 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
let mut port: u16 = 8080;
|
let mut port: u16 = 8080;
|
||||||
let mut relay_addr: SocketAddr = "127.0.0.1:4433".parse()?;
|
let mut relay_addr: SocketAddr = "127.0.0.1:4433".parse()?;
|
||||||
let mut use_tls = false;
|
let mut use_tls = false;
|
||||||
|
let mut auth_url: Option<String> = None;
|
||||||
|
let mut cert_path: Option<String> = None;
|
||||||
|
let mut key_path: Option<String> = None;
|
||||||
|
|
||||||
let args: Vec<String> = std::env::args().collect();
|
let args: Vec<String> = std::env::args().collect();
|
||||||
let mut i = 1;
|
let mut i = 1;
|
||||||
@@ -59,16 +63,22 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
"--port" => { i += 1; port = args[i].parse().expect("invalid port"); }
|
"--port" => { i += 1; port = args[i].parse().expect("invalid port"); }
|
||||||
"--relay" => { i += 1; relay_addr = args[i].parse().expect("invalid relay address"); }
|
"--relay" => { i += 1; relay_addr = args[i].parse().expect("invalid relay address"); }
|
||||||
"--tls" => { use_tls = true; }
|
"--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" => {
|
"--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 <url>]");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Options:");
|
eprintln!("Options:");
|
||||||
eprintln!(" --port <port> HTTP/WebSocket port (default: 8080)");
|
eprintln!(" --port <port> HTTP/WebSocket port (default: 8080)");
|
||||||
eprintln!(" --relay <addr> WZP relay address (default: 127.0.0.1:4433)");
|
eprintln!(" --relay <addr> WZP relay address (default: 127.0.0.1:4433)");
|
||||||
eprintln!(" --tls Enable HTTPS (required for mic on Android)");
|
eprintln!(" --tls Enable HTTPS (required for mic on Android)");
|
||||||
|
eprintln!(" --auth-url <url> featherChat auth endpoint for token validation");
|
||||||
|
eprintln!(" --cert <path> TLS certificate PEM file (optional, overrides self-signed)");
|
||||||
|
eprintln!(" --key <path> TLS private key PEM file (optional, overrides self-signed)");
|
||||||
eprintln!();
|
eprintln!();
|
||||||
eprintln!("Rooms: open https://host:port/<room-name> to join a room.");
|
eprintln!("Rooms: open https://host:port/<room-name> 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);
|
std::process::exit(0);
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
@@ -76,9 +86,14 @@ async fn main() -> anyhow::Result<()> {
|
|||||||
i += 1;
|
i += 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(ref url) = auth_url {
|
||||||
|
info!(url, "auth enabled — browsers must send token as first WS message");
|
||||||
|
}
|
||||||
|
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
relay_addr,
|
relay_addr,
|
||||||
rooms: Arc::new(Mutex::new(HashMap::new())),
|
rooms: Arc::new(Mutex::new(HashMap::new())),
|
||||||
|
auth_url,
|
||||||
};
|
};
|
||||||
|
|
||||||
let static_dir = if std::path::Path::new("crates/wzp-web/static").exists() {
|
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()?;
|
let listen: SocketAddr = format!("0.0.0.0:{port}").parse()?;
|
||||||
|
|
||||||
if use_tls {
|
if use_tls {
|
||||||
let cert_key = rcgen::generate_simple_self_signed(vec![
|
let (cert_der, key_der) = if let (Some(cp), Some(kp)) = (&cert_path, &key_path) {
|
||||||
"localhost".to_string(), "wzp".to_string(),
|
// Load real certificates from files
|
||||||
])?;
|
info!(cert = %cp, key = %kp, "loading TLS certificates from files");
|
||||||
let cert_der = rustls_pki_types::CertificateDer::from(cert_key.cert);
|
let cert_pem = std::fs::read(cp)?;
|
||||||
let key_der = rustls_pki_types::PrivateKeyDer::try_from(cert_key.key_pair.serialize_der())
|
let key_pem = std::fs::read(kp)?;
|
||||||
.map_err(|e| anyhow::anyhow!("key error: {e}"))?;
|
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()
|
let mut tls_config = rustls::ServerConfig::builder()
|
||||||
.with_no_client_auth()
|
.with_no_client_auth()
|
||||||
@@ -141,6 +172,49 @@ async fn ws_handler(
|
|||||||
async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
|
async fn handle_ws(socket: WebSocket, room: String, state: AppState) {
|
||||||
info!(room = %room, "client joined room");
|
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<String> = 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::<serde_json::Value>(&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
|
// Connect to relay
|
||||||
let relay_addr = state.relay_addr;
|
let relay_addr = state.relay_addr;
|
||||||
let bind_addr: SocketAddr = if relay_addr.is_ipv6() {
|
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; }
|
Err(e) => { error!("create endpoint: {e}"); return; }
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pass room name as QUIC SNI so the relay knows which room to join
|
// Hash room name for SNI privacy
|
||||||
let sni = if room.is_empty() { "default" } else { &room };
|
let sni = if room.is_empty() {
|
||||||
|
"default".to_string()
|
||||||
|
} else {
|
||||||
|
wzp_crypto::hash_room_name(&room)
|
||||||
|
};
|
||||||
let connection =
|
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,
|
Ok(c) => c,
|
||||||
Err(e) => { error!("connect to relay: {e}"); return; }
|
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");
|
info!(room = %room, "connected to relay");
|
||||||
|
|
||||||
let transport = Arc::new(wzp_transport::QuinnTransport::new(connection));
|
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 encoder = Arc::new(Mutex::new(CallEncoder::new(&config)));
|
||||||
let decoder = Arc::new(Mutex::new(CallDecoder::new(&config)));
|
let decoder = Arc::new(Mutex::new(CallDecoder::new(&config)));
|
||||||
|
|
||||||
|
|||||||
@@ -12,92 +12,80 @@ Based on featherChat commit 65f6390 — FUTURE_TASKS.md with WZP integration ite
|
|||||||
## WZP-Side Tasks (our responsibility)
|
## WZP-Side Tasks (our responsibility)
|
||||||
|
|
||||||
### WZP-S-1. HKDF Salt/Info String Alignment — DONE
|
### WZP-S-1. HKDF Salt/Info String Alignment — DONE
|
||||||
- HKDF info strings aligned: `warzone-ed25519` / `warzone-x25519`
|
- Both use `None` salt, info strings `warzone-ed25519` / `warzone-x25519`
|
||||||
- Salt: both use `None` (featherChat converts `b""` → `None`). No mismatch.
|
- 15 cross-project tests verify identical output
|
||||||
- Commit: `ac3b997`
|
|
||||||
|
|
||||||
### WZP-S-2. Accept featherChat Bearer Token on Relay — TODO (HIGH)
|
### WZP-S-2. Accept featherChat Bearer Token on Relay — DONE
|
||||||
- Add `--auth-url` flag to wzp-relay (e.g., `--auth-url https://chat.example.com/v1/auth/validate`)
|
- `--auth-url` flag on relay
|
||||||
- On new QUIC connection: expect first signaling message to contain a bearer token
|
- Clients send `SignalMessage::AuthToken` as first signal
|
||||||
- Relay calls featherChat's `/v1/auth/validate` to verify
|
- Relay calls `POST {auth_url}` to validate, rejects if invalid
|
||||||
- Reject connection if token invalid
|
- Commit: `ad16ddb`
|
||||||
- Files: `wzp-relay/src/main.rs`, new `wzp-relay/src/auth.rs`
|
|
||||||
|
|
||||||
### WZP-S-3. Signaling Bridge Mode — TODO (HIGH)
|
### WZP-S-3. Signaling Bridge Mode — DONE
|
||||||
- Client should be able to send/receive `SignalMessage` through featherChat's WebSocket
|
- `featherchat.rs` module: encode/decode WZP SignalMessage into FC CallSignal.payload
|
||||||
- New `WireMessage::CallSignal` variant wraps opaque JSON `SignalMessage`
|
- `WzpCallPayload` wraps signal + relay_addr + room
|
||||||
- Client connects to featherChat WS, sends CallOffer, receives CallAnswer
|
- Commit: `ad16ddb`
|
||||||
- Then uses the relay address from the answer to connect QUIC for media
|
|
||||||
- Files: new `wzp-client/src/featherchat.rs`
|
|
||||||
|
|
||||||
### WZP-S-4. Room Access Control — TODO (MEDIUM)
|
### WZP-S-4. Room Access Control — DONE
|
||||||
- Relay should verify room membership before allowing join
|
- `hash_room_name()` in wzp-crypto: SHA-256("featherchat-group:" + name)[:16] → 32 hex chars
|
||||||
- Room name should be opaque hash (not human-readable group name)
|
- CLI `--room <name>` hashes before using as SNI
|
||||||
- `room_id = SHA-256("featherchat-group:" + group_name)[:16]`
|
- Web bridge hashes room name before connecting to relay
|
||||||
- Files: `wzp-relay/src/room.rs`
|
- RoomManager gains ACL: `with_acl()`, `allow()`, `is_authorized()`
|
||||||
|
- `join()` now returns `Result<ParticipantId, String>`, rejects unauthorized
|
||||||
|
- Relay passes authenticated fingerprint to room join
|
||||||
|
|
||||||
### WZP-S-5. Wire Crypto Handshake into Live Path — PARTIAL
|
### WZP-S-5. Wire Crypto Handshake into Live Path — DONE
|
||||||
- `handshake.rs` exists in both client and relay
|
- CLI: `perform_handshake()` called after connect, before any media mode
|
||||||
- Not used in CLI live mode, file mode, or web bridge
|
- Relay: `accept_handshake()` called after auth, before room join
|
||||||
- Need to make handshake mandatory before media flows
|
- Web bridge: `perform_handshake()` called after auth token, before audio loops
|
||||||
- Files: `wzp-client/src/cli.rs`, `wzp-web/src/main.rs`
|
- Relay generates ephemeral identity seed at startup, logs fingerprint
|
||||||
|
- Quality profile negotiated during handshake
|
||||||
|
|
||||||
### WZP-S-6. Web Bridge + featherChat Web Client — TODO (MEDIUM)
|
### WZP-S-6. Web Bridge + featherChat Web Client — DONE
|
||||||
- featherChat has a WASM web client (warzone-wasm crate)
|
- `--auth-url` flag on web bridge
|
||||||
- Web bridge should accept featherChat session tokens
|
- Browser sends `{ "type": "auth", "token": "..." }` as first WS message
|
||||||
- Share authentication with featherChat web login
|
- Web bridge validates token against featherChat, then passes to relay
|
||||||
- Files: `wzp-web/src/main.rs`
|
- `--cert`/`--key` flags for production TLS certificates
|
||||||
|
|
||||||
### WZP-S-7. Publish wzp-proto for featherChat — TODO (LOW)
|
### WZP-S-7. Publish wzp-proto for featherChat — DONE
|
||||||
- featherChat needs `wzp_proto::SignalMessage` type for `CallSignal` variant
|
- `wzp-proto/Cargo.toml` now standalone (no workspace inheritance)
|
||||||
- Option A: publish wzp-proto to private registry
|
- featherChat can use: `wzp-proto = { git = "ssh://...", path = "crates/wzp-proto" }`
|
||||||
- Option B: featherChat uses JSON schema, WZP serializes to JSON
|
|
||||||
- Option C: git submodule / path dependency
|
|
||||||
|
|
||||||
### WZP-S-8. CLI Seed Input — TODO (LOW)
|
### WZP-S-8. CLI Seed Input — DONE
|
||||||
- Add `--seed <hex>` or `--mnemonic <words>` flag to wzp-client
|
- `--seed <hex>` and `--mnemonic <24 words>` flags
|
||||||
- Derive identity from seed, use for handshake
|
- featherChat-compatible identity: same seed → same keys
|
||||||
- Files: `wzp-client/src/cli.rs`
|
- Commit: `12cdfe6`
|
||||||
|
|
||||||
### WZP-S-9. Fix Hardcoded Assumptions — TODO
|
### WZP-S-9. Fix Hardcoded Assumptions — DONE
|
||||||
1. No auth on relay — fix via WZP-S-2
|
1. No auth on relay — ✅ fixed via S-2 (`--auth-url`)
|
||||||
2. Room names from SNI visible to network — fix via WZP-S-4 (use hashed names)
|
2. Room names from SNI — ✅ fixed via S-4 (hashed room names)
|
||||||
3. No signaling before media — fix via WZP-S-5
|
3. No signaling before media — ✅ fixed via S-5 (mandatory handshake)
|
||||||
4. Self-signed TLS — acceptable for relay-to-relay; need real certs for web
|
4. Self-signed TLS — ✅ fixed via S-6 (`--cert`/`--key` for production)
|
||||||
5. No codec negotiation in web bridge — fix: add profile exchange in WS
|
5. No codec negotiation in web bridge — ✅ profile negotiated in handshake
|
||||||
6. No connection to featherChat key registry — fix via WZP-S-2/S-3
|
6. No connection to FC key registry — ✅ fixed via S-2 (token validation)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## featherChat-Side Tasks (their responsibility, we support)
|
## featherChat-Side Tasks (their responsibility, we support)
|
||||||
|
|
||||||
### WZP-FC-1. Add CallSignal WireMessage variant — DONE (v0.0.21, 064a730)
|
### WZP-FC-1. Add CallSignal WireMessage variant — DONE (v0.0.21, 064a730)
|
||||||
- `CallSignal { id, sender_fingerprint, signal_type, payload, target }`
|
### WZP-FC-2. Call state management + sled tree — TODO (1-2d)
|
||||||
- `CallSignalType`: Offer, Answer, IceCandidate, Hangup, Reject, Ringing, Busy
|
### WZP-FC-3. WS handler for call signaling — TODO (0.5d)
|
||||||
- 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-4. Auth token validation endpoint — DONE (v0.0.21, 064a730)
|
### WZP-FC-4. Auth token validation endpoint — DONE (v0.0.21, 064a730)
|
||||||
- `POST /v1/auth/validate { "token": "..." }`
|
### WZP-FC-5. Group-to-room mapping — TODO (1d)
|
||||||
- Returns: `{ "valid": true, "fingerprint": "...", "alias": "..." }`
|
### WZP-FC-6. Presence/online status API — TODO (0.5-2d)
|
||||||
### WZP-FC-5. Group-to-room mapping — 1d
|
### WZP-FC-7. Missed call notifications — TODO (0.5d)
|
||||||
### WZP-FC-6. Presence/online status API — 0.5-2d
|
### WZP-FC-8. Cross-project identity verification — DONE (15 tests, 26dc848)
|
||||||
### WZP-FC-7. Missed call notifications — 0.5d
|
### WZP-FC-9. HKDF salt investigation — DONE (no mismatch)
|
||||||
### WZP-FC-8. Cross-project identity verification test — 2-4h (CRITICAL)
|
### WZP-FC-10. Web bridge shared auth — TODO (1-2d)
|
||||||
### WZP-FC-9. HKDF salt investigation — VERIFIED: no mismatch
|
### FC-CRATE-1. Standalone warzone-protocol — DONE (v0.0.21, 4a4fa9f)
|
||||||
### WZP-FC-10. Web bridge shared auth — 1-2d
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Integration Priority Order
|
## All WZP-S Tasks Complete
|
||||||
|
|
||||||
1. **WZP-FC-8 + WZP-S-1** — Cross-project identity test (DONE on WZP side)
|
The WZP side of integration is finished. featherChat needs:
|
||||||
2. **WZP-S-8** — CLI seed input (enables identity testing)
|
1. **FC-2 + FC-3** — call state management + WS routing (makes real calls possible)
|
||||||
3. **WZP-FC-1** — CallSignal WireMessage (featherChat side)
|
2. **FC-5** — group-to-room mapping (uses `hash_room_name` convention)
|
||||||
4. **WZP-S-3** — Signaling bridge in client
|
3. **FC-6/7** — presence + missed calls (UX polish)
|
||||||
5. **WZP-FC-4 + WZP-S-2** — Auth tokens (both sides)
|
4. **FC-10** — web bridge shared auth (browser token flow)
|
||||||
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
|
|
||||||
|
|||||||
Reference in New Issue
Block a user