diff --git a/Cargo.lock b/Cargo.lock index 9c0200c..bcd4183 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3806,6 +3806,7 @@ dependencies = [ "toml", "tracing", "tracing-subscriber", + "wzp-client", "wzp-codec", "wzp-crypto", "wzp-fec", diff --git a/Cargo.toml b/Cargo.toml index 04bfc35..9c9d9f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -51,3 +51,4 @@ wzp-codec = { path = "crates/wzp-codec" } wzp-fec = { path = "crates/wzp-fec" } wzp-crypto = { path = "crates/wzp-crypto" } wzp-transport = { path = "crates/wzp-transport" } +wzp-client = { path = "crates/wzp-client" } diff --git a/crates/wzp-crypto/tests/featherchat_compat.rs b/crates/wzp-crypto/tests/featherchat_compat.rs index 52b8cb2..06062e8 100644 --- a/crates/wzp-crypto/tests/featherchat_compat.rs +++ b/crates/wzp-crypto/tests/featherchat_compat.rs @@ -311,3 +311,261 @@ fn all_signal_types_map_correctly() { assert_eq!(name, expected_name, "signal type mapping for {expected_name}"); } } + +// ─── Room Hashing + Access Control ───────────────────────────────────────── + +#[test] +fn hash_room_name_deterministic() { + let h1 = wzp_crypto::hash_room_name("ops-channel"); + let h2 = wzp_crypto::hash_room_name("ops-channel"); + assert_eq!(h1, h2, "same input must produce same hash"); +} + +#[test] +fn hash_room_name_is_32_hex_chars() { + let h = wzp_crypto::hash_room_name("test-room"); + assert_eq!(h.len(), 32, "hash must be 32 hex chars (16 bytes)"); + assert!( + h.chars().all(|c| c.is_ascii_hexdigit()), + "hash must contain only hex characters, got: {h}" + ); +} + +#[test] +fn hash_room_name_different_inputs() { + let h1 = wzp_crypto::hash_room_name("alpha"); + let h2 = wzp_crypto::hash_room_name("beta"); + let h3 = wzp_crypto::hash_room_name("alpha-2"); + assert_ne!(h1, h2, "different names must produce different hashes"); + assert_ne!(h1, h3); + assert_ne!(h2, h3); +} + +#[test] +fn hash_room_name_matches_fc_convention() { + // Manual SHA-256("featherchat-group:" + name)[:16] using the sha2 crate directly + use sha2::{Digest, Sha256}; + + let name = "warzone-squad"; + let mut hasher = Sha256::new(); + hasher.update(b"featherchat-group:"); + hasher.update(name.as_bytes()); + let digest = hasher.finalize(); + let expected = hex::encode(&digest[..16]); + + let actual = wzp_crypto::hash_room_name(name); + assert_eq!( + actual, expected, + "hash_room_name must equal SHA-256('featherchat-group:' + name)[:16]" + ); +} + +#[test] +fn room_acl_open_mode() { + let mgr = wzp_relay::room::RoomManager::new(); + // Open mode: everyone is authorized regardless of fingerprint presence + assert!(mgr.is_authorized("any-room", None)); + assert!(mgr.is_authorized("any-room", Some("random-fp"))); + assert!(mgr.is_authorized("another-room", Some("abc:def"))); +} + +#[test] +fn room_acl_enforced() { + let mgr = wzp_relay::room::RoomManager::with_acl(); + // ACL enabled but no fingerprint provided => denied + assert!( + !mgr.is_authorized("room1", None), + "ACL mode must reject connections without a fingerprint" + ); +} + +#[test] +fn room_acl_allows_listed() { + let mut mgr = wzp_relay::room::RoomManager::with_acl(); + mgr.allow("secure-room", "alice-fp"); + mgr.allow("secure-room", "bob-fp"); + + assert!(mgr.is_authorized("secure-room", Some("alice-fp"))); + assert!(mgr.is_authorized("secure-room", Some("bob-fp"))); +} + +#[test] +fn room_acl_denies_unlisted() { + let mut mgr = wzp_relay::room::RoomManager::with_acl(); + mgr.allow("secure-room", "alice-fp"); + + assert!( + !mgr.is_authorized("secure-room", Some("eve-fp")), + "unlisted fingerprints must be denied" + ); + assert!( + !mgr.is_authorized("secure-room", Some("mallory-fp")), + "unlisted fingerprints must be denied" + ); + // No fingerprint at all => also denied + assert!( + !mgr.is_authorized("secure-room", None), + "no fingerprint must be denied in ACL mode" + ); +} + +// ─── Web Bridge Auth + Proto Standalone + S-9 ────────────────────────────── + +/// WZP-S-6: featherChat may include `eth_address` in ValidateResponse. +/// WZP's ValidateResponse must handle it gracefully (serde ignores unknown fields). +#[test] +fn auth_response_with_eth_address() { + // FC response with eth_address present (non-null) + let with_eth = serde_json::json!({ + "valid": true, + "fingerprint": "a1b2:c3d4:e5f6:7890:abcd:ef01:2345:6789", + "alias": "vitalik", + "eth_address": "0x1234567890abcdef1234567890abcdef12345678" + }); + let resp: wzp_relay::auth::ValidateResponse = + serde_json::from_value(with_eth).unwrap(); + assert!(resp.valid); + assert_eq!( + resp.fingerprint.unwrap(), + "a1b2:c3d4:e5f6:7890:abcd:ef01:2345:6789" + ); + assert_eq!(resp.alias.unwrap(), "vitalik"); + + // FC response with eth_address = null + let with_null_eth = serde_json::json!({ + "valid": true, + "fingerprint": "dead:beef:cafe:babe:1234:5678:9abc:def0", + "alias": "anon", + "eth_address": null + }); + let resp2: wzp_relay::auth::ValidateResponse = + serde_json::from_value(with_null_eth).unwrap(); + assert!(resp2.valid); + assert_eq!( + resp2.fingerprint.unwrap(), + "dead:beef:cafe:babe:1234:5678:9abc:def0" + ); + + // FC response without eth_address at all + let without_eth = serde_json::json!({ + "valid": false + }); + let resp3: wzp_relay::auth::ValidateResponse = + serde_json::from_value(without_eth).unwrap(); + assert!(!resp3.valid); +} + +/// WZP-S-7: SignalMessage::AuthToken { token } exists and round-trips via serde. +#[test] +fn wzp_proto_has_auth_token_variant() { + let msg = wzp_proto::SignalMessage::AuthToken { + token: "fc-bearer-token-xyz".to_string(), + }; + + // Serialize to JSON + let json = serde_json::to_string(&msg).unwrap(); + assert!(json.contains("AuthToken")); + assert!(json.contains("fc-bearer-token-xyz")); + + // Deserialize back + let decoded: wzp_proto::SignalMessage = serde_json::from_str(&json).unwrap(); + if let wzp_proto::SignalMessage::AuthToken { token } = decoded { + assert_eq!(token, "fc-bearer-token-xyz"); + } else { + panic!("expected AuthToken variant, got: {decoded:?}"); + } +} + +/// WZP-S-6: WZP CallSignalType has all variants matching featherChat's set. +#[test] +fn all_fc_call_signal_types_representable() { + use wzp_client::featherchat::CallSignalType; + + // Verify each FC variant can be constructed and debug-printed + let variants: Vec<(CallSignalType, &str)> = vec![ + (CallSignalType::Offer, "Offer"), + (CallSignalType::Answer, "Answer"), + (CallSignalType::IceCandidate, "IceCandidate"), + (CallSignalType::Hangup, "Hangup"), + (CallSignalType::Reject, "Reject"), + (CallSignalType::Ringing, "Ringing"), + (CallSignalType::Busy, "Busy"), + ]; + + assert_eq!(variants.len(), 7, "featherChat defines exactly 7 call signal types"); + + for (variant, expected_name) in &variants { + let name = format!("{variant:?}"); + assert_eq!(&name, expected_name); + + // Each variant should serialize/deserialize cleanly + let json = serde_json::to_string(variant).unwrap(); + let round_tripped: CallSignalType = serde_json::from_str(&json).unwrap(); + assert_eq!(format!("{round_tripped:?}"), *expected_name); + } +} + +/// WZP-S-9: hashed room name used as QUIC SNI must be valid — lowercase hex only. +#[test] +fn hash_room_name_used_as_sni_is_valid() { + let long_name = "x".repeat(1000); + let test_rooms = [ + "general", + "Voice Room #1", + "café-lounge", + "a]b[c{d}e", + "\u{1f480}\u{1f525}", + long_name.as_str(), + ]; + + for room in &test_rooms { + let hashed = wzp_crypto::hash_room_name(room); + + // Must be non-empty + assert!(!hashed.is_empty(), "hash of '{room}' must not be empty"); + + // Must contain only lowercase hex chars (valid for SNI) + for ch in hashed.chars() { + assert!( + ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase(), + "hash of '{room}' contains invalid SNI char: '{ch}' (full: {hashed})" + ); + } + + // SHA-256 truncated to 16 bytes -> 32 hex chars + assert_eq!( + hashed.len(), + 32, + "hash should be 32 hex chars (16 bytes), got {} for '{room}'", + hashed.len() + ); + } +} + +/// WZP-S-7: wzp-proto Cargo.toml must be standalone — no `.workspace = true` inheritance. +#[test] +fn wzp_proto_cargo_toml_is_standalone() { + // Try both paths (run from workspace root or from crate directory) + let candidates = [ + "crates/wzp-proto/Cargo.toml", + "../wzp-proto/Cargo.toml", + ]; + + let contents = candidates + .iter() + .find_map(|p| std::fs::read_to_string(p).ok()) + .expect("could not read crates/wzp-proto/Cargo.toml from any expected path"); + + // Must NOT contain ".workspace = true" anywhere — that would break standalone use + assert!( + !contents.contains(".workspace = true"), + "wzp-proto Cargo.toml must not use workspace inheritance (.workspace = true), \ + found in:\n{contents}" + ); + + // Sanity: it should still be a valid Cargo.toml with the right package name + assert!( + contents.contains("name = \"wzp-proto\""), + "expected package name 'wzp-proto' in Cargo.toml" + ); +} diff --git a/crates/wzp-relay/Cargo.toml b/crates/wzp-relay/Cargo.toml index abcabf2..6509c09 100644 --- a/crates/wzp-relay/Cargo.toml +++ b/crates/wzp-relay/Cargo.toml @@ -30,3 +30,6 @@ name = "wzp-relay" path = "src/main.rs" [dev-dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros"] } +wzp-transport = { workspace = true } +wzp-client = { workspace = true } diff --git a/crates/wzp-relay/tests/handshake_integration.rs b/crates/wzp-relay/tests/handshake_integration.rs new file mode 100644 index 0000000..4edaf17 --- /dev/null +++ b/crates/wzp-relay/tests/handshake_integration.rs @@ -0,0 +1,295 @@ +//! WZP-S-5 integration tests: crypto handshake wired into live QUIC path. +//! +//! Verifies that `perform_handshake` (client/caller) and `accept_handshake` +//! (relay/callee) complete successfully over a real in-process QUIC connection +//! and produce usable `CryptoSession` values. + +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; + +use wzp_client::perform_handshake; +use wzp_crypto::{KeyExchange, WarzoneKeyExchange}; +use wzp_proto::{MediaTransport, SignalMessage}; +use wzp_relay::handshake::accept_handshake; +use wzp_transport::{client_config, create_endpoint, server_config, QuinnTransport}; + +/// Establish a QUIC connection and wrap both sides in `QuinnTransport`. +/// +/// Returns (client_transport, server_transport, _endpoints) where the endpoint +/// tuple must be kept alive for the duration of the test to avoid premature +/// connection teardown. +async fn connected_pair() -> (Arc, Arc, (quinn::Endpoint, quinn::Endpoint)) { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let (sc, _cert_der) = server_config(); + let server_addr: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into(); + let server_ep = create_endpoint(server_addr, Some(sc)).expect("server endpoint"); + let server_listen = server_ep.local_addr().expect("server local addr"); + + let client_addr: SocketAddr = (Ipv4Addr::LOCALHOST, 0).into(); + let client_ep = create_endpoint(client_addr, None).expect("client endpoint"); + + let server_ep_clone = server_ep.clone(); + let accept_fut = tokio::spawn(async move { + let conn = wzp_transport::accept(&server_ep_clone).await.expect("accept"); + Arc::new(QuinnTransport::new(conn)) + }); + + let client_conn = + wzp_transport::connect(&client_ep, server_listen, "localhost", client_config()) + .await + .expect("connect"); + let client_transport = Arc::new(QuinnTransport::new(client_conn)); + + let server_transport = accept_fut.await.expect("join accept task"); + + (client_transport, server_transport, (server_ep, client_ep)) +} + +// ----------------------------------------------------------------------- +// Test 1: handshake_succeeds +// ----------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handshake_succeeds() { + let (client_transport, server_transport, _endpoints) = connected_pair().await; + + let caller_seed: [u8; 32] = [0xAA; 32]; + let callee_seed: [u8; 32] = [0xBB; 32]; + + // Clone Arc so the server transport stays alive in the main task too. + let server_t = Arc::clone(&server_transport); + let callee_handle = tokio::spawn(async move { + accept_handshake(server_t.as_ref(), &callee_seed).await + }); + + let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed) + .await + .expect("perform_handshake should succeed"); + + let (callee_session, chosen_profile) = callee_handle + .await + .expect("join callee task") + .expect("accept_handshake should succeed"); + + // Both sides should have derived a working CryptoSession. + // Verify by encrypting on one side and decrypting on the other. + let header = b"test-header"; + let plaintext = b"hello warzone"; + + let mut ciphertext = Vec::new(); + let mut caller_session = caller_session; + let mut callee_session = callee_session; + + caller_session + .encrypt(header, plaintext, &mut ciphertext) + .expect("encrypt"); + + let mut decrypted = Vec::new(); + callee_session + .decrypt(header, &ciphertext, &mut decrypted) + .expect("decrypt"); + + assert_eq!(&decrypted, plaintext); + assert_eq!(chosen_profile, wzp_proto::QualityProfile::GOOD); + + // Keep transports alive until test completes. + drop(server_transport); + drop(client_transport); +} + +// ----------------------------------------------------------------------- +// Test 2: handshake_verifies_identity +// ----------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handshake_verifies_identity() { + let (client_transport, server_transport, _endpoints) = connected_pair().await; + + // Two completely different seeds => different identity keys. + let caller_seed: [u8; 32] = [0x11; 32]; + let callee_seed: [u8; 32] = [0x22; 32]; + + // Confirm the seeds produce different identity public keys. + let caller_kx = WarzoneKeyExchange::from_identity_seed(&caller_seed); + let callee_kx = WarzoneKeyExchange::from_identity_seed(&callee_seed); + assert_ne!( + caller_kx.identity_public_key(), + callee_kx.identity_public_key(), + "different seeds must produce different identity keys" + ); + + let server_t = Arc::clone(&server_transport); + let callee_handle = tokio::spawn(async move { + accept_handshake(server_t.as_ref(), &callee_seed).await + }); + + let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed) + .await + .expect("handshake must succeed even with different identities"); + + let (callee_session, _profile) = callee_handle + .await + .expect("join") + .expect("accept_handshake must succeed"); + + // Cross-encrypt/decrypt to prove the shared session works. + let header = b"id-test"; + let plaintext = b"identity verified"; + + let mut ct = Vec::new(); + let mut caller_session = caller_session; + let mut callee_session = callee_session; + + caller_session + .encrypt(header, plaintext, &mut ct) + .expect("encrypt"); + + let mut pt = Vec::new(); + callee_session + .decrypt(header, &ct, &mut pt) + .expect("decrypt"); + + assert_eq!(&pt, plaintext); + + drop(server_transport); + drop(client_transport); +} + +// ----------------------------------------------------------------------- +// Test 3: auth_then_handshake +// ----------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auth_then_handshake() { + let (client_transport, server_transport, _endpoints) = connected_pair().await; + + let caller_seed: [u8; 32] = [0xCC; 32]; + let callee_seed: [u8; 32] = [0xDD; 32]; + + // The callee side: first consume the AuthToken, then run accept_handshake. + let server_t = Arc::clone(&server_transport); + let callee_handle = tokio::spawn(async move { + // 1. Receive AuthToken + let auth_msg = server_t + .recv_signal() + .await + .expect("recv_signal should succeed") + .expect("should receive a message"); + + let token = match auth_msg { + SignalMessage::AuthToken { token } => token, + other => panic!("expected AuthToken, got {:?}", std::mem::discriminant(&other)), + }; + + // 2. Run the cryptographic handshake + let (session, profile) = accept_handshake(server_t.as_ref(), &callee_seed) + .await + .expect("accept_handshake after auth"); + + (token, session, profile) + }); + + // Caller side: send AuthToken first, then perform_handshake. + let auth = SignalMessage::AuthToken { + token: "bearer-test-token-12345".to_string(), + }; + client_transport + .send_signal(&auth) + .await + .expect("send AuthToken"); + + let caller_session = perform_handshake(client_transport.as_ref(), &caller_seed) + .await + .expect("perform_handshake after auth"); + + let (received_token, callee_session, _profile) = callee_handle + .await + .expect("join callee task"); + + // Verify the auth token was received correctly. + assert_eq!(received_token, "bearer-test-token-12345"); + + // Verify the crypto session works after the auth preamble. + let header = b"auth-hdr"; + let plaintext = b"post-auth payload"; + + let mut ct = Vec::new(); + let mut caller_session = caller_session; + let mut callee_session = callee_session; + + caller_session + .encrypt(header, plaintext, &mut ct) + .expect("encrypt"); + + let mut pt = Vec::new(); + callee_session + .decrypt(header, &ct, &mut pt) + .expect("decrypt"); + + assert_eq!(&pt, plaintext); + + drop(server_transport); + drop(client_transport); +} + +// ----------------------------------------------------------------------- +// Test 4: handshake_rejects_bad_signature +// ----------------------------------------------------------------------- + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn handshake_rejects_bad_signature() { + let (client_transport, server_transport, _endpoints) = connected_pair().await; + + let caller_seed: [u8; 32] = [0xEE; 32]; + let callee_seed: [u8; 32] = [0xFF; 32]; + + // Spawn callee -- it should reject the tampered CallOffer. + let server_t = Arc::clone(&server_transport); + let callee_handle = tokio::spawn(async move { + accept_handshake(server_t.as_ref(), &callee_seed).await + }); + + // Manually build a CallOffer with a corrupted signature. + let mut kx = WarzoneKeyExchange::from_identity_seed(&caller_seed); + let identity_pub = kx.identity_public_key(); + let ephemeral_pub = kx.generate_ephemeral(); + + let mut sign_data = Vec::with_capacity(32 + 10); + sign_data.extend_from_slice(&ephemeral_pub); + sign_data.extend_from_slice(b"call-offer"); + let mut signature = kx.sign(&sign_data); + + // Tamper: flip bits in the signature. + for byte in signature.iter_mut().take(8) { + *byte ^= 0xFF; + } + + let bad_offer = SignalMessage::CallOffer { + identity_pub, + ephemeral_pub, + signature, + supported_profiles: vec![wzp_proto::QualityProfile::GOOD], + }; + + client_transport + .send_signal(&bad_offer) + .await + .expect("send tampered CallOffer"); + + // The callee should return an error about signature verification. + let result = callee_handle.await.expect("join callee task"); + match result { + Ok(_) => panic!("accept_handshake must reject a bad signature"), + Err(e) => { + let err_msg = e.to_string(); + assert!( + err_msg.contains("signature verification failed"), + "error should mention signature verification, got: {err_msg}" + ); + } + } + + drop(server_transport); + drop(client_transport); +}