Lets a client ask its registered relay "what IP:port do you see for
me?" over the existing TLS-authenticated signal channel, returning
the client's server-reflexive address as a SocketAddr. Replaces the
need for a classic STUN deployment and becomes the bootstrap step
for future P2P hole-punching: once both peers know their own reflex
addrs, they can advertise them in DirectCallOffer and attempt a
direct QUIC handshake to each other.
Wire protocol (wzp-proto):
- SignalMessage::Reflect — unit variant, client -> relay
- SignalMessage::ReflectResponse { observed_addr: String } — relay -> client
- JSON-serde, appended at end of enum: zero ordinal concerns,
backward compat with pre-Phase-1 relays by construction (older
relays log "unexpected message" and drop; newer clients time out
cleanly within 1s).
Relay handler (wzp-relay/src/main.rs, signal loop):
- New match arm next to Ping reuses the already-bound `addr` from
connection.remote_address() and replies with observed_addr as a
string. debug!-level log on success, warn!-level on send failure.
Client side (desktop/src-tauri/src/lib.rs):
- SignalState gains pending_reflect: Option<oneshot::Sender<SocketAddr>>.
- get_reflected_address Tauri command installs the oneshot before
sending Reflect and awaits it with a 1s timeout; cleans up on
every exit path (send failure, timeout, parse error).
- recv loop's new ReflectResponse arm fires the pending sender or
emits a debug log for unsolicited responses — never crashes the
loop on malformed input.
- Integrated into invoke_handler! alongside the other signal
commands.
UI (desktop/index.html + src/main.ts):
- New "Network" section in settings panel with a "Detect" button
that displays the reflected address or a categorized warning
("register first" / "relay does not support reflection" / error).
Tests (crates/wzp-relay/tests/reflect.rs — 3 new, all passing):
- reflect_happy_path: client on loopback gets back 127.0.0.1:<its own port>
- reflect_two_clients_distinct_ports: two concurrent clients see
their own distinct ports, proving per-connection remote_address
- reflect_old_relay_times_out: mock relay that ignores Reflect —
client times out between 1000-1200ms and does not hang
Also pre-existing test bit-rot unrelated to this PR — fixed so the
full workspace `cargo test` goes green:
- handshake_integration tests in wzp-client, wzp-relay and
featherchat_compat in wzp-crypto all missed the `alias` field
addition to CallOffer and the 3-arg form of perform_handshake
plus 4-tuple return of accept_handshake. Updated to the current
API surface.
Results:
cargo test --workspace --exclude wzp-android: 386 passed
cargo check --workspace: clean
cargo clippy: no new warnings in touched files
Verification excludes wzp-android because it's dead code on this
branch (Tauri mobile uses wzp-native instead) and can't link -llog
on macOS host — unchanged status quo.
PRD: .taskmaster/docs/prd_reflect_over_quic.txt
Tasks: 39-46 all completed
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
297 lines
10 KiB
Rust
297 lines
10 KiB
Rust
//! 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<QuinnTransport>, Arc<QuinnTransport>, (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, None)
|
|
.await
|
|
.expect("perform_handshake should succeed");
|
|
|
|
let (callee_session, chosen_profile, _caller_fp, _caller_alias) = 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, None)
|
|
.await
|
|
.expect("handshake must succeed even with different identities");
|
|
|
|
let (callee_session, _profile, _caller_fp, _caller_alias) = 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, _caller_fp, _caller_alias) = 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, None)
|
|
.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],
|
|
alias: None,
|
|
};
|
|
|
|
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);
|
|
}
|