Skip to main content

wacore/
pair_code.rs

1//! Pair code authentication for phone number linking.
2//!
3//! This module implements the alternative device linking protocol used when
4//! users enter an 8-character code on their phone instead of scanning a QR code.
5//!
6//! ## Protocol Overview
7//!
8//! 1. **Stage 1 (companion_hello)**: Client generates a code and sends encrypted
9//!    ephemeral public key to server. Server returns a pairing ref.
10//!
11//! 2. **Stage 2 (companion_finish)**: When user enters code on phone, server
12//!    sends notification with primary's ephemeral key. Client performs DH and
13//!    sends encrypted key bundle.
14//!
15//! ## Cryptography
16//!
17//! - Code: 5 random bytes → Crockford Base32 → 8 characters
18//! - Key derivation: PBKDF2-SHA256 with 131,072 iterations
19//! - Ephemeral encryption: AES-256-CTR
20//! - Bundle encryption: AES-256-GCM after HKDF key derivation
21
22use crate::StringEnum;
23use crate::libsignal::protocol::{KeyPair, PublicKey};
24use aes::cipher::{KeyIvInit, StreamCipher};
25use aes_gcm::Aes256Gcm;
26use aes_gcm::aead::{Aead, KeyInit};
27use ctr::Ctr128BE;
28use hkdf::Hkdf;
29use rand::RngExt;
30use sha2::Sha256;
31use wacore_binary::builder::NodeBuilder;
32use wacore_binary::jid::SERVER_JID;
33use wacore_binary::node::{Node, NodeContent};
34
35// Type aliases
36type Aes256Ctr = Ctr128BE<aes::Aes256>;
37
38/// PBKDF2 iterations for pair code key derivation.
39/// Matches WhatsApp Web's implementation (2^17 = 131,072).
40const PAIR_CODE_PBKDF2_ITERATIONS: u32 = 131_072;
41
42/// Salt size for PBKDF2 key derivation.
43const PAIR_CODE_SALT_SIZE: usize = 32;
44
45/// IV size for AES-CTR encryption.
46const PAIR_CODE_IV_SIZE: usize = 16;
47
48/// Crockford Base32 alphabet used for pair codes.
49/// Excludes 0, I, O, U to prevent visual confusion.
50const CROCKFORD_ALPHABET: &[u8; 32] = b"123456789ABCDEFGHJKLMNPQRSTVWXYZ";
51
52/// Validity duration for pair codes (approximately).
53const PAIR_CODE_VALIDITY_SECS: u64 = 180;
54
55/// Platform identifiers for companion devices.
56/// These match the DeviceProps.PlatformType protobuf enum.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, StringEnum)]
58#[repr(u8)]
59pub enum PlatformId {
60    #[str = "0"]
61    Unknown = 0,
62    #[string_default]
63    #[str = "1"]
64    Chrome = 1,
65    #[str = "2"]
66    Firefox = 2,
67    #[str = "3"]
68    InternetExplorer = 3,
69    #[str = "4"]
70    Opera = 4,
71    #[str = "5"]
72    Safari = 5,
73    #[str = "6"]
74    Edge = 6,
75    #[str = "7"]
76    Electron = 7,
77    #[str = "8"]
78    Uwp = 8,
79    #[str = "9"]
80    OtherWebClient = 9,
81}
82
83/// Options for pair code authentication.
84#[derive(Debug, Clone)]
85pub struct PairCodeOptions {
86    /// Phone number with country code, no leading zeros or special chars (e.g., "15551234567").
87    pub phone_number: String,
88    /// Whether to show push notification on phone (default: true).
89    pub show_push_notification: bool,
90    /// Custom pairing code (8 chars from Crockford alphabet, or None for random).
91    pub custom_code: Option<String>,
92    /// Platform identifier for companion device.
93    pub platform_id: PlatformId,
94    /// Platform display name (e.g., "Chrome (Linux)").
95    pub platform_display: String,
96}
97
98impl Default for PairCodeOptions {
99    fn default() -> Self {
100        Self {
101            phone_number: String::new(),
102            show_push_notification: true,
103            custom_code: None,
104            platform_id: PlatformId::Chrome,
105            platform_display: "Chrome (Linux)".to_string(),
106        }
107    }
108}
109
110/// State machine for pair code authentication flow.
111#[derive(Default)]
112pub enum PairCodeState {
113    /// Initial state - no pair code request in progress.
114    #[default]
115    Idle,
116    /// Stage 1 complete - waiting for phone to confirm code entry.
117    WaitingForPhoneConfirmation {
118        /// Reference returned by server in stage 1.
119        pairing_ref: Vec<u8>,
120        /// Phone number JID (without @s.whatsapp.net).
121        phone_jid: String,
122        /// The 8-character pair code (needed to decrypt primary's ephemeral key).
123        pair_code: String,
124        /// Ephemeral keypair generated for this session.
125        ephemeral_keypair: Box<KeyPair>,
126    },
127    /// Pairing completed (success or failure).
128    Completed,
129}
130
131impl std::fmt::Debug for PairCodeState {
132    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
133        match self {
134            Self::Idle => write!(f, "Idle"),
135            Self::WaitingForPhoneConfirmation { phone_jid, .. } => f
136                .debug_struct("WaitingForPhoneConfirmation")
137                .field("phone_jid", phone_jid)
138                .finish_non_exhaustive(),
139            Self::Completed => write!(f, "Completed"),
140        }
141    }
142}
143
144/// Core pair code cryptographic utilities.
145///
146/// All operations are platform-independent and can be used in `no_std` environments.
147pub struct PairCodeUtils;
148
149impl PairCodeUtils {
150    /// Generates a random 8-character pair code using Crockford Base32.
151    ///
152    /// The code consists of characters from `123456789ABCDEFGHJKLMNPQRSTVWXYZ`,
153    /// which excludes 0, I, O, and U to prevent visual confusion.
154    pub fn generate_code() -> String {
155        let mut bytes = [0u8; 5];
156        rand::make_rng::<rand::rngs::StdRng>().fill(&mut bytes);
157        Self::encode_crockford(&bytes)
158    }
159
160    /// Validates a custom pair code.
161    ///
162    /// Returns `true` if the code is exactly 8 characters and all characters
163    /// are from the Crockford Base32 alphabet.
164    pub fn validate_code(code: &str) -> bool {
165        code.len() == 8
166            && code
167                .bytes()
168                .all(|b| CROCKFORD_ALPHABET.contains(&b.to_ascii_uppercase()))
169    }
170
171    /// Encodes 5 bytes to an 8-character Crockford Base32 string.
172    ///
173    /// 5 bytes = 40 bits = 8 × 5-bit groups, each mapped to the alphabet.
174    fn encode_crockford(bytes: &[u8; 5]) -> String {
175        // Combine 5 bytes into a 40-bit value
176        let mut accumulator: u64 = 0;
177        for &byte in bytes {
178            accumulator = (accumulator << 8) | u64::from(byte);
179        }
180
181        // Extract 8 × 5-bit groups
182        let mut result = String::with_capacity(8);
183        for i in (0..8).rev() {
184            let index = ((accumulator >> (i * 5)) & 0x1F) as usize;
185            result.push(CROCKFORD_ALPHABET[index] as char);
186        }
187        result
188    }
189
190    /// Derives an encryption key from a pair code using PBKDF2-SHA256.
191    ///
192    /// This is a blocking operation (~100ms on modern hardware due to 131,072 iterations).
193    /// Consider wrapping in `spawn_blocking` for async contexts.
194    pub fn derive_key(code: &str, salt: &[u8; PAIR_CODE_SALT_SIZE]) -> [u8; 32] {
195        let mut key = [0u8; 32];
196        pbkdf2::pbkdf2_hmac::<Sha256>(code.as_bytes(), salt, PAIR_CODE_PBKDF2_ITERATIONS, &mut key);
197        key
198    }
199
200    /// Encrypts the companion ephemeral public key for stage 1.
201    ///
202    /// Returns the wrapped ephemeral data: `salt (32) || iv (16) || ciphertext (32)` = 80 bytes.
203    pub fn encrypt_ephemeral_pub(ephemeral_pub: &[u8; 32], code: &str) -> [u8; 80] {
204        // Generate random salt and IV
205        let mut salt = [0u8; PAIR_CODE_SALT_SIZE];
206        let mut iv = [0u8; PAIR_CODE_IV_SIZE];
207        rand::make_rng::<rand::rngs::StdRng>().fill(&mut salt);
208        rand::make_rng::<rand::rngs::StdRng>().fill(&mut iv);
209
210        // Derive key from code and encrypt with AES-256-CTR
211        let key = Self::derive_key(code, &salt);
212        let mut cipher = Aes256Ctr::new(&key.into(), &iv.into());
213        let mut ciphertext = *ephemeral_pub;
214        cipher.apply_keystream(&mut ciphertext);
215
216        // Concatenate: salt (32) || iv (16) || ciphertext (32) = 80 bytes
217        let mut result = [0u8; 80];
218        result[..32].copy_from_slice(&salt);
219        result[32..48].copy_from_slice(&iv);
220        result[48..80].copy_from_slice(&ciphertext);
221
222        result
223    }
224
225    /// Decrypts the primary device's ephemeral public key received in stage 2.
226    ///
227    /// The wrapped data format is: `salt (32) || iv (16) || ciphertext (32)` = 80 bytes.
228    ///
229    /// # Important
230    ///
231    /// This function extracts the salt from the wrapped data and derives a fresh
232    /// encryption key using PBKDF2 with the pair code. This is necessary because
233    /// the primary device encrypts with their own random salt.
234    pub fn decrypt_primary_ephemeral_pub(
235        wrapped: &[u8],
236        pair_code: &str,
237    ) -> Result<[u8; 32], PairCodeError> {
238        if wrapped.len() != 80 {
239            return Err(PairCodeError::InvalidWrappedData {
240                expected: 80,
241                got: wrapped.len(),
242            });
243        }
244
245        // Extract salt, iv, and ciphertext (length validated above guarantees these succeed)
246        let salt: [u8; PAIR_CODE_SALT_SIZE] = wrapped[0..32]
247            .try_into()
248            .expect("salt slice is exactly 32 bytes");
249        let iv: [u8; PAIR_CODE_IV_SIZE] = wrapped[32..48]
250            .try_into()
251            .expect("iv slice is exactly 16 bytes");
252        let mut plaintext: [u8; 32] = wrapped[48..80]
253            .try_into()
254            .expect("ciphertext slice is exactly 32 bytes");
255
256        // Derive key using the PRIMARY's salt
257        let derived_key = Self::derive_key(pair_code, &salt);
258
259        // Decrypt with AES-256-CTR
260        let mut cipher = Aes256Ctr::new((&derived_key).into(), &iv.into());
261        cipher.apply_keystream(&mut plaintext);
262
263        Ok(plaintext)
264    }
265
266    /// Builds the stage 1 (companion_hello) IQ node.
267    pub fn build_companion_hello_iq(
268        phone_number: &str,
269        noise_static_pub: &[u8; 32],
270        wrapped_ephemeral: &[u8; 80],
271        platform_id: PlatformId,
272        platform_display: &str,
273        show_push_notification: bool,
274        req_id: String,
275    ) -> Node {
276        let link_code_reg = NodeBuilder::new("link_code_companion_reg")
277            .attrs([
278                ("jid", format!("{}@s.whatsapp.net", phone_number)),
279                ("stage", "companion_hello".to_string()),
280                (
281                    "should_show_push_notification",
282                    show_push_notification.to_string(),
283                ),
284            ])
285            .children([
286                NodeBuilder::new("link_code_pairing_wrapped_companion_ephemeral_pub")
287                    .bytes(wrapped_ephemeral.to_vec())
288                    .build(),
289                NodeBuilder::new("companion_server_auth_key_pub")
290                    .bytes(noise_static_pub.to_vec())
291                    .build(),
292                NodeBuilder::new("companion_platform_id")
293                    .bytes(platform_id.as_str().as_bytes().to_vec())
294                    .build(),
295                NodeBuilder::new("companion_platform_display")
296                    .bytes(platform_display.as_bytes().to_vec())
297                    .build(),
298                // Nonce is sent as string "0" (matching whatsmeow/baileys)
299                NodeBuilder::new("link_code_pairing_nonce")
300                    .bytes(b"0".to_vec())
301                    .build(),
302            ])
303            .build();
304
305        NodeBuilder::new("iq")
306            .attrs([
307                ("xmlns", "md".to_string()),
308                ("type", "set".to_string()),
309                ("to", SERVER_JID.to_string()),
310                ("id", req_id),
311            ])
312            .children([link_code_reg])
313            .build()
314    }
315
316    /// Parses the stage 1 response to extract the pairing ref.
317    pub fn parse_companion_hello_response(node: &Node) -> Option<Vec<u8>> {
318        node.get_optional_child_by_tag(&["link_code_companion_reg"])
319            .and_then(|n| n.get_optional_child_by_tag(&["link_code_pairing_ref"]))
320            .and_then(|n| n.content.as_ref())
321            .and_then(|c| match c {
322                NodeContent::Bytes(b) => Some(b.clone()),
323                _ => None,
324            })
325    }
326
327    /// Builds the stage 2 (companion_finish) IQ node.
328    pub fn build_companion_finish_iq(
329        phone_number: &str,
330        wrapped_key_bundle: Vec<u8>,
331        identity_pub: &[u8; 32],
332        pairing_ref: &[u8],
333        req_id: String,
334    ) -> Node {
335        let link_code_reg = NodeBuilder::new("link_code_companion_reg")
336            .attrs([
337                ("jid", format!("{}@s.whatsapp.net", phone_number)),
338                ("stage", "companion_finish".to_string()),
339            ])
340            .children([
341                NodeBuilder::new("link_code_pairing_wrapped_key_bundle")
342                    .bytes(wrapped_key_bundle)
343                    .build(),
344                NodeBuilder::new("companion_identity_public")
345                    .bytes(identity_pub.to_vec())
346                    .build(),
347                NodeBuilder::new("link_code_pairing_ref")
348                    .bytes(pairing_ref.to_vec())
349                    .build(),
350            ])
351            .build();
352
353        NodeBuilder::new("iq")
354            .attrs([
355                ("xmlns", "md".to_string()),
356                ("type", "set".to_string()),
357                ("to", SERVER_JID.to_string()),
358                ("id", req_id),
359            ])
360            .children([link_code_reg])
361            .build()
362    }
363
364    /// Prepares the encrypted key bundle for stage 2.
365    ///
366    /// This performs:
367    /// 1. DH key exchange with primary's ephemeral public key
368    /// 2. DH key exchange with primary's identity public key
369    /// 3. HKDF to derive bundle encryption key
370    /// 4. AES-GCM encryption of the key bundle
371    ///
372    /// Returns the wrapped bundle and a new ADV secret derived from the DH exchanges.
373    /// The ADV secret should be stored to enable HMAC verification of pair-success.
374    pub fn prepare_key_bundle(
375        ephemeral_keypair: &KeyPair,
376        primary_ephemeral_pub: &[u8; 32],
377        primary_identity_pub: &[u8; 32],
378        identity_key: &KeyPair,
379    ) -> Result<(Vec<u8>, [u8; 32]), PairCodeError> {
380        // Parse primary's ephemeral public key
381        let primary_eph_pub =
382            PublicKey::from_djb_public_key_bytes(primary_ephemeral_pub).map_err(|e| {
383                PairCodeError::CryptoError(format!("Invalid primary ephemeral key: {e}"))
384            })?;
385
386        // Parse primary's identity public key
387        let primary_id_pub =
388            PublicKey::from_djb_public_key_bytes(primary_identity_pub).map_err(|e| {
389                PairCodeError::CryptoError(format!("Invalid primary identity key: {e}"))
390            })?;
391
392        // DH 1: Ephemeral key exchange
393        let ephemeral_shared = ephemeral_keypair
394            .private_key
395            .calculate_agreement(&primary_eph_pub)
396            .map_err(|e| PairCodeError::CryptoError(format!("Ephemeral DH failed: {e}")))?;
397
398        // DH 2: Identity key exchange (for ADV secret derivation)
399        let identity_shared = identity_key
400            .private_key
401            .calculate_agreement(&primary_id_pub)
402            .map_err(|e| PairCodeError::CryptoError(format!("Identity DH failed: {e}")))?;
403
404        // Generate random bytes for ADV secret derivation
405        let mut random_bytes = [0u8; 32];
406        rand::make_rng::<rand::rngs::StdRng>().fill(&mut random_bytes);
407
408        // Derive ADV secret using HKDF
409        // Combined secret = ephemeral_shared + identity_shared + random_bytes
410        let mut combined_secret = Vec::with_capacity(96);
411        combined_secret.extend_from_slice(&ephemeral_shared);
412        combined_secret.extend_from_slice(&identity_shared);
413        combined_secret.extend_from_slice(&random_bytes);
414
415        let hk_adv = Hkdf::<Sha256>::new(None, &combined_secret);
416        let mut new_adv_secret = [0u8; 32];
417        hk_adv
418            .expand(b"adv_secret", &mut new_adv_secret)
419            .map_err(|_| PairCodeError::CryptoError("HKDF expand for adv_secret failed".into()))?;
420
421        // Prepare bundle: companion_identity_pub (32) + primary_identity_pub (32) + random_bytes (32) = 96 bytes
422        let mut bundle = Vec::with_capacity(96);
423        bundle.extend_from_slice(identity_key.public_key.public_key_bytes());
424        bundle.extend_from_slice(primary_identity_pub);
425        bundle.extend_from_slice(&random_bytes);
426
427        // Generate salt for HKDF
428        let mut key_bundle_salt = [0u8; 32];
429        rand::make_rng::<rand::rngs::StdRng>().fill(&mut key_bundle_salt);
430
431        // Derive bundle encryption key using HKDF
432        // HKDF(IKM=ephemeral_shared, salt=random_salt, info="link_code_pairing_key_bundle_encryption_key")
433        let hk_bundle = Hkdf::<Sha256>::new(Some(&key_bundle_salt), &ephemeral_shared);
434        let mut enc_key = [0u8; 32];
435        hk_bundle
436            .expand(b"link_code_pairing_key_bundle_encryption_key", &mut enc_key)
437            .map_err(|_| {
438                PairCodeError::CryptoError("HKDF expand for bundle encryption key failed".into())
439            })?;
440
441        // Generate random IV for AES-GCM (12 bytes)
442        let mut iv = [0u8; 12];
443        rand::make_rng::<rand::rngs::StdRng>().fill(&mut iv);
444
445        // AES-GCM encrypt the bundle
446        let cipher = Aes256Gcm::new_from_slice(&enc_key)
447            .map_err(|e| PairCodeError::CryptoError(format!("AES-GCM init failed: {e}")))?;
448        let nonce = aes_gcm::Nonce::from_slice(&iv);
449        let encrypted_bundle = cipher
450            .encrypt(nonce, bundle.as_slice())
451            .map_err(|e| PairCodeError::CryptoError(format!("AES-GCM encryption failed: {e}")))?;
452
453        // Wrapped bundle = salt (32) + iv (12) + encrypted_bundle (96 + 16 = 112)
454        let mut wrapped_bundle = Vec::with_capacity(32 + 12 + encrypted_bundle.len());
455        wrapped_bundle.extend_from_slice(&key_bundle_salt);
456        wrapped_bundle.extend_from_slice(&iv);
457        wrapped_bundle.extend_from_slice(&encrypted_bundle);
458
459        Ok((wrapped_bundle, new_adv_secret))
460    }
461
462    /// Returns the pair code validity duration.
463    pub fn code_validity() -> std::time::Duration {
464        std::time::Duration::from_secs(PAIR_CODE_VALIDITY_SECS)
465    }
466}
467
468/// Errors that can occur during pair code operations.
469#[derive(Debug, thiserror::Error)]
470pub enum PairCodeError {
471    #[error("Phone number is required")]
472    PhoneNumberRequired,
473
474    #[error("Phone number is too short (must be at least 7 digits)")]
475    PhoneNumberTooShort,
476
477    #[error("Phone number must not start with 0 (use international format)")]
478    PhoneNumberNotInternational,
479
480    #[error("Invalid custom code: must be 8 characters from Crockford Base32 alphabet")]
481    InvalidCustomCode,
482
483    #[error("Invalid wrapped data: expected {expected} bytes, got {got}")]
484    InvalidWrappedData { expected: usize, got: usize },
485
486    #[error("Cryptographic operation failed: {0}")]
487    CryptoError(String),
488
489    #[error("Not in waiting state for pair code notification")]
490    NotWaiting,
491
492    #[error("Server response missing pairing ref")]
493    MissingPairingRef,
494
495    #[error("Request failed: {0}")]
496    RequestFailed(String),
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502
503    #[test]
504    fn test_generate_code() {
505        let code = PairCodeUtils::generate_code();
506        assert_eq!(code.len(), 8);
507        assert!(PairCodeUtils::validate_code(&code));
508    }
509
510    #[test]
511    fn test_validate_code_valid() {
512        assert!(PairCodeUtils::validate_code("ABCD1234"));
513        assert!(PairCodeUtils::validate_code("12345678"));
514        assert!(PairCodeUtils::validate_code("VWXYZ123"));
515    }
516
517    #[test]
518    fn test_validate_code_invalid() {
519        // Too short
520        assert!(!PairCodeUtils::validate_code("ABC1234"));
521        // Too long
522        assert!(!PairCodeUtils::validate_code("ABCD12345"));
523        // Contains invalid characters (0, O, I, L)
524        assert!(!PairCodeUtils::validate_code("ABCD0123")); // 0 is invalid
525        assert!(!PairCodeUtils::validate_code("ABCDOIJK")); // O is invalid
526        assert!(!PairCodeUtils::validate_code("ABCDIJKL")); // I and L are invalid
527    }
528
529    #[test]
530    fn test_encode_crockford() {
531        // Known test vector: 5 bytes of 0 should give the first character repeated
532        let zeros = [0u8; 5];
533        let encoded = PairCodeUtils::encode_crockford(&zeros);
534        assert_eq!(encoded, "11111111");
535
536        // All 0xFF should give last character repeated
537        let ones = [0xFFu8; 5];
538        let encoded = PairCodeUtils::encode_crockford(&ones);
539        assert_eq!(encoded, "ZZZZZZZZ");
540    }
541
542    #[test]
543    fn test_derive_key_deterministic() {
544        let salt = [0u8; 32];
545        let key1 = PairCodeUtils::derive_key("ABCD1234", &salt);
546        let key2 = PairCodeUtils::derive_key("ABCD1234", &salt);
547        assert_eq!(key1, key2);
548
549        // Different code should give different key
550        let key3 = PairCodeUtils::derive_key("WXYZ5678", &salt);
551        assert_ne!(key1, key3);
552    }
553
554    #[test]
555    fn test_encrypt_ephemeral_output_size() {
556        let ephemeral_pub = [0x42u8; 32];
557        let wrapped = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, "ABCD1234");
558        assert_eq!(wrapped.len(), 80);
559
560        // Verify structure: salt (32) || iv (16) || ciphertext (32)
561        assert_eq!(wrapped[0..32].len(), 32); // salt
562        assert_eq!(wrapped[32..48].len(), 16); // iv
563        assert_eq!(wrapped[48..80].len(), 32); // ciphertext
564    }
565
566    #[test]
567    fn test_encrypt_decrypt_roundtrip() {
568        let ephemeral_pub = [0x42u8; 32];
569        let code = "ABCD1234";
570
571        let wrapped = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, code);
572
573        // Decrypt using the pair code (extracts salt from wrapped data)
574        let decrypted = PairCodeUtils::decrypt_primary_ephemeral_pub(&wrapped, code)
575            .expect("Decryption should succeed");
576
577        assert_eq!(decrypted, ephemeral_pub);
578    }
579
580    #[test]
581    fn test_decrypt_invalid_length() {
582        let code = "ABCD1234";
583
584        // Too short
585        let result = PairCodeUtils::decrypt_primary_ephemeral_pub(&[0u8; 79], code);
586        assert!(matches!(
587            result,
588            Err(PairCodeError::InvalidWrappedData { .. })
589        ));
590
591        // Too long
592        let result = PairCodeUtils::decrypt_primary_ephemeral_pub(&[0u8; 81], code);
593        assert!(matches!(
594            result,
595            Err(PairCodeError::InvalidWrappedData { .. })
596        ));
597    }
598
599    #[test]
600    fn test_platform_id_string_enum() {
601        // StringEnum derive works correctly
602        assert_eq!(PlatformId::Chrome.as_str(), "1");
603        assert_eq!(PlatformId::Firefox.to_string(), "2");
604        assert_eq!(PlatformId::default(), PlatformId::Chrome);
605        // repr(u8) values match DeviceProps.PlatformType protobuf enum
606        assert_eq!(PlatformId::Chrome as u8, 1);
607    }
608
609    #[test]
610    fn test_code_validity_duration() {
611        let duration = PairCodeUtils::code_validity();
612        assert_eq!(duration.as_secs(), 180);
613    }
614
615    #[test]
616    fn test_validate_code_case_insensitive() {
617        // Lowercase should be valid (will be uppercased)
618        assert!(PairCodeUtils::validate_code("abcd1234"));
619        assert!(PairCodeUtils::validate_code("AbCd1234"));
620        assert!(PairCodeUtils::validate_code("vwxyz123"));
621    }
622
623    #[test]
624    fn test_validate_code_all_crockford_chars() {
625        // All valid Crockford Base32 characters
626        assert!(PairCodeUtils::validate_code("12345678"));
627        assert!(PairCodeUtils::validate_code("9ABCDEFG"));
628        assert!(PairCodeUtils::validate_code("HJKLMNPQ"));
629        assert!(PairCodeUtils::validate_code("RSTVWXYZ"));
630    }
631
632    #[test]
633    fn test_generate_code_uniqueness() {
634        // Generate multiple codes and verify they're unique
635        let codes: Vec<String> = (0..100).map(|_| PairCodeUtils::generate_code()).collect();
636        let unique_codes: std::collections::HashSet<_> = codes.iter().collect();
637        // Very unlikely to have duplicates in 100 codes with 40 bits of entropy
638        assert!(unique_codes.len() > 95);
639    }
640
641    #[test]
642    fn test_encrypt_produces_different_output_each_time() {
643        // Same input should produce different output due to random salt/iv
644        let ephemeral_pub = [0x42u8; 32];
645        let code = "ABCD1234";
646
647        let wrapped1 = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, code);
648        let wrapped2 = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, code);
649
650        // Salt and IV should be different
651        assert_ne!(&wrapped1[0..32], &wrapped2[0..32]); // Salt differs
652        assert_ne!(&wrapped1[32..48], &wrapped2[32..48]); // IV differs
653    }
654
655    #[test]
656    fn test_decrypt_with_wrong_code_produces_garbage() {
657        let ephemeral_pub = [0x42u8; 32];
658        let correct_code = "ABCD1234";
659        let wrong_code = "WXYZ5678";
660
661        let wrapped = PairCodeUtils::encrypt_ephemeral_pub(&ephemeral_pub, correct_code);
662
663        // Decrypt with wrong code - should succeed but produce garbage
664        let decrypted = PairCodeUtils::decrypt_primary_ephemeral_pub(&wrapped, wrong_code)
665            .expect("Decryption should succeed structurally");
666
667        // The decrypted data should NOT match the original
668        assert_ne!(decrypted, ephemeral_pub);
669    }
670
671    #[test]
672    fn test_derive_key_with_different_salts() {
673        let code = "ABCD1234";
674        let salt1 = [0u8; 32];
675        let salt2 = [1u8; 32];
676
677        let key1 = PairCodeUtils::derive_key(code, &salt1);
678        let key2 = PairCodeUtils::derive_key(code, &salt2);
679
680        // Different salts should produce different keys
681        assert_ne!(key1, key2);
682    }
683
684    #[test]
685    fn test_pair_code_options_default() {
686        let options = PairCodeOptions::default();
687        assert!(options.phone_number.is_empty());
688        assert!(options.show_push_notification);
689        assert!(options.custom_code.is_none());
690        assert_eq!(options.platform_id, PlatformId::Chrome);
691        assert_eq!(options.platform_display, "Chrome (Linux)");
692    }
693
694    #[test]
695    fn test_pair_code_options_with_custom_code() {
696        let options = PairCodeOptions {
697            phone_number: "15551234567".to_string(),
698            custom_code: Some("MYCODE12".to_string()),
699            ..Default::default()
700        };
701        assert_eq!(options.phone_number, "15551234567");
702        assert_eq!(options.custom_code, Some("MYCODE12".to_string()));
703    }
704
705    #[test]
706    fn test_pair_code_state_debug() {
707        let idle = PairCodeState::Idle;
708        assert_eq!(format!("{:?}", idle), "Idle");
709
710        let completed = PairCodeState::Completed;
711        assert_eq!(format!("{:?}", completed), "Completed");
712    }
713
714    #[test]
715    fn test_pair_code_error_display() {
716        let err = PairCodeError::PhoneNumberRequired;
717        assert_eq!(err.to_string(), "Phone number is required");
718
719        let err = PairCodeError::PhoneNumberTooShort;
720        assert_eq!(
721            err.to_string(),
722            "Phone number is too short (must be at least 7 digits)"
723        );
724
725        let err = PairCodeError::InvalidCustomCode;
726        assert_eq!(
727            err.to_string(),
728            "Invalid custom code: must be 8 characters from Crockford Base32 alphabet"
729        );
730
731        let err = PairCodeError::InvalidWrappedData {
732            expected: 80,
733            got: 50,
734        };
735        assert_eq!(
736            err.to_string(),
737            "Invalid wrapped data: expected 80 bytes, got 50"
738        );
739    }
740
741    #[test]
742    fn test_crockford_encoding_boundary_values() {
743        // Test specific byte patterns
744        let bytes = [0x00, 0x00, 0x00, 0x00, 0x1F]; // Last 5 bits = 31 = 'Z'
745        let encoded = PairCodeUtils::encode_crockford(&bytes);
746        assert_eq!(encoded.chars().last().unwrap(), 'Z');
747
748        let bytes = [0x00, 0x00, 0x00, 0x00, 0x01]; // Last 5 bits = 1 = '2'
749        let encoded = PairCodeUtils::encode_crockford(&bytes);
750        assert_eq!(encoded.chars().last().unwrap(), '2');
751    }
752}