diff --git a/tests/e2e/src/lib.rs b/tests/e2e/src/lib.rs index 80aaefd4a..c76f34949 100644 --- a/tests/e2e/src/lib.rs +++ b/tests/e2e/src/lib.rs @@ -1,5 +1,7 @@ +use std::collections::HashMap; use std::sync::Arc; +use wacore::net::{HttpClient, HttpRequest}; use wacore::store::InMemoryBackend; use wacore::store::traits::TcTokenEntry; use wacore::types::events::{ChannelEventHandler, Event}; @@ -16,6 +18,60 @@ pub fn mock_server_url() -> String { std::env::var("MOCK_SERVER_URL").unwrap_or_else(|_| "wss://127.0.0.1:8080/ws/chat".to_string()) } +/// Translate the `MOCK_SERVER_URL` (a `ws[s]://host:port/ws/chat` WebSocket +/// URL) to the matching admin HTTP URL for the QR-scan endpoint exposed by +/// bartender. Same host/port, scheme `ws`→`http` / `wss`→`https`, path +/// `/admin/mock-phone/scan-qr`. +fn mock_admin_scan_qr_url() -> String { + let ws = mock_server_url(); + let http_scheme = if ws.starts_with("wss://") { + "https://" + } else { + "http://" + }; + let after_scheme = ws.split("://").nth(1).unwrap_or(&ws); + let host_port = after_scheme.split('/').next().unwrap_or(after_scheme); + format!("{http_scheme}{host_port}/admin/mock-phone/scan-qr") +} + +/// Spawn an in-test "phone" that watches `event_rx` for the first +/// `Event::PairingQrCode` and POSTs the QR string to the mock-server's +/// admin endpoint. This is the out-of-process equivalent of bartender's +/// in-process `spawn_qr_autoresponder`. Idempotent: stops after one scan. +fn spawn_qr_autoresponder_http( + event_rx: async_channel::Receiver>, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let url = mock_admin_scan_qr_url(); + let http = UreqHttpClient::new(); + while let Ok(event) = event_rx.recv().await { + if let Event::PairingQrCode { code, .. } = &*event { + let req = HttpRequest { + url: url.clone(), + method: "POST".into(), + headers: HashMap::new(), + body: Some(code.as_bytes().to_vec()), + }; + match http.execute(req).await { + Ok(resp) if (200..300).contains(&resp.status_code) => return, + Ok(resp) => { + eprintln!( + "qr-autoresponder: admin POST returned status {}: {}", + resp.status_code, + String::from_utf8_lossy(&resp.body) + ); + return; + } + Err(e) => { + eprintln!("qr-autoresponder: admin POST transport error: {e}"); + return; + } + } + } + } + }) +} + pub fn unique_push_name(prefix: &str) -> String { format!("{}_{}", prefix, uuid::Uuid::new_v4()) } @@ -82,6 +138,17 @@ impl TestClient { let client = bot.client(); client.register_handler(event_handler); + + // The mock server no longer auto-pairs (legacy timer is off by + // default). Spawn an out-of-process "phone" that POSTs the first + // QR this client emits to the admin endpoint, mirroring the + // in-process autoresponder used by bartender's own e2e suite. + // Uses its own ChannelEventHandler because async_channel is MPMC: + // sharing event_rx would steal events from wait_for_event below. + let (qr_handler, qr_rx) = ChannelEventHandler::new(); + client.register_handler(qr_handler); + let _qr_responder = spawn_qr_autoresponder_http(qr_rx); + let run_handle = bot.run().await?; // Wait for PairSuccess + Connected. diff --git a/wacore/src/iq/props.rs b/wacore/src/iq/props.rs index ce30450dc..f863bcee7 100644 --- a/wacore/src/iq/props.rs +++ b/wacore/src/iq/props.rs @@ -257,9 +257,6 @@ impl crate::protocol::ProtocolNode for PropsResponse { "props" } - /// Serializes metadata attrs only. Individual `` children are not - /// emitted since experiment_props stores lightweight (code, value) tuples - /// without the full AbPropConfig structure needed for node construction. fn into_node(self) -> Node { let mut builder = NodeBuilder::new("props").attr("protocol", PROPS_PROTOCOL_VERSION); @@ -277,7 +274,20 @@ impl crate::protocol::ProtocolNode for PropsResponse { } builder = builder.attr("delta_update", self.delta_update); - builder.build() + // Round-trip with try_from_node_ref. config_expo_key is dropped on + // both sides; extend the tuple type before adding it back. + let prop_nodes: Vec = self + .experiment_props + .into_iter() + .map(|(code, value)| { + NodeBuilder::new("prop") + .attr("config_code", code) + .attr("config_value", &*value) + .build() + }) + .collect(); + + builder.children(prop_nodes).build() } fn try_from_node_ref(node: &NodeRef<'_>) -> Result { @@ -537,6 +547,62 @@ mod tests { assert_eq!(parsed.refresh, response.refresh); assert_eq!(parsed.refresh_id, response.refresh_id); assert_eq!(parsed.delta_update, response.delta_update); + assert_eq!(parsed.experiment_props, response.experiment_props); + } + + /// `` must carry one `` per + /// experiment, matching `WASmaxInAbPropsExperimentConfigMixin`. + #[test] + fn test_props_response_into_node_emits_wa_web_compliant_prop_children() { + let response = PropsResponse { + ab_key: None, + hash: None, + refresh: None, + refresh_id: None, + delta_update: false, + experiment_props: vec![ + (11_262, CompactString::from("1")), + (11_103, CompactString::from("0")), + ], + }; + + let node = response.into_node(); + + let children = match node.content { + Some(NodeContent::Nodes(c)) => c, + other => panic!(" must have Node children, got {other:?}"), + }; + assert_eq!( + children.len(), + 2, + "expected one per experiment_props entry" + ); + + let pairs: Vec<(String, String)> = children + .iter() + .map(|n| { + assert_eq!(n.tag, "prop"); + let code = n + .attrs + .get("config_code") + .map(|v| v.to_string()) + .expect("missing config_code"); + let value = n + .attrs + .get("config_value") + .map(|v| v.to_string()) + .expect("missing config_value"); + (code, value) + }) + .collect(); + assert_eq!( + pairs, + vec![ + ("11262".to_string(), "1".to_string()), + ("11103".to_string(), "0".to_string()), + ], + "code/value pairs must be preserved with their original mapping" + ); } #[test] diff --git a/wacore/src/pair.rs b/wacore/src/pair.rs index 2a6903cbc..971214424 100644 --- a/wacore/src/pair.rs +++ b/wacore/src/pair.rs @@ -150,7 +150,7 @@ impl PairUtils { text: "internal-error", source: anyhow::anyhow!("HMAC container missing details"), })?; - let _hmac_bytes = hmac_container + let hmac_bytes = hmac_container .hmac .as_deref() .ok_or_else(|| PairCryptoError { @@ -163,11 +163,15 @@ impl PairUtils { mac.update(ADV_HOSTED_PREFIX_ACCOUNT_SIGNATURE); } mac.update(details_bytes); - // TODO(security): HMAC verification skipped — adv_secret_key is only - // rotated in the pair-code flow (see handle_pair_code_notification() in - // pair_code.rs, via DeviceCommand::SetAdvSecretKey). QR pairing uses - // the initial random key from Device::new() which won't match. - // Re-enable once both pairing paths persist the correct key. + // adv_secret is shared with the primary out-of-band (QR string or + // pair-code DH). HMAC mismatch means the container is forged: + // account_signature alone is not a backstop, since its key comes + // from the same untrusted blob. + mac.verify_slice(hmac_bytes).map_err(|_| PairCryptoError { + code: 401, + text: "hmac-mismatch", + source: anyhow::anyhow!("ADV signed-device-identity HMAC verification failed"), + })?; // 2. Unmarshal inner container and verify account signature let mut signed_identity = @@ -604,6 +608,100 @@ mod tests { } } + /// Synthesize a signed pair-success payload whose HMAC is keyed by + /// `adv_secret_for_hmac`. Mirrors the verifier's hosted/E2EE branching + /// for both the account signature and the outer HMAC. + fn build_pair_success_payload( + state: &DeviceState, + adv_secret_for_hmac: &[u8; 32], + is_hosted: bool, + ) -> Vec { + use prost::Message; + use waproto::whatsapp as wa; + + let mut rng = rand::make_rng::(); + let account_kp = KeyPair::generate(&mut rng); + let account_type_value = if is_hosted { 1 } else { 0 }; + let inner = wa::AdvDeviceIdentity { + raw_id: Some(1), + timestamp: Some(0), + key_index: Some(0), + account_type: Some(account_type_value), + device_type: Some(account_type_value), + } + .encode_to_vec(); + let account_sig_prefix: &[u8] = if is_hosted { + ADV_HOSTED_PREFIX_ACCOUNT_SIGNATURE + } else { + ADV_PREFIX_ACCOUNT_SIGNATURE + }; + let mut to_sign = Vec::new(); + to_sign.extend_from_slice(account_sig_prefix); + to_sign.extend_from_slice(&inner); + to_sign.extend_from_slice(state.identity_key.public_key.public_key_bytes()); + let sig = account_kp + .private_key + .calculate_signature(&to_sign, &mut rng) + .unwrap(); + let signed = wa::AdvSignedDeviceIdentity { + details: Some(inner), + account_signature_key: Some(account_kp.public_key.public_key_bytes().to_vec()), + account_signature: Some(sig.to_vec()), + device_signature: None, + } + .encode_to_vec(); + let mut mac = ::new_from_slice(adv_secret_for_hmac).unwrap(); + if is_hosted { + mac.update(ADV_HOSTED_PREFIX_ACCOUNT_SIGNATURE); + } + mac.update(&signed); + let hmac_bytes = mac.finalize().into_bytes().to_vec(); + wa::AdvSignedDeviceIdentityHmac { + details: Some(signed), + hmac: Some(hmac_bytes), + account_type: Some(account_type_value), + } + .encode_to_vec() + } + + #[test] + fn do_pair_crypto_accepts_matching_hmac() { + let state = dummy_device_state(); + let payload = build_pair_success_payload(&state, &state.adv_secret_key, false); + PairUtils::do_pair_crypto(&state, &payload).expect("matching HMAC must verify"); + } + + #[test] + fn do_pair_crypto_rejects_mismatched_hmac() { + let state = dummy_device_state(); + // Different secret than the companion holds: tampered/forged pair-success. + let wrong_secret = [0xCDu8; 32]; + let payload = build_pair_success_payload(&state, &wrong_secret, false); + let err = PairUtils::do_pair_crypto(&state, &payload) + .expect_err("mismatched HMAC must abort pairing"); + assert_eq!(err.code, 401, "expected 401 unauthorized, got {}", err.code); + assert_eq!(err.text, "hmac-mismatch"); + } + + #[test] + fn do_pair_crypto_accepts_matching_hmac_for_hosted_account() { + let state = dummy_device_state(); + let payload = build_pair_success_payload(&state, &state.adv_secret_key, true); + PairUtils::do_pair_crypto(&state, &payload) + .expect("hosted-account HMAC with matching secret must verify"); + } + + #[test] + fn do_pair_crypto_rejects_mismatched_hmac_for_hosted_account() { + let state = dummy_device_state(); + let wrong_secret = [0xCDu8; 32]; + let payload = build_pair_success_payload(&state, &wrong_secret, true); + let err = PairUtils::do_pair_crypto(&state, &payload) + .expect_err("hosted-account HMAC with wrong secret must abort pairing"); + assert_eq!(err.code, 401, "expected 401 unauthorized, got {}", err.code); + assert_eq!(err.text, "hmac-mismatch"); + } + /// QR trailing field == `code()` (parity with `companion_platform_id`). #[test] fn qr_trailing_field_matches_companion_web_client_type_code() {