use crate::companion_reg::{
CompanionWebClientType, companion_platform_display, companion_platform_display_raw,
companion_web_client_type_for_props,
};
use crate::libsignal::crypto::{CryptoProviderError, aes_256_gcm_encrypt};
use crate::libsignal::protocol::{CurveError, KeyPair, PublicKey};
use aes::cipher::{KeyIvInit, StreamCipher};
use ctr::Ctr128BE;
use hmac::{Hmac, Mac};
use rand::RngExt;
use sha2::Sha256;
use wacore_binary::SERVER_JID;
use wacore_binary::builder::NodeBuilder;
use wacore_binary::{Node, NodeContentRef, NodeRef};
use waproto::whatsapp as wa;
type Aes256Ctr = Ctr128BE<aes::Aes256>;
const PAIR_CODE_PBKDF2_ITERATIONS: u32 = 131_072;
const PAIR_CODE_SALT_SIZE: usize = 32;
const PAIR_CODE_IV_SIZE: usize = 16;
const CROCKFORD_ALPHABET: &[u8; 32] = b"123456789ABCDEFGHJKLMNPQRSTVWXYZ";
fn pbkdf2_hmac_sha256(password: &[u8], salt: &[u8], rounds: u32, output: &mut [u8]) {
use hmac::KeyInit as _;
let keyed = Hmac::<Sha256>::new_from_slice(password).expect("HMAC accepts any key length");
for (i, chunk) in output.chunks_mut(32).enumerate() {
let mut u = {
let mut mac = keyed.clone();
mac.update(salt);
mac.update(&((i as u32) + 1).to_be_bytes());
let result: [u8; 32] = mac.finalize().into_bytes().into();
result
};
chunk.copy_from_slice(&u[..chunk.len()]);
for _ in 1..rounds {
let mut mac = keyed.clone();
mac.update(&u);
u = mac.finalize().into_bytes().into();
for (a, b) in chunk.iter_mut().zip(u.iter()) {
*a ^= b;
}
}
}
}
const PAIR_CODE_VALIDITY_SECS: u64 = 180;
const PAIR_CODE_MAX_PRIMARY_HELLO_ATTEMPTS: u32 = 3;
const PAIR_CODE_PRIMARY_HELLO_PAIR_SUCCESS_TIMEOUT_SECS: u64 = 60;
const PAIR_CODE_COMPANION_FINISH_IQ_TIMEOUT_SECS: u64 = 30;
const _: () = assert!(
PAIR_CODE_COMPANION_FINISH_IQ_TIMEOUT_SECS < PAIR_CODE_PRIMARY_HELLO_PAIR_SUCCESS_TIMEOUT_SECS,
"a companion_finish refusal has to land while the flow it belongs to is still the current one"
);
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))
}
pub fn derive_companion_platform(props: &wa::DeviceProps) -> (CompanionWebClientType, String) {
build_id_and_display(companion_web_client_type_for_props(props), props)
}
pub fn resolve_companion_platform(
options: &PairCodeOptions,
props: &wa::DeviceProps,
) -> (CompanionWebClientType, String) {
let id = options
.platform_id
.unwrap_or_else(|| companion_web_client_type_for_props(props));
let display = match options.display_os.as_deref().map(str::trim) {
Some(raw) if !raw.is_empty() => companion_platform_display_raw(id, raw),
_ => build_id_and_display(id, props).1,
};
(id, display)
}
#[derive(Debug, Clone)]
pub struct PairCodeOptions {
pub phone_number: String,
pub show_push_notification: bool,
pub custom_code: Option<String>,
pub platform_id: Option<CompanionWebClientType>,
pub display_os: Option<String>,
}
impl Default for PairCodeOptions {
fn default() -> Self {
Self {
phone_number: String::new(),
show_push_notification: true,
custom_code: None,
platform_id: None,
display_os: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct PairCodeClaim(u64);
impl PairCodeClaim {
pub fn next() -> Self {
use core::sync::atomic::Ordering;
static NEXT: portable_atomic::AtomicU64 = portable_atomic::AtomicU64::new(0);
Self(NEXT.fetch_add(1, Ordering::Relaxed))
}
}
#[derive(Default)]
pub enum PairCodeState {
#[default]
Idle,
RequestingCode {
code_generation_ts: i64,
claim: PairCodeClaim,
},
WaitingForPhoneConfirmation {
pairing_ref: Vec<u8>,
phone_jid: String,
pair_code: String,
ephemeral_keypair: Box<KeyPair>,
code_generation_ts: i64,
primary_hello_attempt_count: u32,
},
Completed,
}
impl PairCodeState {
pub fn awaiting_pair_success(&self) -> bool {
matches!(
self,
Self::WaitingForPhoneConfirmation {
primary_hello_attempt_count: 1..,
..
}
)
}
pub fn is_outstanding(&self, now: i64) -> bool {
self.live_flow_remaining(now).is_some() || self.awaiting_pair_success()
}
pub fn live_flow_remaining(&self, now: i64) -> Option<std::time::Duration> {
let (Self::RequestingCode {
code_generation_ts, ..
}
| Self::WaitingForPhoneConfirmation {
code_generation_ts, ..
}) = self
else {
return None;
};
let validity = PairCodeUtils::code_validity();
let age = now.saturating_sub(*code_generation_ts).max(0) as u64;
(age <= validity.as_secs()).then(|| validity - std::time::Duration::from_secs(age))
}
}
impl std::fmt::Debug for PairCodeState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Idle => write!(f, "Idle"),
Self::RequestingCode { .. } => write!(f, "RequestingCode"),
Self::WaitingForPhoneConfirmation { phone_jid, .. } => f
.debug_struct("WaitingForPhoneConfirmation")
.field("phone_jid", phone_jid)
.finish_non_exhaustive(),
Self::Completed => write!(f, "Completed"),
}
}
}
pub struct PairCodeUtils;
impl PairCodeUtils {
pub fn generate_code() -> String {
let mut bytes = [0u8; 5];
rand::make_rng::<rand::rngs::StdRng>().fill(&mut bytes);
Self::encode_crockford(&bytes)
}
pub fn validate_code(code: &str) -> bool {
code.len() == 8
&& code
.bytes()
.all(|b| CROCKFORD_ALPHABET.contains(&b.to_ascii_uppercase()))
}
pub(crate) fn encode_crockford(bytes: &[u8; 5]) -> String {
let mut accumulator: u64 = 0;
for &byte in bytes {
accumulator = (accumulator << 8) | u64::from(byte);
}
let mut result = String::with_capacity(8);
for i in (0..8).rev() {
let index = ((accumulator >> (i * 5)) & 0x1F) as usize;
result.push(CROCKFORD_ALPHABET[index] as char);
}
result
}
pub fn derive_key(code: &str, salt: &[u8; PAIR_CODE_SALT_SIZE]) -> [u8; 32] {
let mut key = [0u8; 32];
pbkdf2_hmac_sha256(code.as_bytes(), salt, PAIR_CODE_PBKDF2_ITERATIONS, &mut key);
key
}
pub fn encrypt_ephemeral_pub(ephemeral_pub: &[u8; 32], code: &str) -> [u8; 80] {
let mut salt = [0u8; PAIR_CODE_SALT_SIZE];
let mut iv = [0u8; PAIR_CODE_IV_SIZE];
rand::make_rng::<rand::rngs::StdRng>().fill(&mut salt);
rand::make_rng::<rand::rngs::StdRng>().fill(&mut iv);
let key = Self::derive_key(code, &salt);
let mut cipher = Aes256Ctr::new(&key.into(), &iv.into());
let mut ciphertext = *ephemeral_pub;
cipher.apply_keystream(&mut ciphertext);
let mut result = [0u8; 80];
result[..32].copy_from_slice(&salt);
result[32..48].copy_from_slice(&iv);
result[48..80].copy_from_slice(&ciphertext);
result
}
pub fn decrypt_primary_ephemeral_pub(
wrapped: &[u8],
pair_code: &str,
) -> Result<[u8; 32], PairCodeError> {
if wrapped.len() != 80 {
return Err(PairCodeError::InvalidWrappedData {
expected: 80,
got: wrapped.len(),
});
}
let salt: [u8; PAIR_CODE_SALT_SIZE] = wrapped[0..32]
.try_into()
.expect("salt slice is exactly 32 bytes");
let iv: [u8; PAIR_CODE_IV_SIZE] = wrapped[32..48]
.try_into()
.expect("iv slice is exactly 16 bytes");
let mut plaintext: [u8; 32] = wrapped[48..80]
.try_into()
.expect("ciphertext slice is exactly 32 bytes");
let derived_key = Self::derive_key(pair_code, &salt);
let mut cipher = Aes256Ctr::new((&derived_key).into(), &iv.into());
cipher.apply_keystream(&mut plaintext);
Ok(plaintext)
}
pub fn build_companion_hello_iq(
phone_number: &str,
noise_static_pub: &[u8; 32],
wrapped_ephemeral: &[u8; 80],
platform_id: &str,
platform_display: &str,
show_push_notification: bool,
req_id: String,
) -> Node {
let link_code_reg = NodeBuilder::new("link_code_companion_reg")
.attrs([
("jid", format!("{}@s.whatsapp.net", phone_number)),
("stage", "companion_hello".to_string()),
(
"should_show_push_notification",
show_push_notification.to_string(),
),
])
.children([
NodeBuilder::new("link_code_pairing_wrapped_companion_ephemeral_pub")
.bytes(wrapped_ephemeral.to_vec())
.build(),
NodeBuilder::new("companion_server_auth_key_pub")
.bytes(noise_static_pub.to_vec())
.build(),
NodeBuilder::new("companion_platform_id")
.bytes(platform_id.as_bytes().to_vec())
.build(),
NodeBuilder::new("companion_platform_display")
.bytes(platform_display.as_bytes().to_vec())
.build(),
NodeBuilder::new("link_code_pairing_nonce")
.bytes(vec![0u8])
.build(),
])
.build();
NodeBuilder::new("iq")
.attrs([
("xmlns", "md".to_string()),
("type", "set".to_string()),
("to", SERVER_JID.to_string()),
("id", req_id),
])
.children([link_code_reg])
.build()
}
pub fn parse_companion_hello_response(node: &NodeRef<'_>) -> Option<Vec<u8>> {
node.get_optional_child_by_tag(&["link_code_companion_reg"])
.and_then(|n| n.get_optional_child_by_tag(&["link_code_pairing_ref"]))
.and_then(|n| match n.content.as_ref() {
Some(NodeContentRef::Bytes(b)) => Some(b.to_vec()),
_ => None,
})
}
pub fn build_companion_finish_iq(
phone_number: &str,
wrapped_key_bundle: Vec<u8>,
identity_pub: &[u8; 32],
pairing_ref: &[u8],
req_id: String,
) -> Node {
let link_code_reg = NodeBuilder::new("link_code_companion_reg")
.attrs([
("jid", format!("{}@s.whatsapp.net", phone_number)),
("stage", "companion_finish".to_string()),
])
.children([
NodeBuilder::new("link_code_pairing_wrapped_key_bundle")
.bytes(wrapped_key_bundle)
.build(),
NodeBuilder::new("companion_identity_public")
.bytes(identity_pub.to_vec())
.build(),
NodeBuilder::new("link_code_pairing_ref")
.bytes(pairing_ref.to_vec())
.build(),
])
.build();
NodeBuilder::new("iq")
.attrs([
("xmlns", "md".to_string()),
("type", "set".to_string()),
("to", SERVER_JID.to_string()),
("id", req_id),
])
.children([link_code_reg])
.build()
}
pub fn prepare_key_bundle(
ephemeral_keypair: &KeyPair,
primary_ephemeral_pub: &[u8; 32],
primary_identity_pub: &[u8; 32],
identity_key: &KeyPair,
) -> Result<(Vec<u8>, [u8; 32]), PairCodeError> {
let primary_eph_pub = PublicKey::from_djb_public_key_bytes(primary_ephemeral_pub)
.map_err(PairCodeError::InvalidPrimaryEphemeralKey)?;
let primary_id_pub = PublicKey::from_djb_public_key_bytes(primary_identity_pub)
.map_err(PairCodeError::InvalidPrimaryIdentityKey)?;
let ephemeral_shared = ephemeral_keypair
.private_key
.calculate_agreement(&primary_eph_pub)
.map_err(PairCodeError::EphemeralKeyAgreement)?;
let identity_shared = identity_key
.private_key
.calculate_agreement(&primary_id_pub)
.map_err(PairCodeError::IdentityKeyAgreement)?;
let mut random_bytes = [0u8; 32];
rand::make_rng::<rand::rngs::StdRng>().fill(&mut random_bytes);
let mut combined_secret = Vec::with_capacity(96);
combined_secret.extend_from_slice(&ephemeral_shared);
combined_secret.extend_from_slice(&identity_shared);
combined_secret.extend_from_slice(&random_bytes);
let mut new_adv_secret = [0u8; 32];
crate::crypto::hkdf_sha256_into(&combined_secret, None, b"adv_secret", &mut new_adv_secret)
.map_err(|_| PairCodeError::AdvSecretKeyDerivation)?;
let mut bundle = Vec::with_capacity(96);
bundle.extend_from_slice(identity_key.public_key.public_key_bytes());
bundle.extend_from_slice(primary_identity_pub);
bundle.extend_from_slice(&random_bytes);
let mut key_bundle_salt = [0u8; 32];
rand::make_rng::<rand::rngs::StdRng>().fill(&mut key_bundle_salt);
let mut enc_key = [0u8; 32];
crate::crypto::hkdf_sha256_into(
&ephemeral_shared,
Some(&key_bundle_salt),
b"link_code_pairing_key_bundle_encryption_key",
&mut enc_key,
)
.map_err(|_| PairCodeError::BundleKeyDerivation)?;
let mut iv = [0u8; 12];
rand::make_rng::<rand::rngs::StdRng>().fill(&mut iv);
let mut wrapped_bundle = Vec::with_capacity(32 + 12 + bundle.len() + 16);
wrapped_bundle.extend_from_slice(&key_bundle_salt);
wrapped_bundle.extend_from_slice(&iv);
aes_256_gcm_encrypt(&enc_key, &iv, b"", &bundle, &mut wrapped_bundle)
.map_err(PairCodeError::BundleAead)?;
Ok((wrapped_bundle, new_adv_secret))
}
pub fn code_validity() -> std::time::Duration {
std::time::Duration::from_secs(PAIR_CODE_VALIDITY_SECS)
}
pub fn max_primary_hello_attempts() -> u32 {
PAIR_CODE_MAX_PRIMARY_HELLO_ATTEMPTS
}
pub fn primary_hello_pair_success_timeout() -> std::time::Duration {
std::time::Duration::from_secs(PAIR_CODE_PRIMARY_HELLO_PAIR_SUCCESS_TIMEOUT_SECS)
}
pub fn companion_finish_iq_timeout() -> std::time::Duration {
std::time::Duration::from_secs(PAIR_CODE_COMPANION_FINISH_IQ_TIMEOUT_SECS)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, crate::WireEnum)]
#[wire(kind = "int")]
pub enum PairCodeRejection {
#[wire = 400]
BadRequest,
#[wire = 403]
Forbidden,
#[wire = 429]
RateOverlimit,
#[wire = 452]
FeatureNotAvailable,
#[wire = 500]
InternalServerError,
#[wire_fallback]
Unknown(i32),
}
impl PairCodeRejection {
pub fn is_throttled(self) -> bool {
matches!(self, Self::RateOverlimit | Self::BadRequest)
}
pub fn text(self) -> Option<&'static str> {
Some(match self {
Self::BadRequest => "bad-request",
Self::Forbidden => "forbidden",
Self::RateOverlimit => "rate-overlimit",
Self::FeatureNotAvailable => "feature-not-available",
Self::InternalServerError => "internal-server-error",
Self::Unknown(_) => return None,
})
}
pub fn from_server(code: u16, text: &str) -> Option<Self> {
let by_code = Self::from(i32::from(code));
match by_code.text() {
Some(expected) if !text.is_empty() && text != expected => None,
_ => Some(by_code),
}
}
}
impl core::fmt::Display for PairCodeRejection {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self.text() {
Some(text) => write!(f, "{text} ({})", self.code()),
None => write!(f, "unknown ({})", self.code()),
}
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum PairCodeError {
#[error("phone number is required")]
PhoneNumberRequired,
#[error("phone number is too short (must be at least 7 digits)")]
PhoneNumberTooShort,
#[error("phone number must not start with 0 (use international format)")]
PhoneNumberNotInternational,
#[error("invalid custom code: must be 8 characters from Crockford Base32 alphabet")]
InvalidCustomCode,
#[error("a pair code is already outstanding ({remaining:?} left of its validity window)")]
CodeAlreadyOutstanding { remaining: std::time::Duration },
#[error("invalid wrapped data: expected {expected} bytes, got {got}")]
InvalidWrappedData { expected: usize, got: usize },
#[error("primary device sent an invalid ephemeral public key")]
InvalidPrimaryEphemeralKey(#[source] CurveError),
#[error("primary device sent an invalid identity public key")]
InvalidPrimaryIdentityKey(#[source] CurveError),
#[error("ephemeral key agreement failed")]
EphemeralKeyAgreement(#[source] CurveError),
#[error("identity key agreement failed")]
IdentityKeyAgreement(#[source] CurveError),
#[error("HKDF expand failed for adv_secret")]
AdvSecretKeyDerivation,
#[error("HKDF expand failed for bundle encryption key")]
BundleKeyDerivation,
#[error("AES-GCM encryption of key bundle failed")]
BundleAead(#[source] CryptoProviderError),
#[error("not in waiting state for pair code notification")]
NotWaiting,
#[error("server response missing pairing ref")]
MissingPairingRef,
#[error("the pair-code flow was cancelled while it was being requested")]
Cancelled,
}
#[cfg(test)]
mod tests {
use super::*;
use wacore_binary::NodeContent;
#[test]
fn test_pbkdf2_matches_per_iteration_reference() {
use hmac::{KeyInit as _, Mac as _};
fn reference(password: &[u8], salt: &[u8], rounds: u32, output: &mut [u8]) {
for (i, chunk) in output.chunks_mut(32).enumerate() {
let mut u = {
let mut mac = Hmac::<Sha256>::new_from_slice(password).unwrap();
mac.update(salt);
mac.update(&((i as u32) + 1).to_be_bytes());
let r: [u8; 32] = mac.finalize().into_bytes().into();
r
};
chunk.copy_from_slice(&u[..chunk.len()]);
for _ in 1..rounds {
let mut mac = Hmac::<Sha256>::new_from_slice(password).unwrap();
mac.update(&u);
u = mac.finalize().into_bytes().into();
for (a, b) in chunk.iter_mut().zip(u.iter()) {
*a ^= b;
}
}
}
}
let cases: &[(&[u8], &[u8], u32, usize)] = &[
(b"password", b"salt", 1, 32),
(b"password", b"salt", 7, 32),
(b"pw", b"NaCl", 100, 64), (b"", b"", 50, 16), (&[0xffu8; 40], &[0x01u8; 13], 33, 48), ];
for &(pw, salt, rounds, len) in cases {
let mut got = vec![0u8; len];
let mut want = vec![0u8; len];
pbkdf2_hmac_sha256(pw, salt, rounds, &mut got);
reference(pw, salt, rounds, &mut want);
assert_eq!(got, want, "pbkdf2 mismatch for rounds={rounds} len={len}");
assert_ne!(got, vec![0u8; len], "output must not be all zeros");
}
}
#[test]
fn test_generate_code() {
let code = PairCodeUtils::generate_code();
assert_eq!(code.len(), 8);
assert!(PairCodeUtils::validate_code(&code));
}
#[test]
fn test_validate_code_valid() {
assert!(PairCodeUtils::validate_code("ABCD1234"));
assert!(PairCodeUtils::validate_code("12345678"));
assert!(PairCodeUtils::validate_code("VWXYZ123"));
}
#[test]
fn test_validate_code_invalid() {
assert!(!PairCodeUtils::validate_code("ABC1234"));
assert!(!PairCodeUtils::validate_code("ABCD12345"));
assert!(!PairCodeUtils::validate_code("ABCD0123")); assert!(!PairCodeUtils::validate_code("ABCDOIJK")); assert!(!PairCodeUtils::validate_code("ABCDIJKL")); }
#[test]
fn test_encode_crockford() {
let zeros = [0u8; 5];
let encoded = PairCodeUtils::encode_crockford(&zeros);
assert_eq!(encoded, "11111111");
let ones = [0xFFu8; 5];
let encoded = PairCodeUtils::encode_crockford(&ones);
assert_eq!(encoded, "ZZZZZZZZ");
}
#[test]
fn test_derive_key_deterministic() {
let salt = [0u8; 32];
let key1 = PairCodeUtils::derive_key("ABCD1234", &salt);
let key2 = PairCodeUtils::derive_key("ABCD1234", &salt);
assert_eq!(key1, key2);
let key3 = PairCodeUtils::derive_key("WXYZ5678", &salt);
assert_ne!(key1, key3);
}
#[test]
fn test_encrypt_ephemeral_output_size() {
let ephemeral_pub = [0x42u8; 32];
let wrapped = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, "ABCD1234");
assert_eq!(wrapped.len(), 80);
assert_eq!(wrapped[0..32].len(), 32); assert_eq!(wrapped[32..48].len(), 16); assert_eq!(wrapped[48..80].len(), 32); }
#[test]
fn test_encrypt_decrypt_roundtrip() {
let ephemeral_pub = [0x42u8; 32];
let code = "ABCD1234";
let wrapped = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, code);
let decrypted = PairCodeUtils::decrypt_primary_ephemeral_pub(&wrapped, code)
.expect("Decryption should succeed");
assert_eq!(decrypted, ephemeral_pub);
}
#[test]
fn test_decrypt_invalid_length() {
let code = "ABCD1234";
let result = PairCodeUtils::decrypt_primary_ephemeral_pub(&[0u8; 79], code);
assert!(matches!(
result,
Err(PairCodeError::InvalidWrappedData { .. })
));
let result = PairCodeUtils::decrypt_primary_ephemeral_pub(&[0u8; 81], code);
assert!(matches!(
result,
Err(PairCodeError::InvalidWrappedData { .. })
));
}
fn props(os: Option<&str>, pt: Option<wa::device_props::PlatformType>) -> wa::DeviceProps {
wa::DeviceProps {
os: os.map(|s| s.to_string()),
platform_type: pt,
..Default::default()
}
}
#[test]
fn derive_chrome_linux_matches_wa_web() {
let p = props(Some("Linux"), Some(wa::device_props::PlatformType::CHROME));
assert_eq!(
derive_companion_platform(&p),
(CompanionWebClientType::Chrome, "Chrome (Linux)".to_string())
);
}
#[test]
fn derive_firefox_uses_companion_web_client_wire() {
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.wire_byte(), b'3');
assert_eq!(display, "Firefox (Linux)");
}
#[test]
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.wire_byte(), b'2');
assert_eq!(display, "Edge (Windows)");
}
#[test]
fn derive_android_platform_types_map_to_chrome() {
use wa::device_props::PlatformType as P;
for pt in [P::ANDROID_PHONE, P::ANDROID_TABLET, P::ANDROID_AMBIGUOUS] {
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]
fn derive_ios_phone_falls_back_to_other_web_client_and_chrome() {
let p = props(Some("iOS"), Some(wa::device_props::PlatformType::IOS_PHONE));
let (id, display) = derive_companion_platform(&p);
assert_eq!(id, CompanionWebClientType::OtherWebClient);
assert_eq!(display, "Chrome (iOS)");
}
#[test]
fn derive_no_os_substitutes_linux() {
let p = props(None, Some(wa::device_props::PlatformType::CHROME));
assert_eq!(
derive_companion_platform(&p),
(CompanionWebClientType::Chrome, "Chrome (Linux)".to_string())
);
}
#[test]
fn derive_empty_os_substitutes_linux() {
let p = props(Some(" "), Some(wa::device_props::PlatformType::CHROME));
assert_eq!(
derive_companion_platform(&p),
(CompanionWebClientType::Chrome, "Chrome (Linux)".to_string())
);
}
#[test]
fn derive_unknown_proto_yields_other_web_client_id_and_chrome_display() {
let p = props(None, None);
assert_eq!(
derive_companion_platform(&p),
(
CompanionWebClientType::OtherWebClient,
"Chrome (Linux)".to_string()
)
);
}
#[test]
fn derive_display_uses_known_label_for_every_proto_variant() {
use wa::device_props::PlatformType as P;
const SERVER_ACCEPT_LIST: &[u8] = b"0123456789abcdefghijklm";
const KNOWN_LABELS: &[&str] = &[
"Chrome", "Edge", "Firefox", "IE", "Opera", "Safari", "Android",
];
for pt in [
P::UNKNOWN,
P::CHROME,
P::FIREFOX,
P::IE,
P::OPERA,
P::SAFARI,
P::EDGE,
P::DESKTOP,
P::IPAD,
P::ANDROID_TABLET,
P::OHANA,
P::ALOHA,
P::CATALINA,
P::TCL_TV,
P::IOS_PHONE,
P::IOS_CATALYST,
P::ANDROID_PHONE,
P::ANDROID_AMBIGUOUS,
P::WEAR_OS,
P::AR_WRIST,
P::AR_DEVICE,
P::UWP,
P::VR,
P::CLOUD_API,
P::SMARTGLASSES,
] {
let p = props(Some("Linux"), Some(pt));
let (id, display) = derive_companion_platform(&p);
assert!(
SERVER_ACCEPT_LIST.contains(&id.wire_byte()),
"{pt:?} wire byte {:?} outside server accept list",
id.wire_byte() as char,
);
let label = display.split(" (").next().unwrap();
assert!(
KNOWN_LABELS.contains(&label),
"{pt:?} produced display {display:?} with unexpected label {label:?}"
);
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::ANDROID_PHONE),
);
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 resolve_display_os_override_is_verbatim() {
let p = props(Some("Linux"), Some(wa::device_props::PlatformType::CHROME));
let opts = PairCodeOptions {
display_os: Some("Ubuntu".to_string()),
..Default::default()
};
assert_eq!(
resolve_companion_platform(&opts, &p),
(
CompanionWebClientType::Chrome,
"Chrome (Ubuntu)".to_string()
)
);
}
#[test]
fn resolve_display_os_override_beats_branding_props_os() {
let p = props(Some("Veloz"), Some(wa::device_props::PlatformType::CHROME));
let opts = PairCodeOptions {
display_os: Some("Fedora".to_string()),
..Default::default()
};
assert_eq!(resolve_companion_platform(&opts, &p).1, "Chrome (Fedora)");
}
#[test]
fn resolve_display_os_override_whitespace_falls_back_to_coercion() {
let p = props(Some("Veloz"), Some(wa::device_props::PlatformType::CHROME));
let opts = PairCodeOptions {
display_os: Some(" ".to_string()),
..Default::default()
};
assert_eq!(resolve_companion_platform(&opts, &p).1, "Chrome (Linux)");
}
fn waiting_at(ts: i64) -> PairCodeState {
PairCodeState::WaitingForPhoneConfirmation {
pairing_ref: b"3@2:ref".to_vec(),
phone_jid: "15551234567".to_string(),
pair_code: "ABCD1234".to_string(),
ephemeral_keypair: Box::new(KeyPair::generate(
&mut rand::make_rng::<rand::rngs::StdRng>(),
)),
code_generation_ts: ts,
primary_hello_attempt_count: 0,
}
}
#[test]
fn live_flow_remaining_is_none_when_no_code_is_outstanding() {
assert_eq!(PairCodeState::Idle.live_flow_remaining(1_000), None);
assert_eq!(PairCodeState::Completed.live_flow_remaining(1_000), None);
}
#[test]
fn live_flow_remaining_counts_down_the_validity_window() {
let validity = PairCodeUtils::code_validity().as_secs() as i64;
assert_eq!(
waiting_at(1_000).live_flow_remaining(1_000),
Some(PairCodeUtils::code_validity())
);
assert_eq!(
waiting_at(1_000).live_flow_remaining(1_000 + 30),
Some(std::time::Duration::from_secs(validity as u64 - 30))
);
}
#[test]
fn live_flow_remaining_treats_the_exact_window_as_still_live() {
let validity = PairCodeUtils::code_validity().as_secs() as i64;
assert_eq!(
waiting_at(1_000).live_flow_remaining(1_000 + validity),
Some(std::time::Duration::ZERO)
);
assert_eq!(
waiting_at(1_000).live_flow_remaining(1_000 + validity + 1),
None,
"an expired code is not a flow anyone can still complete"
);
}
#[test]
fn live_flow_remaining_survives_a_backwards_clock() {
assert_eq!(
waiting_at(1_000).live_flow_remaining(900),
Some(PairCodeUtils::code_validity())
);
}
#[test]
fn test_code_validity_duration() {
let duration = PairCodeUtils::code_validity();
assert_eq!(duration.as_secs(), 180);
}
#[test]
fn test_validate_code_case_insensitive() {
assert!(PairCodeUtils::validate_code("abcd1234"));
assert!(PairCodeUtils::validate_code("AbCd1234"));
assert!(PairCodeUtils::validate_code("vwxyz123"));
}
#[test]
fn test_validate_code_all_crockford_chars() {
assert!(PairCodeUtils::validate_code("12345678"));
assert!(PairCodeUtils::validate_code("9ABCDEFG"));
assert!(PairCodeUtils::validate_code("HJKLMNPQ"));
assert!(PairCodeUtils::validate_code("RSTVWXYZ"));
}
#[test]
fn test_generate_code_uniqueness() {
let codes: Vec<String> = (0..100).map(|_| PairCodeUtils::generate_code()).collect();
let unique_codes: std::collections::HashSet<_> = codes.iter().collect();
assert!(unique_codes.len() > 95);
}
#[test]
fn test_encrypt_produces_different_output_each_time() {
let ephemeral_pub = [0x42u8; 32];
let code = "ABCD1234";
let wrapped1 = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, code);
let wrapped2 = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, code);
assert_ne!(&wrapped1[0..32], &wrapped2[0..32]); assert_ne!(&wrapped1[32..48], &wrapped2[32..48]); }
#[test]
fn test_decrypt_with_wrong_code_produces_garbage() {
let ephemeral_pub = [0x42u8; 32];
let correct_code = "ABCD1234";
let wrong_code = "WXYZ5678";
let wrapped = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, correct_code);
let decrypted = PairCodeUtils::decrypt_primary_ephemeral_pub(&wrapped, wrong_code)
.expect("Decryption should succeed structurally");
assert_ne!(decrypted, ephemeral_pub);
}
#[test]
fn test_derive_key_with_different_salts() {
let code = "ABCD1234";
let salt1 = [0u8; 32];
let salt2 = [1u8; 32];
let key1 = PairCodeUtils::derive_key(code, &salt1);
let key2 = PairCodeUtils::derive_key(code, &salt2);
assert_ne!(key1, key2);
}
#[test]
fn pair_code_options_default_has_no_platform_hardcode() {
let options = PairCodeOptions::default();
assert!(options.phone_number.is_empty());
assert!(options.show_push_notification, "default must keep push on");
assert!(options.custom_code.is_none());
assert!(
options.platform_id.is_none(),
"platform_id default must be None so derivation kicks in"
);
}
#[test]
fn test_pair_code_options_with_custom_code() {
let options = PairCodeOptions {
phone_number: "15551234567".to_string(),
custom_code: Some("MYCODE12".to_string()),
..Default::default()
};
assert_eq!(options.phone_number, "15551234567");
assert_eq!(options.custom_code, Some("MYCODE12".to_string()));
}
#[test]
fn test_pair_code_state_debug() {
let idle = PairCodeState::Idle;
assert_eq!(format!("{:?}", idle), "Idle");
let completed = PairCodeState::Completed;
assert_eq!(format!("{:?}", completed), "Completed");
}
#[test]
fn test_pair_code_error_display() {
let err = PairCodeError::PhoneNumberRequired;
assert_eq!(err.to_string(), "phone number is required");
let err = PairCodeError::PhoneNumberTooShort;
assert_eq!(
err.to_string(),
"phone number is too short (must be at least 7 digits)"
);
let err = PairCodeError::InvalidCustomCode;
assert_eq!(
err.to_string(),
"invalid custom code: must be 8 characters from Crockford Base32 alphabet"
);
let err = PairCodeError::InvalidWrappedData {
expected: 80,
got: 50,
};
assert_eq!(
err.to_string(),
"invalid wrapped data: expected 80 bytes, got 50"
);
}
#[test]
fn invalid_primary_ephemeral_key_preserves_curve_source() {
let err = PairCodeError::InvalidPrimaryEphemeralKey(CurveError::NoKeyTypeIdentifier);
let src = std::error::Error::source(&err).expect("source preserved");
let curve = src
.downcast_ref::<CurveError>()
.expect("downcasts to CurveError");
assert!(matches!(curve, CurveError::NoKeyTypeIdentifier));
}
#[test]
fn bundle_aead_preserves_crypto_provider_source() {
let err = PairCodeError::BundleAead(CryptoProviderError::BadInput);
let src = std::error::Error::source(&err).expect("source preserved");
let cpe = src
.downcast_ref::<CryptoProviderError>()
.expect("downcasts to CryptoProviderError");
assert!(matches!(cpe, CryptoProviderError::BadInput));
}
#[test]
fn test_crockford_encoding_boundary_values() {
let bytes = [0x00, 0x00, 0x00, 0x00, 0x1F]; let encoded = PairCodeUtils::encode_crockford(&bytes);
assert_eq!(encoded.chars().last().unwrap(), 'Z');
let bytes = [0x00, 0x00, 0x00, 0x00, 0x01]; let encoded = PairCodeUtils::encode_crockford(&bytes);
assert_eq!(encoded.chars().last().unwrap(), '2');
}
fn child_bytes<'a>(node: &'a Node, tag: &str) -> &'a [u8] {
let n = node
.get_optional_child_by_tag(&[tag])
.unwrap_or_else(|| panic!("missing <{tag}>"));
match n.content.as_ref() {
Some(NodeContent::Bytes(b)) => b.as_slice(),
other => panic!("expected Bytes for <{tag}>, got {other:?}"),
}
}
fn build_iq(pid: &str, pdisp: &str) -> Node {
let noise = [0xAAu8; 32];
let wrapped = [0xBBu8; 80];
PairCodeUtils::build_companion_hello_iq(
"15551234567",
&noise,
&wrapped,
pid,
pdisp,
true,
"req-1".to_string(),
)
}
#[test]
fn companion_hello_iq_shape() {
let iq = build_iq("e", "Android (Android)");
assert_eq!(iq.tag, "iq");
let reg = iq
.get_optional_child_by_tag(&["link_code_companion_reg"])
.expect("link_code_companion_reg");
let attrs: std::collections::HashMap<String, String> = reg
.attrs
.iter()
.map(|(k, v)| (k.to_string(), v.as_str().into_owned()))
.collect();
assert_eq!(
attrs.get("stage").map(String::as_str),
Some("companion_hello")
);
assert_eq!(
attrs.get("jid").map(String::as_str),
Some("15551234567@s.whatsapp.net")
);
assert_eq!(
attrs
.get("should_show_push_notification")
.map(String::as_str),
Some("true")
);
assert_eq!(child_bytes(reg, "link_code_pairing_nonce"), &[0u8]);
}
#[test]
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 (16)"
);
}
#[test]
fn companion_hello_iq_chrome_linux_wire_parity() {
let iq = build_iq("1", "Chrome (Linux)");
let reg = iq
.get_optional_child_by_tag(&["link_code_companion_reg"])
.unwrap();
assert_eq!(child_bytes(reg, "companion_platform_id"), b"1");
assert_eq!(
child_bytes(reg, "companion_platform_display"),
b"Chrome (Linux)"
);
}
#[test]
fn android_device_props_emit_server_accepted_companion_hello() {
let props = wa::DeviceProps {
os: Some("Android".into()),
platform_type: Some(wa::device_props::PlatformType::ANDROID_PHONE),
..Default::default()
};
let (pid, pdisp) = resolve_companion_platform(&PairCodeOptions::default(), &props);
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"1");
assert_eq!(
child_bytes(reg, "companion_platform_display"),
b"Chrome (Android)"
);
}
#[test]
fn explicit_options_override_id_and_display_follows() {
let props = wa::DeviceProps {
os: Some("Android".into()),
platform_type: Some(wa::device_props::PlatformType::ANDROID_PHONE),
..Default::default()
};
let opts = PairCodeOptions {
platform_id: Some(CompanionWebClientType::Chrome),
..Default::default()
};
let (pid, pdisp) = resolve_companion_platform(&opts, &props);
assert_eq!(pid, CompanionWebClientType::Chrome);
assert_eq!(pdisp, "Chrome (Android)");
}
#[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);
}
}