diff --git a/src/client/lid_pn.rs b/src/client/lid_pn.rs index 7d6880ce..57c3c979 100644 --- a/src/client/lid_pn.rs +++ b/src/client/lid_pn.rs @@ -472,10 +472,9 @@ impl Client { } /// Resolve any user JID to its bare LID form, or `None` when no LID is - /// available. Mirrors WA Web's `WAWebLidMigrationUtils.toUserLid` - /// (`docs/captured-js/WAWeb/Lid/MigrationUtils.js:17-20`): LID passes - /// through, PN goes through the cache-aside mapping, anything else and - /// any lookup failure returns `None`. + /// available. Mirrors WA Web's `WAWebLidMigrationUtils.toUserLid`: LID + /// passes through, PN goes through the cache-aside mapping, anything + /// else and any lookup failure returns `None`. /// /// Used by `send_status_message` to replicate WA Web's /// `compactMap(list, toUserLid)` skip-on-unresolvable semantics. diff --git a/src/features/media_reupload.rs b/src/features/media_reupload.rs index bfd8bc0b..07ec4db4 100644 --- a/src/features/media_reupload.rs +++ b/src/features/media_reupload.rs @@ -4,7 +4,7 @@ //! sends a `` stanza and waits for a //! `` response with a new `directPath`. //! -//! Reference: WAWebRequestMediaReuploadManager (docs/captured-js/). +//! Reference: WAWebRequestMediaReuploadManager. use crate::client::{Client, ClientError, NodeFilter}; use anyhow::Result; diff --git a/src/pair.rs b/src/pair.rs index 327b3ce9..ff00e46f 100644 --- a/src/pair.rs +++ b/src/pair.rs @@ -6,20 +6,33 @@ use prost::Message; use std::sync::Arc; use std::sync::atomic::Ordering; +use wacore::companion_reg::companion_web_client_type_for_props; use wacore::libsignal::protocol::KeyPair; use wacore_binary::NodeRef; use wacore_binary::{Jid, SERVER_JID}; use waproto::whatsapp as wa; +pub use wacore::companion_reg::{CompanionWebClientType, NATIVE_CAMERA_DEEP_LINK_PREFIX}; pub use wacore::pair::{DeviceState, PairCryptoError, PairUtils}; -pub fn make_qr_data(store: &crate::store::Device, ref_str: String) -> String { +/// Auto-derives client type from `device_props`; see +/// [`make_qr_data_with_client_type`] to override. +pub fn make_qr_data(store: &crate::store::Device, ref_str: &str) -> String { + let client_type = companion_web_client_type_for_props(&store.device_props); + make_qr_data_with_client_type(store, ref_str, client_type) +} + +pub fn make_qr_data_with_client_type( + store: &crate::store::Device, + ref_str: &str, + client_type: CompanionWebClientType, +) -> String { let device_state = DeviceState { identity_key: store.identity_key.clone(), noise_key: store.noise_key.clone(), adv_secret_key: store.adv_secret_key, }; - PairUtils::make_qr_data(&device_state, ref_str) + PairUtils::make_qr_data(&device_state, ref_str, client_type) } pub async fn handle_iq(client: &Arc, node: &NodeRef<'_>) -> bool { @@ -49,12 +62,14 @@ pub async fn handle_iq(client: &Arc, node: &NodeRef<'_>) -> bool { noise_key: device_snapshot.noise_key.clone(), adv_secret_key: device_snapshot.adv_secret_key, }; + let client_type = + companion_web_client_type_for_props(&device_snapshot.device_props); for grandchild in child.get_children_by_tag("ref") { if let Some(bytes) = grandchild.content_bytes() && let Ok(r) = std::str::from_utf8(bytes) { - codes.push(PairUtils::make_qr_data(&device_state, r.to_string())); + codes.push(PairUtils::make_qr_data(&device_state, r, client_type)); } } diff --git a/src/pair_code.rs b/src/pair_code.rs index c0bc5294..fe25662b 100644 --- a/src/pair_code.rs +++ b/src/pair_code.rs @@ -57,6 +57,7 @@ use wacore_binary::Jid; use wacore_binary::{NodeContent, NodeContentRef, NodeRef}; // Re-export types for user convenience +pub use wacore::companion_reg::CompanionWebClientType; pub use wacore::pair_code::PairCodeOptions; impl Client { @@ -160,22 +161,17 @@ impl Client { }) .await; - // Resolve companion_platform_{id,display} from options + device_props. - // This is the single point where the pairing code flow picks what - // identity to announce; bare `PairCodeOptions::default()` derives from - // `Device.device_props` (os + platform_type) rather than the legacy - // "Chrome (Linux)" hardcode. - let (platform_id_str, platform_display_str) = + let (platform_id, platform_display) = resolve_companion_platform(&options, &device_snapshot.device_props); + let platform_id_str = platform_id.to_string(); - // Build the stage 1 IQ node let req_id = self.generate_request_id(); let iq_content = PairCodeUtils::build_companion_hello_iq( &phone_number, &noise_static_pub, &wrapped_ephemeral, &platform_id_str, - &platform_display_str, + &platform_display, options.show_push_notification, req_id.clone(), ); diff --git a/wacore/src/companion_reg.rs b/wacore/src/companion_reg.rs new file mode 100644 index 00000000..053993a1 --- /dev/null +++ b/wacore/src/companion_reg.rs @@ -0,0 +1,302 @@ +//! Mirrors `WAWebCompanionRegClientUtils.DEVICE_PLATFORM`. + +use waproto::whatsapp as wa; + +/// Prefix `WAWebLinkDeviceQrcode` uses when iOS native-camera linking is on. +/// Concatenate with `make_qr_data` output to get a scannable deep-link URL. +pub const NATIVE_CAMERA_DEEP_LINK_PREFIX: &str = "https://wa.me/settings/linked_devices#"; + +/// Encode-only: discriminants pinned to wire ints, no decode fallback. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +#[repr(i32)] +pub enum CompanionWebClientType { + #[default] + Unknown = 0, + Chrome = 1, + Edge = 2, + Firefox = 3, + Ie = 4, + Opera = 5, + Safari = 6, + Electron = 7, + Uwp = 8, + OtherWebClient = 9, +} + +impl CompanionWebClientType { + pub const fn code(self) -> i32 { + self as i32 + } +} + +impl std::fmt::Display for CompanionWebClientType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.code().fmt(f) + } +} + +/// One of the 6 browsers the server accepts in `companion_platform_display` +/// (per whatsmeow `PairPhone` doc). Non-browser variants fall back to "Chrome" +/// since WA Web emits the same when the underlying renderer is Chromium. +pub const fn companion_browser_name(ct: CompanionWebClientType) -> &'static str { + match ct { + CompanionWebClientType::Chrome => "Chrome", + CompanionWebClientType::Edge => "Edge", + CompanionWebClientType::Firefox => "Firefox", + CompanionWebClientType::Ie => "IE", + CompanionWebClientType::Opera => "Opera", + CompanionWebClientType::Safari => "Safari", + CompanionWebClientType::Unknown + | CompanionWebClientType::Electron + | CompanionWebClientType::Uwp + | CompanionWebClientType::OtherWebClient => "Chrome", + } +} + +/// Non-web platforms collapse to `OtherWebClient`, matching WA Web's +/// `info().name` fall-through. WA Web's `gkx("4112") ⇒ UWP` host-shell +/// short-circuit isn't replicated — caller sets `PlatformType::Uwp` +/// explicitly. Same for `Electron` via `Desktop`. +pub const fn companion_web_client_type_for_platform( + pt: wa::device_props::PlatformType, +) -> CompanionWebClientType { + use CompanionWebClientType as C; + use wa::device_props::PlatformType as P; + match pt { + P::Unknown => C::Unknown, + P::Chrome => C::Chrome, + P::Firefox => C::Firefox, + P::Ie => C::Ie, + P::Opera => C::Opera, + P::Safari => C::Safari, + P::Edge => C::Edge, + P::Desktop => C::Electron, + P::Uwp => C::Uwp, + P::Ipad + | P::AndroidTablet + | P::Ohana + | P::Aloha + | P::Catalina + | P::TclTv + | P::IosPhone + | P::IosCatalyst + | P::AndroidPhone + | P::AndroidAmbiguous + | P::WearOs + | P::ArWrist + | P::ArDevice + | P::Vr + | P::CloudApi + | P::Smartglasses => C::OtherWebClient, + } +} + +pub fn companion_web_client_type_for_props(props: &wa::DeviceProps) -> CompanionWebClientType { + props + .platform_type + .and_then(|v| wa::device_props::PlatformType::try_from(v).ok()) + .map(companion_web_client_type_for_platform) + .unwrap_or_default() +} + +/// ` ()` as WA Web emits. Empty OS falls back to "Linux" +/// since WA Web never sends a bare browser. +pub fn companion_platform_display(ct: CompanionWebClientType, os: &str) -> String { + let os = os.trim(); + let os = if os.is_empty() { "Linux" } else { os }; + format!("{} ({})", companion_browser_name(ct), os) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn wire_codes_match_wa_web() { + assert_eq!(CompanionWebClientType::Unknown.code(), 0); + assert_eq!(CompanionWebClientType::Chrome.code(), 1); + assert_eq!(CompanionWebClientType::Edge.code(), 2); + assert_eq!(CompanionWebClientType::Firefox.code(), 3); + assert_eq!(CompanionWebClientType::Ie.code(), 4); + assert_eq!(CompanionWebClientType::Opera.code(), 5); + assert_eq!(CompanionWebClientType::Safari.code(), 6); + assert_eq!(CompanionWebClientType::Electron.code(), 7); + assert_eq!(CompanionWebClientType::Uwp.code(), 8); + assert_eq!(CompanionWebClientType::OtherWebClient.code(), 9); + } + + #[test] + fn display_renders_decimal_wire_integer() { + assert_eq!(format!("{}", CompanionWebClientType::Unknown), "0"); + assert_eq!(format!("{}", CompanionWebClientType::Chrome), "1"); + assert_eq!(format!("{}", CompanionWebClientType::OtherWebClient), "9"); + } + + #[test] + fn default_is_unknown_zero() { + assert_eq!( + CompanionWebClientType::default(), + CompanionWebClientType::Unknown, + ); + assert_eq!(CompanionWebClientType::default().code(), 0); + } + + #[test] + fn browser_platform_types_round_trip() { + use CompanionWebClientType as C; + use wa::device_props::PlatformType as P; + assert_eq!(companion_web_client_type_for_platform(P::Chrome), C::Chrome); + assert_eq!( + companion_web_client_type_for_platform(P::Firefox), + C::Firefox + ); + assert_eq!(companion_web_client_type_for_platform(P::Edge), C::Edge); + assert_eq!(companion_web_client_type_for_platform(P::Safari), C::Safari); + assert_eq!(companion_web_client_type_for_platform(P::Opera), C::Opera); + assert_eq!(companion_web_client_type_for_platform(P::Ie), C::Ie); + } + + #[test] + fn desktop_maps_to_electron_and_uwp_preserved() { + use CompanionWebClientType as C; + use wa::device_props::PlatformType as P; + assert_eq!( + companion_web_client_type_for_platform(P::Desktop), + C::Electron + ); + assert_eq!(companion_web_client_type_for_platform(P::Uwp), C::Uwp); + } + + #[test] + fn mobile_and_xr_collapse_to_other() { + use CompanionWebClientType as C; + use wa::device_props::PlatformType as P; + for pt in [ + P::Ipad, + P::AndroidPhone, + P::AndroidTablet, + P::IosPhone, + P::IosCatalyst, + P::AndroidAmbiguous, + P::WearOs, + P::ArWrist, + P::ArDevice, + P::Vr, + P::Ohana, + P::Aloha, + P::Catalina, + P::TclTv, + P::CloudApi, + P::Smartglasses, + ] { + assert_eq!( + companion_web_client_type_for_platform(pt), + C::OtherWebClient, + "{pt:?} must fall back to OtherWebClient", + ); + } + } + + #[test] + fn for_props_reads_platform_type() { + let props = wa::DeviceProps { + platform_type: Some(wa::device_props::PlatformType::Chrome as i32), + ..Default::default() + }; + assert_eq!( + companion_web_client_type_for_props(&props), + CompanionWebClientType::Chrome, + ); + } + + #[test] + fn for_props_missing_platform_type_is_unknown() { + let props = wa::DeviceProps::default(); + assert_eq!( + companion_web_client_type_for_props(&props), + CompanionWebClientType::Unknown, + ); + } + + #[test] + fn for_props_invalid_platform_type_is_unknown() { + let props = wa::DeviceProps { + platform_type: Some(9999), + ..Default::default() + }; + assert_eq!( + companion_web_client_type_for_props(&props), + CompanionWebClientType::Unknown, + ); + } + + #[test] + fn browser_name_for_six_valid_browsers() { + assert_eq!( + companion_browser_name(CompanionWebClientType::Chrome), + "Chrome" + ); + assert_eq!(companion_browser_name(CompanionWebClientType::Edge), "Edge"); + assert_eq!( + companion_browser_name(CompanionWebClientType::Firefox), + "Firefox" + ); + assert_eq!(companion_browser_name(CompanionWebClientType::Ie), "IE"); + assert_eq!( + companion_browser_name(CompanionWebClientType::Opera), + "Opera" + ); + assert_eq!( + companion_browser_name(CompanionWebClientType::Safari), + "Safari" + ); + } + + #[test] + fn browser_name_for_non_browser_falls_back_to_chrome() { + for ct in [ + CompanionWebClientType::Unknown, + CompanionWebClientType::Electron, + CompanionWebClientType::Uwp, + CompanionWebClientType::OtherWebClient, + ] { + assert_eq!(companion_browser_name(ct), "Chrome", "{ct:?}"); + } + } + + #[test] + fn platform_display_always_browser_paren_os() { + assert_eq!( + companion_platform_display(CompanionWebClientType::Chrome, "Linux"), + "Chrome (Linux)" + ); + assert_eq!( + companion_platform_display(CompanionWebClientType::Firefox, "Mac"), + "Firefox (Mac)" + ); + } + + #[test] + fn platform_display_empty_os_defaults_to_linux() { + assert_eq!( + companion_platform_display(CompanionWebClientType::Chrome, ""), + "Chrome (Linux)" + ); + assert_eq!( + companion_platform_display(CompanionWebClientType::Chrome, " "), + "Chrome (Linux)" + ); + } + + #[test] + fn platform_display_non_browser_uses_chrome() { + assert_eq!( + companion_platform_display(CompanionWebClientType::OtherWebClient, "Android"), + "Chrome (Android)" + ); + assert_eq!( + companion_platform_display(CompanionWebClientType::Electron, "Mac"), + "Chrome (Mac)" + ); + } +} diff --git a/wacore/src/lib.rs b/wacore/src/lib.rs index 9a15ddb8..85061180 100644 --- a/wacore/src/lib.rs +++ b/wacore/src/lib.rs @@ -9,6 +9,7 @@ pub use wacore_derive::{EmptyNode, ProtocolNode, WireEnum}; pub mod adv; pub mod appstate_sync; pub mod client; +pub mod companion_reg; pub mod download; pub mod iq; pub mod protocol; diff --git a/wacore/src/media_retry.rs b/wacore/src/media_retry.rs index c0a63e97..38324967 100644 --- a/wacore/src/media_retry.rs +++ b/wacore/src/media_retry.rs @@ -8,7 +8,7 @@ //! - Parsing the notification node //! //! Reference: WAWebCryptoMediaRetry, WAWebSendServerErrorReceiptJob, -//! WAWebHandleMediaRetryNotification (docs/captured-js/). +//! WAWebHandleMediaRetryNotification. use anyhow::{Result, anyhow}; use hkdf::Hkdf; diff --git a/wacore/src/pair.rs b/wacore/src/pair.rs index 603deb65..2a6903cb 100644 --- a/wacore/src/pair.rs +++ b/wacore/src/pair.rs @@ -1,3 +1,4 @@ +use crate::companion_reg::CompanionWebClientType; use crate::libsignal::crypto::aes_256_gcm_encrypt; use crate::libsignal::protocol::{KeyPair, PublicKey}; use base64::Engine as _; @@ -52,15 +53,21 @@ pub struct DeviceState { pub struct PairUtils; impl PairUtils { - /// Constructs the full QR code string from the ref and device keys. - pub fn make_qr_data(device_state: &DeviceState, ref_str: String) -> String { + /// `,,,,` per WA Web + /// (`WAWebLinkDeviceQrcode.react`); 4-field form rejected since + /// tulir/whatsmeow#1110. + pub fn make_qr_data( + device_state: &DeviceState, + ref_str: &str, + client_type: CompanionWebClientType, + ) -> String { let noise_b64 = BASE64_STANDARD.encode(device_state.noise_key.public_key.public_key_bytes()); let identity_b64 = BASE64_STANDARD.encode(device_state.identity_key.public_key.public_key_bytes()); let adv_b64 = BASE64_STANDARD.encode(device_state.adv_secret_key); - [ref_str, noise_b64, identity_b64, adv_b64].join(",") + format!("{ref_str},{noise_b64},{identity_b64},{adv_b64},{client_type}") } /// Builds acknowledgment node for a pairing request @@ -262,23 +269,40 @@ impl PairUtils { .build() } - /// Parses QR code and extracts crypto keys for pairing + /// Permissive: accepts legacy 4-field, current 5-field, optional + /// `https://wa.me/settings/linked_devices#` prefix, trailing FAQ URL, + /// or any combination (used by e2e replay; WA Web only emits one shape + /// at a time). pub fn parse_qr_code(qr_code: &str) -> Result<(String, [u8; 32], [u8; 32]), anyhow::Error> { - let parts: Vec<&str> = qr_code.split(',').collect(); - if parts.len() != 4 { - return Err(anyhow::anyhow!("Invalid QR code format")); + let body = qr_code + .strip_prefix(crate::companion_reg::NATIVE_CAMERA_DEEP_LINK_PREFIX) + .unwrap_or(qr_code); + let parts: Vec<&str> = body.split(',').collect(); + if parts.len() < 4 { + return Err(anyhow::anyhow!( + "Invalid QR code format: expected at least 4 comma-separated fields, got {}", + parts.len() + )); } let pairing_ref = parts[0].to_string(); let dut_noise_pub_b64 = parts[1]; let dut_identity_pub_b64 = parts[2]; - // The ADV secret is not used by the phone side. - + if pairing_ref.is_empty() + || dut_noise_pub_b64.is_empty() + || dut_identity_pub_b64.is_empty() + || parts[3].is_empty() + || parts.iter().skip(4).any(|p| p.is_empty()) + { + return Err(anyhow::anyhow!( + "Invalid QR code format: all comma-separated fields must be non-empty" + )); + } let dut_noise_pub_bytes = BASE64_STANDARD .decode(dut_noise_pub_b64) - .map_err(|e| anyhow::anyhow!(e))?; + .map_err(|e| anyhow::anyhow!("Invalid QR noise public key base64: {e}"))?; let dut_identity_pub_bytes = BASE64_STANDARD .decode(dut_identity_pub_b64) - .map_err(|e| anyhow::anyhow!(e))?; + .map_err(|e| anyhow::anyhow!("Invalid QR identity public key base64: {e}"))?; let dut_noise_pub: [u8; 32] = dut_noise_pub_bytes .try_into() @@ -364,3 +388,234 @@ impl PairUtils { slices.iter().flat_map(|s| s.iter().cloned()).collect() } } + +#[cfg(test)] +mod tests { + use super::*; + use rand::RngExt; + + fn dummy_device_state() -> DeviceState { + let mut rng = rand::make_rng::(); + let mut adv = [0u8; 32]; + rng.fill(&mut adv); + DeviceState { + identity_key: KeyPair::generate(&mut rng), + noise_key: KeyPair::generate(&mut rng), + adv_secret_key: adv, + } + } + + #[test] + fn make_qr_data_has_five_fields_with_client_type_suffix() { + let state = dummy_device_state(); + let qr = PairUtils::make_qr_data(&state, "the-ref", CompanionWebClientType::Chrome); + let parts: Vec<&str> = qr.split(',').collect(); + assert_eq!(parts.len(), 5, "expected 5 fields, got {qr:?}"); + assert_eq!(parts[0], "the-ref"); + assert_eq!(parts[4], "1", "Chrome wire value must be \"1\""); + } + + #[test] + fn make_qr_data_renders_each_client_type_decimal_integer() { + let state = dummy_device_state(); + for (ct, wire) in [ + (CompanionWebClientType::Unknown, "0"), + (CompanionWebClientType::Chrome, "1"), + (CompanionWebClientType::Edge, "2"), + (CompanionWebClientType::Firefox, "3"), + (CompanionWebClientType::Ie, "4"), + (CompanionWebClientType::Opera, "5"), + (CompanionWebClientType::Safari, "6"), + (CompanionWebClientType::Electron, "7"), + (CompanionWebClientType::Uwp, "8"), + (CompanionWebClientType::OtherWebClient, "9"), + ] { + let qr = PairUtils::make_qr_data(&state, "r", ct); + assert_eq!(qr.rsplit(',').next(), Some(wire), "{ct:?}"); + } + } + + #[test] + fn parse_qr_code_accepts_new_five_field_format() { + let state = dummy_device_state(); + let qr = PairUtils::make_qr_data(&state, "the-ref", CompanionWebClientType::OtherWebClient); + let (pairing_ref, noise, identity) = PairUtils::parse_qr_code(&qr).unwrap(); + assert_eq!(pairing_ref, "the-ref"); + assert_eq!(noise, *state.noise_key.public_key.public_key_bytes()); + assert_eq!(identity, *state.identity_key.public_key.public_key_bytes()); + } + + #[test] + fn parse_qr_code_accepts_legacy_four_field_format() { + let state = dummy_device_state(); + let legacy = [ + "ref".to_string(), + BASE64_STANDARD.encode(state.noise_key.public_key.public_key_bytes()), + BASE64_STANDARD.encode(state.identity_key.public_key.public_key_bytes()), + BASE64_STANDARD.encode(state.adv_secret_key), + ] + .join(","); + let (pairing_ref, noise, identity) = PairUtils::parse_qr_code(&legacy).unwrap(); + assert_eq!(pairing_ref, "ref"); + assert_eq!(noise, *state.noise_key.public_key.public_key_bytes()); + assert_eq!(identity, *state.identity_key.public_key.public_key_bytes()); + } + + #[test] + fn parse_qr_code_accepts_native_camera_prefix() { + let state = dummy_device_state(); + let inner = PairUtils::make_qr_data(&state, "r", CompanionWebClientType::Chrome); + let prefixed = format!("https://wa.me/settings/linked_devices#{inner}"); + let (pairing_ref, _, _) = PairUtils::parse_qr_code(&prefixed).unwrap(); + assert_eq!(pairing_ref, "r"); + } + + #[test] + fn parse_qr_code_accepts_faq_url_suffix() { + let state = dummy_device_state(); + let inner = PairUtils::make_qr_data(&state, "r", CompanionWebClientType::Chrome); + let suffixed = format!("{inner},https://faq.whatsapp.com/r/ld"); + let (pairing_ref, _, _) = PairUtils::parse_qr_code(&suffixed).unwrap(); + assert_eq!(pairing_ref, "r"); + } + + #[test] + fn parse_qr_code_rejects_too_few_fields() { + let err = PairUtils::parse_qr_code("a,b,c").unwrap_err(); + assert!( + err.to_string().contains("at least 4"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_qr_code_rejects_empty_fields() { + let err = PairUtils::parse_qr_code(",,,,").unwrap_err(); + assert!( + err.to_string().contains("non-empty"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_qr_code_rejects_empty_trailing_client_type() { + let state = dummy_device_state(); + let noise = BASE64_STANDARD.encode(state.noise_key.public_key.public_key_bytes()); + let identity = BASE64_STANDARD.encode(state.identity_key.public_key.public_key_bytes()); + let adv = BASE64_STANDARD.encode(state.adv_secret_key); + let qr = format!("ref,{noise},{identity},{adv},"); + let err = PairUtils::parse_qr_code(&qr).unwrap_err(); + assert!( + err.to_string().contains("non-empty"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_qr_code_rejects_malformed_base64() { + let err = PairUtils::parse_qr_code("ref,!!notb64!!,!!notb64!!,advsecret").unwrap_err(); + assert!( + err.to_string().contains("base64"), + "unexpected error: {err}" + ); + } + + #[test] + fn parse_qr_code_rejects_wrong_key_length() { + let state = dummy_device_state(); + let short_noise = BASE64_STANDARD.encode([0u8; 16]); + let identity = BASE64_STANDARD.encode(state.identity_key.public_key.public_key_bytes()); + let adv = BASE64_STANDARD.encode(state.adv_secret_key); + let qr = format!("ref,{short_noise},{identity},{adv}"); + let err = PairUtils::parse_qr_code(&qr).unwrap_err(); + assert!( + err.to_string().contains("length"), + "unexpected error: {err}" + ); + } + + /// E2E: DeviceProps → auto-derive → QR wire id matches WA Web. + #[test] + fn auto_derive_from_device_props_round_trip() { + use crate::companion_reg::companion_web_client_type_for_props; + use waproto::whatsapp as wa; + + let cases = [ + (wa::device_props::PlatformType::Chrome, "1"), + (wa::device_props::PlatformType::Firefox, "3"), + (wa::device_props::PlatformType::Safari, "6"), + (wa::device_props::PlatformType::Edge, "2"), + (wa::device_props::PlatformType::Desktop, "7"), + (wa::device_props::PlatformType::Uwp, "8"), + (wa::device_props::PlatformType::AndroidPhone, "9"), + (wa::device_props::PlatformType::IosPhone, "9"), + (wa::device_props::PlatformType::Vr, "9"), + (wa::device_props::PlatformType::Unknown, "0"), + ]; + let state = dummy_device_state(); + for (pt, expected_wire) in cases { + let props = wa::DeviceProps { + platform_type: Some(pt as i32), + ..Default::default() + }; + let ct = companion_web_client_type_for_props(&props); + let qr = PairUtils::make_qr_data(&state, "ref", ct); + let trailing = qr.rsplit(',').next().unwrap(); + assert_eq!(trailing, expected_wire, "{pt:?}"); + } + } + + /// Bare `DeviceProps` produces 5-field QR with trailing "0", no panic. + #[test] + fn auto_derive_default_device_props_yields_unknown_zero() { + use crate::companion_reg::companion_web_client_type_for_props; + use waproto::whatsapp as wa; + + let state = dummy_device_state(); + let ct = companion_web_client_type_for_props(&wa::DeviceProps::default()); + let qr = PairUtils::make_qr_data(&state, "ref", ct); + let parts: Vec<&str> = qr.split(',').collect(); + assert_eq!(parts.len(), 5); + assert_eq!(parts[4], "0"); + } + + /// `make_qr_data` output must always round-trip through `parse_qr_code`. + #[test] + fn round_trip_make_then_parse_for_every_client_type() { + let state = dummy_device_state(); + for ct in [ + CompanionWebClientType::Unknown, + CompanionWebClientType::Chrome, + CompanionWebClientType::Edge, + CompanionWebClientType::Firefox, + CompanionWebClientType::Ie, + CompanionWebClientType::Opera, + CompanionWebClientType::Safari, + CompanionWebClientType::Electron, + CompanionWebClientType::Uwp, + CompanionWebClientType::OtherWebClient, + ] { + let qr = PairUtils::make_qr_data(&state, "the-ref", ct); + let (pairing_ref, noise, identity) = PairUtils::parse_qr_code(&qr) + .unwrap_or_else(|e| panic!("{ct:?} round-trip failed: {e}")); + assert_eq!(pairing_ref, "the-ref", "{ct:?}"); + assert_eq!(noise, *state.noise_key.public_key.public_key_bytes()); + assert_eq!(identity, *state.identity_key.public_key.public_key_bytes()); + } + } + + /// QR trailing field == `code()` (parity with `companion_platform_id`). + #[test] + fn qr_trailing_field_matches_companion_web_client_type_code() { + let state = dummy_device_state(); + for ct in [ + CompanionWebClientType::Chrome, + CompanionWebClientType::OtherWebClient, + CompanionWebClientType::Uwp, + ] { + let qr = PairUtils::make_qr_data(&state, "r", ct); + let trailing = qr.rsplit(',').next().unwrap(); + assert_eq!(trailing, ct.code().to_string()); + } + } +} diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index b53e3c24..82c87e7a 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -19,6 +19,9 @@ //! - Ephemeral encryption: AES-256-CTR //! - Bundle encryption: AES-256-GCM after HKDF key derivation +use crate::companion_reg::{ + CompanionWebClientType, companion_platform_display, companion_web_client_type_for_props, +}; use crate::libsignal::crypto::aes_256_gcm_encrypt; use crate::libsignal::protocol::{KeyPair, PublicKey}; use aes::cipher::{KeyIvInit, StreamCipher}; @@ -78,88 +81,27 @@ fn pbkdf2_hmac_sha256(password: &[u8], salt: &[u8], rounds: u32, output: &mut [u /// Validity duration for pair codes (approximately). const PAIR_CODE_VALIDITY_SECS: u64 = 180; -/// Human-readable label for a `DeviceProps.PlatformType`, used to build -/// `companion_platform_display`. Mirrors WA Web's `WAWebMiscBrowserUtils.info().name` -/// for web entries and extends the table with mobile/desktop labels for companions -/// that aren't browsers. -/// -/// Match is exhaustive on purpose: a new variant in the proto must force a conscious -/// decision instead of silently falling through. -pub fn platform_friendly_name(pt: wa::device_props::PlatformType) -> &'static str { - use wa::device_props::PlatformType as P; - match pt { - P::Unknown => "Unknown", - P::Chrome => "Chrome", - P::Firefox => "Firefox", - P::Ie => "IE", - P::Opera => "Opera", - P::Safari => "Safari", - P::Edge => "Edge", - P::Desktop => "Desktop", - P::Ipad => "iPad", - P::AndroidTablet => "Android", - P::Ohana => "Ohana", - P::Aloha => "Aloha", - P::Catalina => "Catalina", - P::TclTv => "TCL TV", - P::IosPhone => "iPhone", - P::IosCatalyst => "Mac Catalyst", - P::AndroidPhone => "Android", - P::AndroidAmbiguous => "Android", - P::WearOs => "Wear OS", - P::ArWrist => "AR Wrist", - P::ArDevice => "AR Device", - P::Uwp => "UWP", - P::Vr => "VR", - P::CloudApi => "Cloud API", - P::Smartglasses => "Smart Glasses", - } -} - -/// Derives `(companion_platform_id, companion_platform_display)` from `DeviceProps`. -/// -/// Mirrors WA Web's pattern `" ()"` where `name` comes from the running -/// browser and `os` from the OS detection. For a non-web companion the equivalent -/// pair is `(platform_friendly_name(platform_type), device_props.os)`. -/// -/// - `platform_type` missing or invalid → `Unknown`. -/// - `os` missing/empty → display omits the parenthesised OS and uses the friendly -/// name only. -pub fn derive_companion_platform(props: &wa::DeviceProps) -> (String, String) { - let pt = props - .platform_type - .and_then(|v| wa::device_props::PlatformType::try_from(v).ok()) - .unwrap_or(wa::device_props::PlatformType::Unknown); - - let id = (pt as i32).to_string(); - let name = platform_friendly_name(pt); - let os = props.os.as_deref().unwrap_or("").trim(); - let display = if os.is_empty() { - name.to_string() - } else { - format!("{name} ({os})") - }; - +/// `(companion_platform_id, companion_platform_display)` per WA Web's +/// `Alt/DeviceLinkingIq.js`. Display always Browser-valid (see +/// `companion_platform_display`). +pub fn derive_companion_platform(props: &wa::DeviceProps) -> (CompanionWebClientType, String) { + let id = companion_web_client_type_for_props(props); + let os = props.os.as_deref().unwrap_or(""); + let display = companion_platform_display(id, os); (id, display) } -/// Resolves the companion platform strings to send in the `companion_hello` IQ, -/// honouring explicit overrides from `PairCodeOptions` and falling back to -/// [`derive_companion_platform`] for any `None` field. -/// -/// This is the single point where the pairing code flow decides what `companion_*` -/// values to announce to the primary device; the registration payload route -/// (`Device::get_client_payload`) is unaffected. +/// Honours `PairCodeOptions::platform_id` override; display is always +/// derived (no override — WA Web has none, server rejects arbitrary strings). pub fn resolve_companion_platform( options: &PairCodeOptions, props: &wa::DeviceProps, -) -> (String, String) { - let (derived_id, derived_display) = derive_companion_platform(props); +) -> (CompanionWebClientType, String) { let id = options .platform_id - .map(|pt| (pt as i32).to_string()) - .unwrap_or(derived_id); - let display = options.platform_display.clone().unwrap_or(derived_display); + .unwrap_or_else(|| companion_web_client_type_for_props(props)); + let os = props.os.as_deref().unwrap_or(""); + let display = companion_platform_display(id, os); (id, display) } @@ -172,13 +114,8 @@ pub struct PairCodeOptions { pub show_push_notification: bool, /// Custom pairing code (8 chars from Crockford alphabet, or None for random). pub custom_code: Option, - /// Override for `companion_platform_id`. `None` derives from `Device.device_props` - /// via [`resolve_companion_platform`]. Uses the proto `DeviceProps.PlatformType` - /// enum directly, so only valid wire values can be expressed at compile time. - pub platform_id: Option, - /// Override for `companion_platform_display`. `None` derives from - /// `Device.device_props` via [`resolve_companion_platform`]. - pub platform_display: Option, + /// `None` auto-derives from `Device.device_props.platform_type`. + pub platform_id: Option, } impl Default for PairCodeOptions { @@ -188,7 +125,6 @@ impl Default for PairCodeOptions { show_push_notification: true, custom_code: None, platform_id: None, - platform_display: None, } } } @@ -702,132 +638,85 @@ mod tests { let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Chrome)); assert_eq!( derive_companion_platform(&p), - ("1".to_string(), "Chrome (Linux)".to_string()) + (CompanionWebClientType::Chrome, "Chrome (Linux)".to_string()) ); } #[test] - fn derive_android_phone() { - let p = props( - Some("Android"), - Some(wa::device_props::PlatformType::AndroidPhone), - ); - assert_eq!( - derive_companion_platform(&p), - ("16".to_string(), "Android (Android)".to_string()) - ); + fn derive_firefox_uses_companion_web_client_wire() { + // Regression: proto Firefox=2 vs CWCT Firefox=3. + let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Firefox)); + let (id, display) = derive_companion_platform(&p); + assert_eq!(id, CompanionWebClientType::Firefox); + assert_eq!(id.code(), 3); + assert_eq!(display, "Firefox (Linux)"); } #[test] - fn derive_ios_phone() { - let p = props(Some("iOS"), Some(wa::device_props::PlatformType::IosPhone)); - assert_eq!( - derive_companion_platform(&p), - ("14".to_string(), "iPhone (iOS)".to_string()) - ); + fn derive_edge_uses_companion_web_client_wire() { + let p = props(Some("Windows"), Some(wa::device_props::PlatformType::Edge)); + let (id, display) = derive_companion_platform(&p); + assert_eq!(id, CompanionWebClientType::Edge); + assert_eq!(id.code(), 2); + assert_eq!(display, "Edge (Windows)"); } #[test] - fn derive_ipad() { - let p = props(Some("iPadOS"), Some(wa::device_props::PlatformType::Ipad)); - assert_eq!( - derive_companion_platform(&p), - ("8".to_string(), "iPad (iPadOS)".to_string()) + fn derive_android_phone_falls_back_to_other_web_client_and_chrome() { + // Regression: previously ("16","Android (Android)"); server rejects. + let p = props( + Some("Android"), + Some(wa::device_props::PlatformType::AndroidPhone), ); + let (id, display) = derive_companion_platform(&p); + assert_eq!(id, CompanionWebClientType::OtherWebClient); + assert_eq!(id.code(), 9); + assert_eq!(display, "Chrome (Android)"); } #[test] - fn derive_no_os() { - let p = props(None, Some(wa::device_props::PlatformType::AndroidPhone)); - assert_eq!( - derive_companion_platform(&p), - ("16".to_string(), "Android".to_string()) - ); + fn derive_ios_phone_falls_back_to_other_web_client_and_chrome() { + let p = props(Some("iOS"), Some(wa::device_props::PlatformType::IosPhone)); + let (id, display) = derive_companion_platform(&p); + assert_eq!(id, CompanionWebClientType::OtherWebClient); + assert_eq!(display, "Chrome (iOS)"); } #[test] - fn derive_empty_os_trims_parenthesis() { - let p = props(Some(" "), Some(wa::device_props::PlatformType::IosPhone)); + fn derive_no_os_substitutes_linux() { + let p = props(None, Some(wa::device_props::PlatformType::Chrome)); assert_eq!( derive_companion_platform(&p), - ("14".to_string(), "iPhone".to_string()) + (CompanionWebClientType::Chrome, "Chrome (Linux)".to_string()) ); } #[test] - fn derive_unknown_proto() { - let p = props(None, None); + fn derive_empty_os_substitutes_linux() { + let p = props(Some(" "), Some(wa::device_props::PlatformType::Chrome)); assert_eq!( derive_companion_platform(&p), - ("0".to_string(), "Unknown".to_string()) - ); - } - - /// Regression guard for the `Chrome (Linux)` hardcode: a bare `DeviceProps::default()` - /// (what `Device::new()` effectively starts with before `set_device_props`) MUST NOT - /// produce the legacy web identifier. - #[test] - fn derive_bare_device_props_never_emits_chrome_linux() { - let (id, display) = derive_companion_platform(&wa::DeviceProps::default()); - assert_ne!(id, "1", "bare DeviceProps must not claim Chrome"); - assert_ne!( - display, "Chrome (Linux)", - "bare DeviceProps must not claim Chrome (Linux)" - ); - } - - #[test] - fn resolve_explicit_id_only() { - let p = props( - Some("Android"), - Some(wa::device_props::PlatformType::AndroidPhone), - ); - let opts = PairCodeOptions { - platform_id: Some(wa::device_props::PlatformType::Chrome), - ..Default::default() - }; - assert_eq!( - resolve_companion_platform(&opts, &p), - ("1".to_string(), "Android (Android)".to_string()) - ); - } - - #[test] - fn resolve_explicit_display_only() { - let p = props( - Some("Android"), - Some(wa::device_props::PlatformType::AndroidPhone), - ); - let opts = PairCodeOptions { - platform_display: Some("My Bot".into()), - ..Default::default() - }; - assert_eq!( - resolve_companion_platform(&opts, &p), - ("16".to_string(), "My Bot".to_string()) + (CompanionWebClientType::Chrome, "Chrome (Linux)".to_string()) ); } #[test] - fn resolve_full_override_ignores_device_props() { - let p = props( - Some("Android"), - Some(wa::device_props::PlatformType::AndroidPhone), - ); - let opts = PairCodeOptions { - platform_id: Some(wa::device_props::PlatformType::Chrome), - platform_display: Some("Chrome (Linux)".into()), - ..Default::default() - }; + fn derive_unknown_proto_yields_unknown_id_and_chrome_display() { + let p = props(None, None); assert_eq!( - resolve_companion_platform(&opts, &p), - ("1".to_string(), "Chrome (Linux)".to_string()) + derive_companion_platform(&p), + ( + CompanionWebClientType::Unknown, + "Chrome (Linux)".to_string() + ) ); } + /// Total scan: display always ` (Linux)` for every proto variant. #[test] - fn friendly_name_exhaustive_non_empty() { + fn derive_display_always_uses_valid_browser_for_every_proto_variant() { use wa::device_props::PlatformType as P; + const VALID_BROWSERS: &[&str] = &["Chrome", "Edge", "Firefox", "IE", "Opera", "Safari"]; for pt in [ P::Unknown, P::Chrome, @@ -855,15 +744,53 @@ mod tests { P::CloudApi, P::Smartglasses, ] { - let name = platform_friendly_name(pt); - assert!(!name.is_empty(), "empty label for {pt:?}"); + let p = props(Some("Linux"), Some(pt)); + let (id, display) = derive_companion_platform(&p); assert!( - !name.contains('_'), - "label for {pt:?} should be human-readable, not raw enum name: {name}" + (0..=9).contains(&id.code()), + "{pt:?} produced wire {} outside CompanionWebClientType range", + id.code() + ); + let browser = display.split(" (").next().unwrap(); + assert!( + VALID_BROWSERS.contains(&browser), + "{pt:?} produced display {display:?} with invalid browser {browser:?}" + ); + assert!( + display.ends_with(" (Linux)"), + "{pt:?} produced display {display:?} without parenthesised OS" ); } } + #[test] + fn resolve_explicit_id_overrides_derived() { + let p = props( + Some("Android"), + Some(wa::device_props::PlatformType::AndroidPhone), + ); + let opts = PairCodeOptions { + platform_id: Some(CompanionWebClientType::Chrome), + ..Default::default() + }; + assert_eq!( + resolve_companion_platform(&opts, &p), + ( + CompanionWebClientType::Chrome, + "Chrome (Android)".to_string() + ) + ); + } + + #[test] + fn resolve_default_uses_derived() { + let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Edge)); + assert_eq!( + resolve_companion_platform(&PairCodeOptions::default(), &p), + (CompanionWebClientType::Edge, "Edge (Linux)".to_string()) + ); + } + #[test] fn test_code_validity_duration() { let duration = PairCodeUtils::code_validity(); @@ -952,10 +879,6 @@ mod tests { options.platform_id.is_none(), "platform_id default must be None so derivation kicks in" ); - assert!( - options.platform_display.is_none(), - "platform_display default must be None so derivation kicks in" - ); } #[test] @@ -1077,14 +1000,14 @@ mod tests { #[test] fn companion_hello_iq_android_wire_strings() { - let iq = build_iq("16", "Android (Android)"); + let iq = build_iq("9", "Chrome (Android)"); let reg = iq .get_optional_child_by_tag(&["link_code_companion_reg"]) .unwrap(); - assert_eq!(child_bytes(reg, "companion_platform_id"), b"16"); + assert_eq!(child_bytes(reg, "companion_platform_id"), b"9"); assert_eq!( child_bytes(reg, "companion_platform_display"), - b"Android (Android)" + b"Chrome (Android)" ); } @@ -1102,55 +1025,53 @@ mod tests { ); } - /// End-to-end for the reported bug: a caller that only configured - /// `DeviceProps` (os=Android, platform_type=ANDROID_PHONE) and used - /// `PairCodeOptions::default()` must see the companion_hello IQ carry - /// `companion_platform_id=16` and an Android-flavoured display — not the - /// legacy "1" / "Chrome (Linux)". + /// E2E: mobile DeviceProps + default options emit server-valid companion_hello. #[test] - fn bug_android_device_props_no_longer_emit_chrome_linux() { + fn android_device_props_emit_server_valid_companion_hello() { let props = wa::DeviceProps { os: Some("Android".into()), platform_type: Some(wa::device_props::PlatformType::AndroidPhone as i32), ..Default::default() }; let (pid, pdisp) = resolve_companion_platform(&PairCodeOptions::default(), &props); - assert_eq!(pid, "16"); - assert_eq!(pdisp, "Android (Android)"); + assert_eq!(pid, CompanionWebClientType::OtherWebClient); + assert_eq!(pid.code(), 9); + assert_eq!(pdisp, "Chrome (Android)"); - let iq = build_iq(&pid, &pdisp); + let iq = build_iq(&pid.to_string(), &pdisp); let reg = iq .get_optional_child_by_tag(&["link_code_companion_reg"]) .unwrap(); - assert_eq!(child_bytes(reg, "companion_platform_id"), b"16"); + assert_eq!(child_bytes(reg, "companion_platform_id"), b"9"); assert_eq!( child_bytes(reg, "companion_platform_display"), - b"Android (Android)" - ); - // Negative side of the regression: not Chrome anymore. - assert_ne!(child_bytes(reg, "companion_platform_id"), b"1"); - assert_ne!( - child_bytes(reg, "companion_platform_display"), - b"Chrome (Linux)" + b"Chrome (Android)" ); } - /// Explicit override survives: power users that want to impersonate a - /// specific web client can still do so. #[test] - fn explicit_options_still_beat_device_props() { + fn explicit_options_override_id_and_display_follows() { let props = wa::DeviceProps { os: Some("Android".into()), platform_type: Some(wa::device_props::PlatformType::AndroidPhone as i32), ..Default::default() }; let opts = PairCodeOptions { - platform_id: Some(wa::device_props::PlatformType::Chrome), - platform_display: Some("Chrome (Linux)".into()), + platform_id: Some(CompanionWebClientType::Chrome), ..Default::default() }; let (pid, pdisp) = resolve_companion_platform(&opts, &props); - assert_eq!(pid, "1"); - assert_eq!(pdisp, "Chrome (Linux)"); + assert_eq!(pid, CompanionWebClientType::Chrome); + assert_eq!(pdisp, "Chrome (Android)"); + } + + /// Pair-code and QR share derivation. + #[test] + fn pair_code_id_matches_qr_id_for_same_device_props() { + use crate::companion_reg::companion_web_client_type_for_props; + let p = props(Some("Linux"), Some(wa::device_props::PlatformType::Edge)); + let (pair_code_id, _) = derive_companion_platform(&p); + let qr_id = companion_web_client_type_for_props(&p); + assert_eq!(pair_code_id, qr_id); } }