test: 17 new tests for S-4/5/6/7/9 integration tasks
S-4 Room hashing + ACL (8 tests in featherchat_compat.rs): - hash_room_name: deterministic, 32 hex chars, different inputs differ - hash_room_name_matches_fc_convention: manual SHA-256 verification - room_acl: open mode, enforced mode, allow-listed, deny-unlisted S-5 Handshake integration (4 tests in handshake_integration.rs): - handshake_succeeds: real QUIC, encrypt/decrypt cross-verified - handshake_verifies_identity: different seeds, session still works - auth_then_handshake: AuthToken + CallOffer/Answer in sequence - handshake_rejects_bad_signature: tampered sig → error S-6/7/9 Web+Proto+TLS (5 tests in featherchat_compat.rs): - auth_response_with_eth_address: FC's extra field handled - wzp_proto_has_auth_token_variant: serialize/deserialize roundtrip - all_fc_call_signal_types_representable: all 7 types verified - hash_room_name_used_as_sni_is_valid: unicode/special chars → valid hex - wzp_proto_cargo_toml_is_standalone: no workspace inheritance 196 total tests passing across all crates. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -3806,6 +3806,7 @@ dependencies = [
|
|||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"tracing-subscriber",
|
"tracing-subscriber",
|
||||||
|
"wzp-client",
|
||||||
"wzp-codec",
|
"wzp-codec",
|
||||||
"wzp-crypto",
|
"wzp-crypto",
|
||||||
"wzp-fec",
|
"wzp-fec",
|
||||||
|
|||||||
@@ -51,3 +51,4 @@ wzp-codec = { path = "crates/wzp-codec" }
|
|||||||
wzp-fec = { path = "crates/wzp-fec" }
|
wzp-fec = { path = "crates/wzp-fec" }
|
||||||
wzp-crypto = { path = "crates/wzp-crypto" }
|
wzp-crypto = { path = "crates/wzp-crypto" }
|
||||||
wzp-transport = { path = "crates/wzp-transport" }
|
wzp-transport = { path = "crates/wzp-transport" }
|
||||||
|
wzp-client = { path = "crates/wzp-client" }
|
||||||
|
|||||||
@@ -311,3 +311,261 @@ fn all_signal_types_map_correctly() {
|
|||||||
assert_eq!(name, expected_name, "signal type mapping for {expected_name}");
|
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"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -30,3 +30,6 @@ name = "wzp-relay"
|
|||||||
path = "src/main.rs"
|
path = "src/main.rs"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
|
tokio = { workspace = true, features = ["rt-multi-thread", "macros"] }
|
||||||
|
wzp-transport = { workspace = true }
|
||||||
|
wzp-client = { workspace = true }
|
||||||
|
|||||||
295
crates/wzp-relay/tests/handshake_integration.rs
Normal file
295
crates/wzp-relay/tests/handshake_integration.rs
Normal file
@@ -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<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)
|
||||||
|
.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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user