diff --git a/wacore/src/companion_reg.rs b/wacore/src/companion_reg.rs index 9dde64aa..a6e6799f 100644 --- a/wacore/src/companion_reg.rs +++ b/wacore/src/companion_reg.rs @@ -1,11 +1,5 @@ -//! `companion_platform_id` and `companion_platform_display` emission. -//! -//! Server accepts 23 single-byte ids: digits `0..9` (WA Web) and letters -//! `a..m`. Only the 13 with a confirmed platform meaning are exposed. -//! Sources: WA Web `WAWebCompanionRegClientUtils` for the digits; the -//! official WhatsApp Android client for the mobile letters `d`, `e`, -//! `f`. Adding the rest without binary or wire confirmation risks -//! mislabelling the device on the primary side. +//! `companion_platform_id` + `companion_platform_display` emission. +//! Encoding only. use waproto::whatsapp as wa; @@ -13,14 +7,11 @@ use waproto::whatsapp as wa; /// 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: every variant has a fixed single-byte ASCII wire form. -/// Decoding from the wire is not modelled because this crate only emits -/// the field, never receives it. +/// Web codes follow `WAWebCompanionRegClientUtils.DEVICE_PLATFORM`. +/// Android letters need server-side attestation, so they're reachable +/// only through explicit opt-in. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] pub enum CompanionWebClientType { - // Web (digit codes from WAWebCompanionRegClientUtils.DEVICE_PLATFORM). - #[default] - Unknown, Chrome, Edge, Firefox, @@ -29,8 +20,11 @@ pub enum CompanionWebClientType { Safari, Electron, Uwp, + /// Default fallback. The proto's `UNKNOWN` (wire `'0'`) is absent + /// because WA Web never emits it from a real browser and the server + /// rejects it. + #[default] OtherWebClient, - // Mobile (letter codes from the official WhatsApp Android client). AndroidTablet, AndroidPhone, AndroidAmbiguous, @@ -40,7 +34,6 @@ impl CompanionWebClientType { /// Single-byte ASCII id placed in ``. pub const fn wire_byte(self) -> u8 { match self { - Self::Unknown => b'0', Self::Chrome => b'1', Self::Edge => b'2', Self::Firefox => b'3', @@ -75,8 +68,7 @@ pub const fn companion_browser_name(ct: CompanionWebClientType) -> &'static str CompanionWebClientType::Ie => "IE", CompanionWebClientType::Opera => "Opera", CompanionWebClientType::Safari => "Safari", - CompanionWebClientType::Unknown - | CompanionWebClientType::Electron + CompanionWebClientType::Electron | CompanionWebClientType::Uwp | CompanionWebClientType::OtherWebClient | CompanionWebClientType::AndroidTablet @@ -85,16 +77,18 @@ pub const fn companion_browser_name(ct: CompanionWebClientType) -> &'static str } } -/// Maps `DeviceProps::PlatformType` to a wire variant. Variants without -/// a confirmed letter fall back to `OtherWebClient` ('9'), which the -/// server still accepts. +/// Android maps to `Chrome` because that's what real WA Web on +/// Chrome-Android emits and what the server accepts; the Android +/// letters need attestation we can't fake from this crate, so they +/// stay behind `PairCodeOptions::platform_id`. iOS/AR/VR and the +/// proto's `UNKNOWN` collapse to `OtherWebClient` — `'0'` would be +/// server-rejected. 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, @@ -103,10 +97,9 @@ pub const fn companion_web_client_type_for_platform( P::Edge => C::Edge, P::Desktop => C::Electron, P::Uwp => C::Uwp, - P::AndroidPhone => C::AndroidPhone, - P::AndroidTablet => C::AndroidTablet, - P::AndroidAmbiguous => C::AndroidAmbiguous, - P::Ipad + P::AndroidPhone | P::AndroidTablet | P::AndroidAmbiguous => C::Chrome, + P::Unknown + | P::Ipad | P::Ohana | P::Aloha | P::Catalina @@ -127,7 +120,7 @@ pub fn companion_web_client_type_for_props(props: &wa::DeviceProps) -> Companion .platform_type .and_then(|v| wa::device_props::PlatformType::try_from(v).ok()) .map(companion_web_client_type_for_platform) - .unwrap_or_default() + .unwrap_or(CompanionWebClientType::OtherWebClient) } /// `companion_platform_display` body. Server validates only length @@ -153,7 +146,6 @@ mod tests { #[test] fn wire_byte_matches_wa_web() { - assert_eq!(CompanionWebClientType::Unknown.wire_byte(), b'0'); assert_eq!(CompanionWebClientType::Chrome.wire_byte(), b'1'); assert_eq!(CompanionWebClientType::Edge.wire_byte(), b'2'); assert_eq!(CompanionWebClientType::Firefox.wire_byte(), b'3'); @@ -174,7 +166,6 @@ mod tests { #[test] fn display_renders_wire_byte_as_char() { - assert_eq!(format!("{}", CompanionWebClientType::Unknown), "0"); assert_eq!(format!("{}", CompanionWebClientType::Chrome), "1"); assert_eq!(format!("{}", CompanionWebClientType::OtherWebClient), "9"); assert_eq!(format!("{}", CompanionWebClientType::AndroidPhone), "e"); @@ -183,65 +174,53 @@ mod tests { } #[test] - fn default_is_unknown_zero() { + fn default_is_other_web_client_nine() { assert_eq!( CompanionWebClientType::default(), - CompanionWebClientType::Unknown, - ); - assert_eq!(CompanionWebClientType::default().wire_byte(), b'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 + CompanionWebClientType::OtherWebClient, ); - 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); + assert_eq!(CompanionWebClientType::default().wire_byte(), b'9'); } #[test] - fn desktop_maps_to_electron_and_uwp_preserved() { + fn browser_and_desktop_platform_types_map_to_their_variants() { 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); + for (pt, expected) in [ + (P::Chrome, C::Chrome), + (P::Firefox, C::Firefox), + (P::Edge, C::Edge), + (P::Safari, C::Safari), + (P::Opera, C::Opera), + (P::Ie, C::Ie), + (P::Desktop, C::Electron), + (P::Uwp, C::Uwp), + ] { + assert_eq!( + companion_web_client_type_for_platform(pt), + expected, + "{pt:?}" + ); + } } #[test] - fn android_platform_types_map_to_dedicated_letters() { + fn android_platform_types_map_to_chrome() { use CompanionWebClientType as C; use wa::device_props::PlatformType as P; - assert_eq!( - companion_web_client_type_for_platform(P::AndroidPhone), - C::AndroidPhone, - ); - assert_eq!( - companion_web_client_type_for_platform(P::AndroidTablet), - C::AndroidTablet, - ); - assert_eq!( - companion_web_client_type_for_platform(P::AndroidAmbiguous), - C::AndroidAmbiguous, - ); + for pt in [P::AndroidPhone, P::AndroidTablet, P::AndroidAmbiguous] { + assert_eq!( + companion_web_client_type_for_platform(pt), + C::Chrome, + "{pt:?}" + ); + } } #[test] fn unconfirmed_platform_types_collapse_to_other() { use CompanionWebClientType as C; use wa::device_props::PlatformType as P; - // No confirmed letter for these yet (would need iOS/Mac/Quest RE - // or live capture). Fallback to OtherWebClient ('9') stays - // server-valid. for pt in [ P::Ipad, P::IosPhone, @@ -260,11 +239,28 @@ mod tests { assert_eq!( companion_web_client_type_for_platform(pt), C::OtherWebClient, - "{pt:?} must fall back to OtherWebClient", + "{pt:?}", ); } } + #[test] + fn proto_unknown_collapses_to_other_web_client() { + use CompanionWebClientType as C; + use wa::device_props::PlatformType as P; + assert_eq!( + companion_web_client_type_for_platform(P::Unknown), + C::OtherWebClient, + ); + } + + #[test] + fn android_variants_still_emit_their_wire_bytes_when_used_directly() { + assert_eq!(CompanionWebClientType::AndroidPhone.wire_byte(), b'e'); + assert_eq!(CompanionWebClientType::AndroidTablet.wire_byte(), b'd'); + assert_eq!(CompanionWebClientType::AndroidAmbiguous.wire_byte(), b'f'); + } + #[test] fn for_props_reads_platform_type() { let props = wa::DeviceProps { @@ -278,52 +274,44 @@ mod tests { } #[test] - fn for_props_missing_platform_type_is_unknown() { + fn for_props_missing_platform_type_is_other_web_client() { let props = wa::DeviceProps::default(); assert_eq!( companion_web_client_type_for_props(&props), - CompanionWebClientType::Unknown, + CompanionWebClientType::OtherWebClient, ); } #[test] - fn for_props_invalid_platform_type_is_unknown() { + fn for_props_invalid_platform_type_is_other_web_client() { let props = wa::DeviceProps { platform_type: Some(9999), ..Default::default() }; assert_eq!( companion_web_client_type_for_props(&props), - CompanionWebClientType::Unknown, + CompanionWebClientType::OtherWebClient, ); } #[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" - ); + use CompanionWebClientType as C; + for (ct, name) in [ + (C::Chrome, "Chrome"), + (C::Edge, "Edge"), + (C::Firefox, "Firefox"), + (C::Ie, "IE"), + (C::Opera, "Opera"), + (C::Safari, "Safari"), + ] { + assert_eq!(companion_browser_name(ct), name, "{ct:?}"); + } } #[test] fn browser_name_for_non_browser_falls_back_to_chrome() { for ct in [ - CompanionWebClientType::Unknown, CompanionWebClientType::Electron, CompanionWebClientType::Uwp, CompanionWebClientType::OtherWebClient, diff --git a/wacore/src/pair.rs b/wacore/src/pair.rs index 7acd66a7..978534d3 100644 --- a/wacore/src/pair.rs +++ b/wacore/src/pair.rs @@ -423,7 +423,6 @@ mod tests { fn make_qr_data_renders_each_client_type_wire_byte() { let state = dummy_device_state(); for (ct, wire) in [ - (CompanionWebClientType::Unknown, "0"), (CompanionWebClientType::Chrome, "1"), (CompanionWebClientType::Edge, "2"), (CompanionWebClientType::Firefox, "3"), @@ -554,12 +553,12 @@ mod tests { (wa::device_props::PlatformType::Edge, "2"), (wa::device_props::PlatformType::Desktop, "7"), (wa::device_props::PlatformType::Uwp, "8"), - (wa::device_props::PlatformType::AndroidPhone, "e"), - (wa::device_props::PlatformType::AndroidTablet, "d"), - (wa::device_props::PlatformType::AndroidAmbiguous, "f"), + (wa::device_props::PlatformType::AndroidPhone, "1"), + (wa::device_props::PlatformType::AndroidTablet, "1"), + (wa::device_props::PlatformType::AndroidAmbiguous, "1"), (wa::device_props::PlatformType::IosPhone, "9"), (wa::device_props::PlatformType::Vr, "9"), - (wa::device_props::PlatformType::Unknown, "0"), + (wa::device_props::PlatformType::Unknown, "9"), ]; let state = dummy_device_state(); for (pt, expected_wire) in cases { @@ -574,9 +573,8 @@ mod tests { } } - /// Bare `DeviceProps` produces 5-field QR with trailing "0", no panic. #[test] - fn auto_derive_default_device_props_yields_unknown_zero() { + fn auto_derive_default_device_props_yields_other_web_client_nine() { use crate::companion_reg::companion_web_client_type_for_props; use waproto::whatsapp as wa; @@ -585,7 +583,7 @@ mod tests { 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"); + assert_eq!(parts[4], "9"); } /// `make_qr_data` output must always round-trip through `parse_qr_code`. @@ -593,7 +591,6 @@ mod tests { 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, diff --git a/wacore/src/pair_code.rs b/wacore/src/pair_code.rs index cf1f0e16..a3c3b1e2 100644 --- a/wacore/src/pair_code.rs +++ b/wacore/src/pair_code.rs @@ -81,14 +81,19 @@ 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; +fn build_id_and_display( + id: CompanionWebClientType, + props: &wa::DeviceProps, +) -> (CompanionWebClientType, String) { + let os = props.os.as_deref().unwrap_or(""); + (id, companion_platform_display(id, 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) + build_id_and_display(companion_web_client_type_for_props(props), props) } /// Honours `PairCodeOptions::platform_id` override; display is always @@ -100,9 +105,7 @@ pub fn resolve_companion_platform( let id = options .platform_id .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) + build_id_and_display(id, props) } /// Options for pair code authentication. @@ -669,39 +672,14 @@ mod tests { } #[test] - fn derive_android_phone_uses_dedicated_letter_and_android_label() { - let p = props( - Some("Android"), - Some(wa::device_props::PlatformType::AndroidPhone), - ); - let (id, display) = derive_companion_platform(&p); - assert_eq!(id, CompanionWebClientType::AndroidPhone); - assert_eq!(id.wire_byte(), b'e'); - assert_eq!(display, "Android (Android)"); - } - - #[test] - fn derive_android_tablet_uses_dedicated_letter() { - let p = props( - Some("Android"), - Some(wa::device_props::PlatformType::AndroidTablet), - ); - let (id, display) = derive_companion_platform(&p); - assert_eq!(id, CompanionWebClientType::AndroidTablet); - assert_eq!(id.wire_byte(), b'd'); - assert_eq!(display, "Android (Android)"); - } - - #[test] - fn derive_android_ambiguous_uses_dedicated_letter() { - let p = props( - Some("Android"), - Some(wa::device_props::PlatformType::AndroidAmbiguous), - ); - let (id, display) = derive_companion_platform(&p); - assert_eq!(id, CompanionWebClientType::AndroidAmbiguous); - assert_eq!(id.wire_byte(), b'f'); - assert_eq!(display, "Android (Android)"); + fn derive_android_platform_types_map_to_chrome() { + use wa::device_props::PlatformType as P; + for pt in [P::AndroidPhone, P::AndroidTablet, P::AndroidAmbiguous] { + let (id, display) = derive_companion_platform(&props(Some("Android"), Some(pt))); + assert_eq!(id, CompanionWebClientType::Chrome, "{pt:?}"); + assert_eq!(id.wire_byte(), b'1', "{pt:?}"); + assert_eq!(display, "Chrome (Android)", "{pt:?}"); + } } #[test] @@ -731,20 +709,17 @@ mod tests { } #[test] - fn derive_unknown_proto_yields_unknown_id_and_chrome_display() { + fn derive_unknown_proto_yields_other_web_client_id_and_chrome_display() { let p = props(None, None); assert_eq!( derive_companion_platform(&p), ( - CompanionWebClientType::Unknown, + CompanionWebClientType::OtherWebClient, "Chrome (Linux)".to_string() ) ); } - /// Every proto variant produces a wire byte from the server's - /// accept-list and a display whose label is one of the known emitter - /// cohorts (`Browser` for web, `Android` for Android). #[test] fn derive_display_uses_known_label_for_every_proto_variant() { use wa::device_props::PlatformType as P; @@ -1054,15 +1029,15 @@ mod tests { } #[test] - fn companion_hello_iq_android_wire_strings() { - let iq = build_iq("e", "Android (Android)"); + fn companion_hello_iq_passes_through_explicit_android_letter() { + let iq = build_iq("e", "Android (16)"); let reg = iq .get_optional_child_by_tag(&["link_code_companion_reg"]) .unwrap(); assert_eq!(child_bytes(reg, "companion_platform_id"), b"e"); assert_eq!( child_bytes(reg, "companion_platform_display"), - b"Android (Android)" + b"Android (16)" ); } @@ -1080,27 +1055,26 @@ mod tests { ); } - /// E2E: mobile DeviceProps + default options emit server-valid companion_hello. #[test] - fn android_device_props_emit_server_valid_companion_hello() { + fn android_device_props_emit_server_accepted_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, CompanionWebClientType::AndroidPhone); - assert_eq!(pid.wire_byte(), b'e'); - assert_eq!(pdisp, "Android (Android)"); + assert_eq!(pid, CompanionWebClientType::Chrome); + assert_eq!(pid.wire_byte(), b'1'); + assert_eq!(pdisp, "Chrome (Android)"); 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"e"); + assert_eq!(child_bytes(reg, "companion_platform_id"), b"1"); assert_eq!( child_bytes(reg, "companion_platform_display"), - b"Android (Android)" + b"Chrome (Android)" ); }