Skip to main content

wechat_protocol/
client.rs

1#[path = "group_mutation.rs"]
2mod group_mutation;
3pub use group_mutation::*;
4#[path = "group_detail.rs"]
5mod group_detail;
6pub use group_detail::*;
7#[path = "group_inventory.rs"]
8mod group_inventory;
9pub use group_inventory::*;
10#[path = "logout.rs"]
11mod logout;
12#[path = "revoke.rs"]
13mod revoke;
14#[path = "status_notify.rs"]
15mod status_notify;
16pub use crate::wire::{
17    AddMessage, AvatarBytes, ContactLookup, ContactRecord, Heartbeat, InitContactPage, RoomMember,
18    RoomRoster, SyncBatch, SyncCommand, TextSendResult, VoiceChunk,
19};
20use crate::{
21    business::{self, AuthenticatedPacket, Prelogin},
22    crypto::Ecdh,
23    mmtls::{self, Session},
24    protobuf as p, wire,
25};
26use anyhow::{bail, ensure, Result};
27pub use logout::*;
28use openssl::{nid::Nid, pkey::PKey, rsa::Rsa};
29pub use revoke::*;
30use serde::{Deserialize, Serialize};
31pub use status_notify::*;
32use std::{fmt, time::Duration};
33use zeroize::Zeroize;
34
35#[derive(Clone, Serialize, Deserialize)]
36#[serde(deny_unknown_fields)]
37pub struct DeviceProfile {
38    pub device_id: Vec<u8>,
39    pub device_type: String,
40    pub client_version: i32,
41}
42impl fmt::Debug for DeviceProfile {
43    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
44        f.debug_struct("DeviceProfile")
45            .field("device_id", &"[redacted]")
46            .field("device_type", &self.device_type)
47            .field("client_version", &self.client_version)
48            .finish()
49    }
50}
51impl DeviceProfile {
52    pub fn generate(device_type: String) -> Result<Self> {
53        let bytes = crate::crypto::random::<16>()?;
54        let id = format!("l{}", hex::encode(bytes)).into_bytes()[..16].to_vec();
55        let p = Self {
56            device_id: id,
57            device_type,
58            client_version: -227271398,
59        };
60        p.validate()?;
61        Ok(p)
62    }
63    pub fn validate(&self) -> Result<()> {
64        ensure!(
65            self.device_id.len() == 16
66                && !self.device_type.is_empty()
67                && self.device_type.len() <= 1024
68                && !self.device_type.contains('\0'),
69            "Invalid native device profile"
70        );
71        ensure!(
72            self.client_version == -227271398,
73            "Unsupported native client version"
74        );
75        Ok(())
76    }
77}
78/// Raw accepted auth sections, retained only inside the encrypted account state.
79/// The update mask describes native application gates; presence alone does not
80/// mean an optional section changed active account credentials.
81#[derive(Clone, Default, Serialize, Deserialize)]
82#[serde(deny_unknown_fields)]
83pub struct AuthSnapshot {
84    pub update_mask: u32,
85    pub response: Vec<u8>,
86    pub auth: Vec<u8>,
87    pub account: Vec<u8>,
88    pub network: Vec<u8>,
89    pub ax_auth: Vec<u8>,
90}
91impl fmt::Debug for AuthSnapshot {
92    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
93        f.debug_struct("AuthSnapshot")
94            .field("sections", &"[redacted]")
95            .finish()
96    }
97}
98impl Drop for AuthSnapshot {
99    fn drop(&mut self) {
100        self.response.zeroize();
101        self.auth.zeroize();
102        self.account.zeroize();
103        self.network.zeroize();
104        self.ax_auth.zeroize();
105    }
106}
107/// Accepted credentials. Serialize only into the service's encrypted state vault.
108#[derive(Clone, Serialize, Deserialize)]
109#[serde(deny_unknown_fields)]
110pub struct AccountState {
111    pub schema: u32,
112    pub device: DeviceProfile,
113    pub uin: u32,
114    pub own_username: String,
115    pub cookie: Vec<u8>,
116    pub session_key: Vec<u8>,
117    pub request_key: Vec<u8>,
118    pub response_key: Vec<u8>,
119    pub auto_auth_key: Vec<u8>,
120    pub key_info: Vec<u8>,
121    /// Native AuthSect field 9 SKBuffer. This is the official profile password,
122    /// distinct from transport keys, and is never part of an HTTP response.
123    #[serde(default)]
124    pub database_password: Vec<u8>,
125    #[serde(default)]
126    pub auth_snapshot: AuthSnapshot,
127    pub long_host: String,
128    pub short_host: String,
129}
130impl fmt::Debug for AccountState {
131    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
132        f.debug_struct("AccountState")
133            .field("schema", &self.schema)
134            .field("credentials", &"[redacted]")
135            .finish()
136    }
137}
138impl Drop for AccountState {
139    fn drop(&mut self) {
140        self.cookie.zeroize();
141        self.session_key.zeroize();
142        self.request_key.zeroize();
143        self.response_key.zeroize();
144        self.auto_auth_key.zeroize();
145        self.key_info.zeroize();
146        self.database_password.zeroize();
147    }
148}
149impl AccountState {
150    pub fn validate(&self) -> Result<()> {
151        ensure!(
152            self.schema == 1
153                && self.uin != 0
154                && !self.own_username.is_empty()
155                && self.own_username.len() <= 256
156                && !self.own_username.contains('\0'),
157            "Invalid native account identity"
158        );
159        self.device.validate()?;
160        mmtls::validate_host(&self.long_host)?;
161        mmtls::validate_host(&self.short_host)?;
162        ensure!(
163            self.cookie.len() <= 15
164                && self.session_key.len() == 16
165                && ((self.request_key.is_empty() && self.response_key.is_empty())
166                    || (self.request_key.len() == 24 && self.response_key.len() == 24))
167                && self.auto_auth_key.len() <= 1024 * 1024
168                && self.key_info.len() <= 1024 * 1024
169                && (self.database_password.is_empty() || self.database_password.len() == 32)
170                && [
171                    &self.auth_snapshot.response,
172                    &self.auth_snapshot.auth,
173                    &self.auth_snapshot.account,
174                    &self.auth_snapshot.network,
175                    &self.auth_snapshot.ax_auth
176                ]
177                .iter()
178                .all(|v| v.len() <= 1024 * 1024),
179            "Invalid native account keys"
180        );
181        Ok(())
182    }
183}
184impl AccountState {
185    /// Import an operator-selected private baseline checkpoint. This only converts
186    /// explicit standalone credentials; it never searches or reads official files.
187    /// Return diagnostics contain field names, never supplied credential values.
188    pub fn from_baseline_json(input: &[u8]) -> Result<Self> {
189        use base64::{engine::general_purpose::STANDARD, Engine};
190        ensure!(
191            input.len() <= 8 * 1024 * 1024,
192            "Baseline checkpoint too large"
193        );
194        let value: serde_json::Value = serde_json::from_slice(input)
195            .map_err(|_| anyhow::anyhow!("Invalid baseline checkpoint JSON"))?;
196        let object = value
197            .as_object()
198            .ok_or_else(|| anyhow::anyhow!("Invalid baseline checkpoint object"))?;
199        let string = |name: &str| -> Result<String> {
200            object
201                .get(name)
202                .and_then(|v| v.as_str())
203                .map(str::to_string)
204                .ok_or_else(|| anyhow::anyhow!("Missing baseline field {name}"))
205        };
206        let decode = |name: &str, required: bool| -> Result<Vec<u8>> {
207            let encoded = match object.get(name) {
208                Some(v) => v
209                    .as_str()
210                    .ok_or_else(|| anyhow::anyhow!("Invalid baseline field {name}"))?,
211                None if !required => "",
212                _ => bail!("Missing baseline field {name}"),
213            };
214            let bytes = STANDARD
215                .decode(encoded)
216                .map_err(|_| anyhow::anyhow!("Invalid baseline encoding {name}"))?;
217            ensure!(
218                STANDARD.encode(&bytes) == encoded,
219                "Noncanonical baseline encoding {name}"
220            );
221            Ok(bytes)
222        };
223        ensure!(
224            object.get("schema").and_then(|v| v.as_u64()) == Some(1),
225            "Unsupported baseline schema"
226        );
227        let uin = match object.get("uin") {
228            Some(serde_json::Value::String(s)) => s
229                .parse::<u32>()
230                .map_err(|_| anyhow::anyhow!("Invalid baseline UIN"))?,
231            Some(n) => u32::try_from(
232                n.as_u64()
233                    .ok_or_else(|| anyhow::anyhow!("Invalid baseline UIN"))?,
234            )?,
235            None => bail!("Missing baseline UIN"),
236        };
237        let account = decode("accountSect", false)?;
238        let section_username = if account.is_empty() {
239            None
240        } else {
241            let f = p::decode(&account)?;
242            p::optional_data(&f, 1)?
243                .map(|v| std::str::from_utf8(v).map(str::to_string))
244                .transpose()
245                .map_err(|_| anyhow::anyhow!("Invalid baseline account username"))?
246        };
247        let own_username = object
248            .get("ownUsername")
249            .or_else(|| object.get("username"))
250            .map(|v| {
251                v.as_str()
252                    .map(str::to_string)
253                    .ok_or_else(|| anyhow::anyhow!("Invalid baseline username"))
254            })
255            .transpose()?
256            .or(section_username.clone())
257            .ok_or_else(|| anyhow::anyhow!("Missing baseline account identity"))?;
258        ensure!(
259            section_username.is_none_or(|s| s == own_username),
260            "Baseline account identity mismatch"
261        );
262        let state =
263            Self {
264                schema: 1,
265                device: DeviceProfile {
266                    device_id: decode("deviceId", true)?,
267                    device_type: string("deviceType")?,
268                    client_version: i32::try_from(
269                        object
270                            .get("clientVersion")
271                            .and_then(|v| v.as_i64())
272                            .ok_or_else(|| anyhow::anyhow!("Invalid baseline client version"))?,
273                    )?,
274                },
275                uin,
276                own_username,
277                cookie: decode("cookie", false)?,
278                session_key: decode("sessionKey", true)?,
279                request_key: decode("field23", false)?,
280                response_key: decode("field24", false)?,
281                auto_auth_key: decode("autoAuthKey", false)?,
282                key_info: decode("keyInfo", false)?,
283                database_password: decode("databasePassword", false)?,
284                auth_snapshot: AuthSnapshot {
285                    update_mask: match object.get("authSections") {
286                        Some(serde_json::Value::String(value)) => value.parse()?,
287                        Some(value) => u32::try_from(value.as_u64().ok_or_else(|| {
288                            anyhow::anyhow!("Invalid baseline auth section mask")
289                        })?)?,
290                        None => 0,
291                    },
292                    response: decode("authResponse", false)?,
293                    auth: decode("authSect", false)?,
294                    account,
295                    network: decode("networkSect", false)?,
296                    ax_auth: decode("axAuthSect", false)?,
297                },
298                long_host: object
299                    .get("longHost")
300                    .and_then(|v| v.as_str())
301                    .unwrap_or("long.weixin.qq.com")
302                    .to_string(),
303                short_host: object
304                    .get("shortHost")
305                    .and_then(|v| v.as_str())
306                    .unwrap_or("short.weixin.qq.com")
307                    .to_string(),
308            };
309        state.validate()?;
310        Ok(state)
311    }
312}
313
314#[derive(Clone, Serialize, Deserialize)]
315#[serde(deny_unknown_fields)]
316pub struct LoginAttempt {
317    pub schema: u32,
318    pub device: DeviceProfile,
319    pub computer_name: String,
320    pub qr_png: Vec<u8>,
321    pub width: u32,
322    pub height: u32,
323    pub created_at: u32,
324    pub expires_at: u32,
325    pub identifier: Vec<u8>,
326    pub notify_key: Vec<u8>,
327    pub cookie: Vec<u8>,
328    pub key_version: u16,
329    pub long_host: String,
330    pub short_host: String,
331    pub status: LoginStatus,
332    #[serde(default)]
333    key_info: Vec<u8>,
334    credential3: Option<Vec<u8>>,
335    credential4: Option<Vec<u8>>,
336    #[serde(default)]
337    lan_upload_attempted: bool,
338    // Socket/token ownership never survives serialization or daemon restart.
339    #[serde(skip)]
340    lan_challenge: Option<std::sync::Arc<crate::lan_login::Challenge>>,
341}
342impl fmt::Debug for LoginAttempt {
343    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
344        f.debug_struct("LoginAttempt")
345            .field("status", &self.status)
346            .field("created_at", &self.created_at)
347            .field("expires_at", &self.expires_at)
348            .field("credentials", &"[redacted]")
349            .finish()
350    }
351}
352impl Drop for LoginAttempt {
353    fn drop(&mut self) {
354        self.stop_lan_login();
355        self.identifier.zeroize();
356        self.notify_key.zeroize();
357        self.cookie.zeroize();
358        self.key_info.zeroize();
359        self.credential3.zeroize();
360        self.credential4.zeroize();
361    }
362}
363impl LoginAttempt {
364    pub fn lan_login_state(&self) -> Option<&'static str> {
365        self.lan_challenge
366            .as_ref()
367            .map(|challenge| challenge.status())
368    }
369    fn stop_lan_login(&mut self) {
370        if let Some(challenge) = self.lan_challenge.take() {
371            challenge.stop();
372        }
373    }
374    pub fn qr_svg(&self) -> Result<String> {
375        use base64::Engine;
376        ensure!(
377            !self.qr_png.is_empty(),
378            "This saved-device attempt has no QR"
379        );
380        let encoded = base64::engine::general_purpose::STANDARD.encode(&self.qr_png);
381        Ok(format!("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"530\" height=\"530\" viewBox=\"0 0 265 265\"><rect width=\"265\" height=\"265\" fill=\"white\"/><image x=\"40\" y=\"40\" width=\"185\" height=\"185\" style=\"image-rendering:pixelated\" href=\"data:image/png;base64,{encoded}\"/></svg>"))
382    }
383    fn validate(&self) -> Result<()> {
384        self.validate_at(wire::unix_time()?)
385    }
386    fn locally_expired(&self, now: u32) -> bool {
387        // A scanned QR is an approval challenge. Its image expiry must not end
388        // that challenge; the operation and this hard bound still limit it.
389        let hard_deadline = self.created_at.saturating_add(900);
390        let deadline = if self.qr_png.is_empty()
391            || matches!(self.status, LoginStatus::Scanned | LoginStatus::Approved)
392        {
393            hard_deadline
394        } else {
395            self.expires_at.min(hard_deadline)
396        };
397        now >= deadline
398    }
399    fn validate_at(&self, now: u32) -> Result<()> {
400        ensure!(
401            self.schema == 1 && !self.locally_expired(now),
402            "Login attempt expired"
403        );
404        self.device.validate()?;
405        ensure!(
406            !self.identifier.is_empty()
407                && self.identifier.len() <= 4096
408                && !self.notify_key.is_empty()
409                && self.notify_key.len() <= 1024
410                && self.cookie.len() <= 15
411                && self.key_info.len() <= 1024 * 1024,
412            "Invalid login attempt"
413        );
414        mmtls::validate_host(&self.long_host)?;
415        mmtls::validate_host(&self.short_host)?;
416        ensure!(
417            [10014, 50005].contains(&self.key_version),
418            "Unsupported login key version"
419        );
420        Ok(())
421    }
422    fn manual_auth_request(&self, point: &[u8]) -> Result<Vec<u8>> {
423        wire::manual_auth(
424            &self.device,
425            &self.computer_name,
426            self.credential3
427                .as_deref()
428                .ok_or_else(|| anyhow::anyhow!("Missing approval"))?,
429            self.credential4
430                .as_deref()
431                .ok_or_else(|| anyhow::anyhow!("Missing approval"))?,
432            point,
433            &self.key_info,
434        )
435    }
436}
437#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
438#[serde(rename_all = "snake_case")]
439pub enum LoginStatus {
440    Pending,
441    Scanned,
442    Approved,
443    Expired,
444    Cancelled,
445    Rejected,
446    Completed,
447}
448#[derive(Clone, Debug)]
449pub struct NativeOptions {
450    pub long_host: String,
451    pub short_host: String,
452    pub timeout: Duration,
453    pub receive_budget: usize,
454}
455impl Default for NativeOptions {
456    fn default() -> Self {
457        Self {
458            long_host: "long.weixin.qq.com".to_string(),
459            short_host: "short.weixin.qq.com".to_string(),
460            timeout: Duration::from_secs(30),
461            receive_budget: crate::MAX_MESSAGE_BYTES,
462        }
463    }
464}
465pub struct NativeClient {
466    options: NativeOptions,
467}
468/// Polling an issued login challenge is repeatable. This does not authorize
469/// retrying QR creation, credential exchange, sends or rejected authentication.
470pub fn is_retryable_login_poll_error(error: &anyhow::Error) -> bool {
471    if crate::login_lan_error_reason(error).is_some() {
472        return false;
473    }
474    error.chain().any(|cause| {
475        if let Some(io) = cause.downcast_ref::<std::io::Error>() {
476            return matches!(
477                io.kind(),
478                std::io::ErrorKind::TimedOut
479                    | std::io::ErrorKind::WouldBlock
480                    | std::io::ErrorKind::ConnectionReset
481                    | std::io::ErrorKind::ConnectionAborted
482                    | std::io::ErrorKind::ConnectionRefused
483                    | std::io::ErrorKind::UnexpectedEof
484                    | std::io::ErrorKind::BrokenPipe
485                    | std::io::ErrorKind::NotConnected
486            );
487        }
488        if let Some(http) = cause.downcast_ref::<reqwest::Error>() {
489            return http.is_timeout() || http.is_connect();
490        }
491        let message = cause.to_string();
492        matches!(
493            message.as_str(),
494            "MMTLS deadline exceeded" | "MMTLS connection closed"
495        ) || message
496            .strip_prefix("Native shortlink HTTP status ")
497            .and_then(|s| s.parse::<u16>().ok())
498            .is_some_and(|status| matches!(status, 408 | 429 | 500 | 502 | 503 | 504))
499    })
500}
501impl NativeClient {
502    pub fn new(options: NativeOptions) -> Result<Self> {
503        mmtls::validate_host(&options.long_host)?;
504        mmtls::validate_host(&options.short_host)?;
505        ensure!(
506            !options.timeout.is_zero() && options.timeout <= Duration::from_secs(120),
507            "Invalid native timeout"
508        );
509        ensure!(
510            (1024..=crate::MAX_MESSAGE_BYTES).contains(&options.receive_budget),
511            "Invalid receive budget"
512        );
513        Ok(Self { options })
514    }
515    fn session(&self, host: &str) -> Result<Session> {
516        Session::connect(host, self.options.timeout, self.options.receive_budget)
517    }
518    pub fn push_connection(&self, state: &AccountState) -> Result<crate::push::Connection> {
519        crate::push::Connection::open(state, self.options.timeout, self.options.receive_budget)
520    }
521    #[allow(clippy::too_many_arguments)]
522    fn prelogin_rpc(
523        &self,
524        long_host: &str,
525        short_host: &str,
526        cgi: u16,
527        path: &str,
528        plain: &[u8],
529        cookie: &[u8],
530        auth_key: &[u8],
531        fixed_version: Option<u16>,
532    ) -> Result<(Vec<u8>, Vec<u8>, u16)> {
533        let session = self.session(long_host)?;
534        let profile = if let Some(version) = fixed_version {
535            business::profile_for_region(if version == 50005 { 2 } else { 1 })?
536        } else {
537            business::profile_for_region(session.certificate_region)?
538        };
539        let prepared = Prelogin::prepare(plain, &profile, auth_key)?;
540        let packet = business::prelogin_packet(&prepared, cgi, profile.version, cookie)?;
541        let reply = mmtls::short_exchange(
542            &session.short,
543            short_host,
544            path,
545            &packet,
546            self.options.timeout,
547        )?;
548        let envelope = business::split(&reply)?;
549        ensure!(
550            envelope.return_code == 0,
551            "Native envelope returned {}",
552            envelope.return_code
553        );
554        ensure!(
555            envelope.cgi == cgi as u64 && envelope.encryption == 12,
556            "Unexpected prelogin response policy"
557        );
558        let plain = prepared.decrypt_reply(&envelope.payload)?;
559        Ok((plain, envelope.cookie, profile.version))
560    }
561    pub fn start_qr_login(
562        &self,
563        device: DeviceProfile,
564        computer_name: &str,
565    ) -> Result<LoginAttempt> {
566        self.start_profile_qr_login(device, computer_name, &[])
567    }
568    /// QR approval for an existing installation retains its selected key-info.
569    /// This neither bypasses phone approval nor imports active authorization.
570    pub fn start_profile_qr_login(
571        &self,
572        device: DeviceProfile,
573        computer_name: &str,
574        key_info: &[u8],
575    ) -> Result<LoginAttempt> {
576        device.validate()?;
577        ensure!(key_info.len() <= 1024 * 1024, "Invalid saved key-info");
578        let plain = wire::qr_request(&device, computer_name)?;
579        let (reply, cookie, key_version) = self.prelogin_rpc(
580            &self.options.long_host,
581            &self.options.short_host,
582            502,
583            "/cgi-bin/micromsg-bin/getloginqrcode",
584            &plain,
585            &[],
586            &[],
587            None,
588        )?;
589        let qr = wire::decode_qr(&reply)?;
590        let now = wire::unix_time()?;
591        Ok(LoginAttempt {
592            schema: 1,
593            device,
594            computer_name: computer_name.to_string(),
595            qr_png: qr.image,
596            width: qr.width,
597            height: qr.height,
598            created_at: now,
599            expires_at: now.saturating_add(240),
600            identifier: qr.identifier,
601            notify_key: qr.notify_key,
602            cookie,
603            key_version,
604            long_host: self.options.long_host.clone(),
605            short_host: self.options.short_host.clone(),
606            status: LoginStatus::Pending,
607            key_info: key_info.to_vec(),
608            credential3: None,
609            credential4: None,
610            lan_upload_attempted: false,
611            lan_challenge: None,
612        })
613    }
614    /// Initiates phone approval for an existing remembered device without a QR.
615    /// The caller chooses this fallback explicitly; no automatic auth retries occur.
616    pub fn start_saved_device_login(
617        &self,
618        state: &AccountState,
619        computer_name: &str,
620    ) -> Result<LoginAttempt> {
621        state.validate()?;
622        self.start_saved_login(
623            &state.device,
624            &state.own_username,
625            &state.auto_auth_key,
626            &state.cookie,
627            &state.key_info,
628            &state.long_host,
629            &state.short_host,
630            computer_name,
631        )
632    }
633    /// Use remembered official-profile trust without inventing active session keys.
634    pub fn start_profile_saved_device_login(
635        &self,
636        device: &DeviceProfile,
637        username: &str,
638        auto_auth_key: &[u8],
639        cookie: &[u8],
640        key_info: &[u8],
641        computer_name: &str,
642    ) -> Result<LoginAttempt> {
643        self.start_saved_login(
644            device,
645            username,
646            auto_auth_key,
647            cookie,
648            key_info,
649            &self.options.long_host,
650            &self.options.short_host,
651            computer_name,
652        )
653    }
654    #[allow(clippy::too_many_arguments)]
655    fn start_saved_login(
656        &self,
657        device: &DeviceProfile,
658        username: &str,
659        auto_auth_key: &[u8],
660        cookie: &[u8],
661        key_info: &[u8],
662        long_host: &str,
663        short_host: &str,
664        computer_name: &str,
665    ) -> Result<LoginAttempt> {
666        device.validate()?;
667        ensure!(
668            !username.is_empty()
669                && username.len() <= 256
670                && !username.contains('\0')
671                && !auto_auth_key.is_empty()
672                && auto_auth_key.len() <= 1024 * 1024
673                && cookie.len() <= 15
674                && key_info.len() <= 1024 * 1024,
675            "Invalid saved-device credentials"
676        );
677        let rsa = Rsa::generate(1024)?;
678        let public = PKey::from_rsa(rsa)?.public_key_to_pem()?;
679        let request = wire::push_login(device, username, auto_auth_key, &public)?;
680        let (reply, cookie, key_version) = self.prelogin_rpc(
681            long_host,
682            short_host,
683            654,
684            "/cgi-bin/micromsg-bin/pushloginurl",
685            &request,
686            cookie,
687            &[],
688            None,
689        )?;
690        let (identifier, notify_key) = wire::decode_push(&reply)?;
691        let now = wire::unix_time()?;
692        Ok(LoginAttempt {
693            schema: 1,
694            device: device.clone(),
695            computer_name: computer_name.to_string(),
696            qr_png: vec![],
697            width: 0,
698            height: 0,
699            created_at: now,
700            expires_at: now.saturating_add(240),
701            identifier,
702            notify_key,
703            cookie,
704            key_version,
705            long_host: long_host.into(),
706            short_host: short_host.into(),
707            status: LoginStatus::Pending,
708            key_info: key_info.to_vec(),
709            credential3: None,
710            credential4: None,
711            lan_upload_attempted: false,
712            lan_challenge: None,
713        })
714    }
715    pub fn poll_login(&self, attempt: &mut LoginAttempt) -> Result<LoginStatus> {
716        self.poll_login_guarded(
717            attempt,
718            std::time::Instant::now() + Duration::from_secs(900),
719            || false,
720        )
721    }
722    /// The guard bounds the temporary LAN listener even while a blocking poll
723    /// or upload is in flight. It must not perform blocking work itself.
724    pub fn poll_login_guarded(
725        &self,
726        attempt: &mut LoginAttempt,
727        deadline: std::time::Instant,
728        cancelled: impl Fn() -> bool + Send + Sync + 'static,
729    ) -> Result<LoginStatus> {
730        let now = wire::unix_time()?;
731        if attempt.locally_expired(now) {
732            attempt.stop_lan_login();
733            attempt.status = LoginStatus::Expired;
734            return Ok(attempt.status);
735        }
736        attempt.validate_at(now)?;
737        ensure!(
738            !matches!(
739                attempt.status,
740                LoginStatus::Completed | LoginStatus::Cancelled | LoginStatus::Rejected
741            ),
742            "Login attempt is terminal"
743        );
744        let request = wire::poll_request(&attempt.device, &attempt.identifier)?;
745        let (reply, cookie, _) = self.prelogin_rpc(
746            &attempt.long_host,
747            &attempt.short_host,
748            503,
749            "/cgi-bin/micromsg-bin/checkloginqrcode",
750            &request,
751            &attempt.cookie,
752            &[],
753            Some(attempt.key_version),
754        )?;
755        let response = wire::decode_poll(&reply, &attempt.notify_key)?;
756        if !cookie.is_empty() {
757            attempt.cookie = cookie;
758        }
759        let remaining = attempt
760            .created_at
761            .saturating_add(900)
762            .saturating_sub(wire::unix_time()?);
763        let deadline =
764            deadline.min(std::time::Instant::now() + Duration::from_secs(u64::from(remaining)));
765        let scope = crate::lan_login::Scope::new(deadline, cancelled);
766        apply_login_notification(attempt, response, |attempt| {
767            self.start_lan_login(attempt, scope)
768        })
769    }
770    fn start_lan_login(
771        &self,
772        attempt: &mut LoginAttempt,
773        scope: crate::lan_login::Scope,
774    ) -> Result<std::sync::Arc<crate::lan_login::Challenge>> {
775        use crate::lan_login as lan;
776        let challenge = std::sync::Arc::new(lan::Challenge::bind(scope)?);
777        let random = zeroize::Zeroizing::new(crate::crypto::random::<16>()?);
778        let request = zeroize::Zeroizing::new(challenge.request(
779            &attempt.device,
780            &attempt.identifier,
781            &random,
782        )?);
783        let (reply, cookie, _) = self
784            .prelogin_rpc(
785                &attempt.long_host,
786                &attempt.short_host,
787                lan::CGI,
788                lan::PATH,
789                &request,
790                &attempt.cookie,
791                &[],
792                Some(attempt.key_version),
793            )
794            .map_err(|_| lan::failure("upload_failed"))?;
795        let reply = zeroize::Zeroizing::new(reply);
796        let token =
797            lan::response(&reply).map_err(|_| lan::failure("upload_rejected_or_invalid"))?;
798        challenge.arm(&token)?;
799        if !cookie.is_empty() {
800            attempt.cookie = cookie;
801        }
802        Ok(challenge)
803    }
804    pub fn complete_login(&self, attempt: &mut LoginAttempt) -> Result<AccountState> {
805        attempt.stop_lan_login();
806        attempt.validate()?;
807        ensure!(
808            attempt.status == LoginStatus::Approved,
809            "Login has not been approved"
810        );
811        let inner = Ecdh::generate(Nid::SECP224R1)?;
812        let request = attempt.manual_auth_request(&inner.public()?)?;
813        let mut long_host = attempt.long_host.clone();
814        let mut short_host = attempt.short_host.clone();
815        let mut cookie = attempt.cookie.clone();
816        for redirected in 0..=1 {
817            let (reply, next_cookie, _) = self.prelogin_rpc(
818                &long_host,
819                &short_host,
820                252,
821                "/cgi-bin/micromsg-bin/secmanualauth",
822                &request,
823                &cookie,
824                &[],
825                None,
826            )?;
827            let fields = p::decode(&reply)?;
828            let code = p::base_code(&fields)?;
829            if code == -301 && redirected == 0 {
830                (long_host, short_host) = redirect(&fields)?;
831                cookie = next_cookie;
832                continue;
833            }
834            ensure!(code == 0, "Account authentication returned {code}");
835            let mut state = reduce_auth(&reply, None, &inner, &attempt.device, &next_cookie)?;
836            state.long_host = long_host;
837            state.short_host = short_host;
838            state.validate()?;
839            attempt.status = LoginStatus::Completed;
840            attempt.credential3.zeroize();
841            attempt.credential4.zeroize();
842            return Ok(state);
843        }
844        bail!("Native authentication redirect limit reached")
845    }
846    /// Replace an expired, unscanned QR while preserving installation key-info.
847    pub fn refresh_qr_login(&self, attempt: &LoginAttempt) -> Result<LoginAttempt> {
848        ensure!(
849            attempt.status == LoginStatus::Expired && !attempt.qr_png.is_empty(),
850            "Only an expired QR may be refreshed"
851        );
852        let options = NativeOptions {
853            long_host: attempt.long_host.clone(),
854            short_host: attempt.short_host.clone(),
855            ..self.options.clone()
856        };
857        Self::new(options)?.start_profile_qr_login(
858            attempt.device.clone(),
859            &attempt.computer_name,
860            &attempt.key_info,
861        )
862    }
863    /// One trusted AutoAuth exchange, retaining identity and replacing selected key
864    /// sections atomically in the returned value. Does not silently fall back to QR.
865    pub fn renew_session(
866        &self,
867        previous: &AccountState,
868        computer_name: &str,
869    ) -> Result<AccountState> {
870        previous.validate()?;
871        let inner = Ecdh::generate(Nid::SECP224R1)?;
872        let request = wire::auto_auth(previous, &inner.public()?, computer_name)?;
873        let mut long = previous.long_host.clone();
874        let mut short = previous.short_host.clone();
875        let mut cookie = previous.cookie.clone();
876        for redirected in 0..=1 {
877            let (reply, next_cookie, _) = self.prelogin_rpc(
878                &long,
879                &short,
880                763,
881                "/cgi-bin/micromsg-bin/secautoauth",
882                &request,
883                &cookie,
884                &previous.auto_auth_key,
885                None,
886            )?;
887            let fields = p::decode(&reply)?;
888            let code = p::base_code(&fields)?;
889            if code == -301 && redirected == 0 {
890                (long, short) = redirect(&fields)?;
891                cookie = next_cookie;
892                continue;
893            }
894            ensure!(code == 0, "Automatic authentication returned {code}");
895            let mut next = reduce_auth(
896                &reply,
897                Some(previous),
898                &inner,
899                &previous.device,
900                &next_cookie,
901            )?;
902            next.long_host = long;
903            next.short_host = short;
904            next.validate()?;
905            return Ok(next);
906        }
907        bail!("AutoAuth redirect limit reached")
908    }
909    pub(crate) fn authenticated_rpc(
910        &self,
911        state: &mut AccountState,
912        cgi: u16,
913        path: &str,
914        request: &[u8],
915    ) -> Result<Vec<u8>> {
916        let session = self.session(&state.long_host)?;
917        let packet = AuthenticatedPacket::prepare(state, cgi, request)?;
918        let reply = mmtls::short_exchange(
919            &session.short,
920            &state.short_host,
921            path,
922            &packet.packet,
923            self.options.timeout,
924        )?;
925        let (plain, cookie) = packet.decode_verified_transport_reply(&reply)?;
926        if !cookie.is_empty() {
927            state.cookie = cookie;
928        }
929        Ok(plain)
930    }
931    pub fn sync(&self, state: &mut AccountState, cursor: &[u8]) -> Result<SyncBatch> {
932        self.sync_scene(state, cursor, 6)
933    }
934    pub fn sync_scene(
935        &self,
936        state: &mut AccountState,
937        cursor: &[u8],
938        scene: u32,
939    ) -> Result<SyncBatch> {
940        ensure!([6, 7].contains(&scene), "Unsupported sync scene");
941        let wire_cursor = if cursor.is_empty() { &[0][..] } else { cursor };
942        let request = wire::sync_request(wire_cursor, &state.device.device_type, scene)?;
943        wire::decode_sync(
944            &self.authenticated_rpc(state, 138, "/cgi-bin/micromsg-bin/newsync", &request)?,
945            cursor,
946        )
947    }
948    pub fn heartbeat(&self, state: &mut AccountState, cursor: &[u8]) -> Result<Heartbeat> {
949        let request = wire::heartbeat_request(state, cursor)?;
950        wire::decode_heartbeat(&self.authenticated_rpc(
951            state,
952            518,
953            "/cgi-bin/micromsg-bin/heartbeat",
954            &request,
955        )?)
956    }
957    /// Submit exactly once. An I/O error after submission is an uncertain outcome;
958    /// services must persist the intent and must not automatically retry this call.
959    pub fn send_text(
960        &self,
961        state: &mut AccountState,
962        destination: &str,
963        content: &str,
964        created_at: u32,
965        client_message_id: u32,
966    ) -> Result<TextSendResult> {
967        self.send_text_with_source(
968            state,
969            destination,
970            content,
971            created_at,
972            client_message_id,
973            None,
974        )
975    }
976    pub fn send_text_with_source(
977        &self,
978        state: &mut AccountState,
979        destination: &str,
980        content: &str,
981        created_at: u32,
982        client_message_id: u32,
983        source: Option<&str>,
984    ) -> Result<TextSendResult> {
985        let request = wire::text_send_request_with_source(
986            destination,
987            content,
988            created_at,
989            client_message_id,
990            source,
991        )?;
992        wire::decode_text_send(
993            &self.authenticated_rpc(state, 522, "/cgi-bin/micromsg-bin/newsendmsg", &request)?,
994            destination,
995            client_message_id,
996        )
997    }
998    pub fn lookup_contacts(
999        &self,
1000        state: &mut AccountState,
1001        usernames: &[String],
1002    ) -> Result<ContactLookup> {
1003        let request = wire::contact_request(state, usernames)?;
1004        wire::decode_contacts(
1005            &self.authenticated_rpc(state, 182, "/cgi-bin/micromsg-bin/getcontact", &request)?,
1006            usernames,
1007        )
1008    }
1009    pub fn lookup_room(
1010        &self,
1011        state: &mut AccountState,
1012        room_id: &str,
1013        version: u32,
1014    ) -> Result<RoomRoster> {
1015        let request = wire::room_request(state, room_id, version)?;
1016        wire::decode_room(
1017            &self.authenticated_rpc(
1018                state,
1019                551,
1020                "/cgi-bin/micromsg-bin/getchatroommemberdetail",
1021                &request,
1022            )?,
1023            room_id,
1024        )
1025    }
1026    pub fn init_contacts(
1027        &self,
1028        state: &mut AccountState,
1029        contact_sequence: u32,
1030        room_sequence: u32,
1031    ) -> Result<InitContactPage> {
1032        let request =
1033            wire::init_contacts_request(&state.own_username, contact_sequence, room_sequence)?;
1034        let page = wire::decode_init_contacts(&self.authenticated_rpc(
1035            state,
1036            851,
1037            "/cgi-bin/micromsg-bin/initcontact",
1038            &request,
1039        )?)?;
1040        ensure!(
1041            !page.more
1042                || page.contact_sequence != contact_sequence
1043                || page.room_sequence != room_sequence,
1044            "Contact bootstrap made no cursor progress"
1045        );
1046        Ok(page)
1047    }
1048    pub fn download_avatars(
1049        &self,
1050        state: &mut AccountState,
1051        usernames: &[String],
1052    ) -> Result<Vec<AvatarBytes>> {
1053        let request = wire::avatar_request(state, usernames)?;
1054        wire::decode_avatars(
1055            &self.authenticated_rpc(
1056                state,
1057                123,
1058                "/cgi-bin/micromsg-bin/batchgetheadimg",
1059                &request,
1060            )?,
1061            usernames,
1062        )
1063    }
1064    pub fn download_voice_chunk(
1065        &self,
1066        state: &mut AccountState,
1067        server_message_id: u64,
1068        total_length: u32,
1069        offset: u32,
1070    ) -> Result<VoiceChunk> {
1071        let request = wire::voice_request(state, server_message_id, total_length, offset)?;
1072        wire::decode_voice(
1073            &self.authenticated_rpc(state, 128, "/cgi-bin/micromsg-bin/downloadvoice", &request)?,
1074            server_message_id,
1075            total_length,
1076            offset,
1077        )
1078    }
1079    pub fn cdn_config(&self, state: &mut AccountState) -> Result<crate::media::CdnConfig> {
1080        let request = p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?);
1081        let reply =
1082            self.authenticated_rpc(state, 379, "/cgi-bin/micromsg-bin/getcdndns", &request)?;
1083        crate::media::decode_cdn_config(&reply, state)
1084    }
1085    pub fn large_cdn_config(
1086        &self,
1087        state: &mut AccountState,
1088        route: crate::media::LargeCdnRoute,
1089    ) -> Result<crate::media::CdnConfig> {
1090        let request = p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?);
1091        let reply =
1092            self.authenticated_rpc(state, 379, "/cgi-bin/micromsg-bin/getcdndns", &request)?;
1093        crate::media::decode_large_cdn_config(&reply, state, route)
1094    }
1095    /// Submit a URL card once through the app-message carrier. The caller owns
1096    /// the durable intent and must inspect an uncertain submission outcome.
1097    pub fn send_share(
1098        &self,
1099        state: &mut AccountState,
1100        destination: &str,
1101        client_id: &str,
1102        created_at: u32,
1103        content: &str,
1104    ) -> Result<crate::media_send::MediaSendResult> {
1105        let request = crate::media_send::share_send_request(
1106            state,
1107            destination,
1108            client_id,
1109            created_at,
1110            content,
1111        )?;
1112        crate::media_send::decode_file_send(
1113            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1114            client_id,
1115        )
1116    }
1117    /// Submit a quoted reply once. The caller owns the persisted XML, intent and
1118    /// uncertain-submission handling, as for other app-message sends.
1119    pub fn send_quote(
1120        &self,
1121        state: &mut AccountState,
1122        destination: &str,
1123        client_id: &str,
1124        created_at: u32,
1125        content: &str,
1126    ) -> Result<crate::media_send::MediaSendResult> {
1127        let request = crate::quote_send::quote_send_request(
1128            state,
1129            destination,
1130            client_id,
1131            created_at,
1132            content,
1133        )?;
1134        crate::media_send::decode_file_send(
1135            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1136            client_id,
1137        )
1138    }
1139    /// Reserve one file-upload message. An uncertain response must not be
1140    /// retried automatically; retain the returned receipt with the send intent.
1141    pub fn reserve_file_upload(
1142        &self,
1143        state: &mut AccountState,
1144        destination: &str,
1145        progress_client_id: &str,
1146        file_name: &str,
1147        plaintext_bytes: usize,
1148        md5: &str,
1149    ) -> Result<crate::file_upload::FileUploadReservation> {
1150        let request = crate::file_upload::file_upload_request(
1151            state,
1152            destination,
1153            progress_client_id,
1154            file_name,
1155            plaintext_bytes,
1156            md5,
1157        )?;
1158        crate::file_upload::decode_file_upload_reservation(&self.authenticated_rpc(
1159            state,
1160            6691,
1161            "/cgi-bin/micromsg-bin/sendfileuploadmsg",
1162            &request,
1163        )?)
1164    }
1165    pub fn send_file(
1166        &self,
1167        state: &mut AccountState,
1168        destination: &str,
1169        client_id: &str,
1170        created_at: u32,
1171        upload: &crate::media::UploadedMedia,
1172    ) -> Result<crate::media_send::MediaSendResult> {
1173        let request = crate::media_send::file_send_request(
1174            state,
1175            destination,
1176            client_id,
1177            created_at,
1178            upload,
1179        )?;
1180        crate::media_send::decode_file_send(
1181            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1182            client_id,
1183        )
1184    }
1185    #[allow(clippy::too_many_arguments)]
1186    pub fn send_file_with_reservation(
1187        &self,
1188        state: &mut AccountState,
1189        destination: &str,
1190        client_id: &str,
1191        created_at: u32,
1192        upload: &crate::media::UploadedMedia,
1193        reservation: Option<&crate::file_upload::FileUploadReservation>,
1194    ) -> Result<crate::media_send::MediaSendResult> {
1195        let request = crate::media_send::file_send_request_with_reservation(
1196            state,
1197            destination,
1198            client_id,
1199            created_at,
1200            upload,
1201            reservation,
1202        )?;
1203        let reply =
1204            self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?;
1205        if reservation.is_some() {
1206            crate::media_send::decode_file_send_correlated(
1207                &reply,
1208                client_id,
1209                &state.own_username,
1210                destination,
1211                &upload.descriptor.aes_key,
1212            )
1213        } else {
1214            crate::media_send::decode_file_send(&reply, client_id)
1215        }
1216    }
1217    pub fn send_image(
1218        &self,
1219        state: &mut AccountState,
1220        destination: &str,
1221        client_id: &str,
1222        upload: &crate::media::UploadedImage,
1223    ) -> Result<crate::media_send::MediaSendResult> {
1224        let request = crate::media_send::image_send_request(state, destination, client_id, upload)?;
1225        crate::media_send::decode_image_send(
1226            &self.authenticated_rpc(state, 110, "/cgi-bin/micromsg-bin/uploadmsgimg", &request)?,
1227            client_id,
1228            &state.own_username,
1229            destination,
1230        )
1231    }
1232    pub fn send_video(
1233        &self,
1234        state: &mut AccountState,
1235        destination: &str,
1236        client_id: &str,
1237        upload: &crate::media::UploadedVideo,
1238        play_seconds: u32,
1239    ) -> Result<crate::media_send::MediaSendResult> {
1240        let request = crate::media_send::video_send_request(
1241            state,
1242            destination,
1243            client_id,
1244            upload,
1245            play_seconds,
1246        )?;
1247        crate::media_send::decode_video_send(
1248            &self.authenticated_rpc(state, 149, "/cgi-bin/micromsg-bin/uploadvideo", &request)?,
1249            client_id,
1250        )
1251    }
1252    /// Submit one voice note, as consecutive byte ranges when it exceeds one
1253    /// request. Each range is sent once; an error after the first submission is
1254    /// an uncertain outcome that the caller must not retry automatically.
1255    pub fn send_voice(
1256        &self,
1257        state: &mut AccountState,
1258        destination: &str,
1259        client_id: &str,
1260        created_at: u32,
1261        duration_ms: u32,
1262        encoded: &[u8],
1263    ) -> Result<crate::media_send::MediaSendResult> {
1264        let mut upload = crate::media_send::VoiceUpload::new(
1265            destination,
1266            client_id,
1267            created_at,
1268            duration_ms,
1269            encoded,
1270        )?;
1271        loop {
1272            let request = upload.request(state)?;
1273            let reply =
1274                self.authenticated_rpc(state, 127, "/cgi-bin/micromsg-bin/uploadvoice", &request)?;
1275            if let Some(result) = upload.accept_reply(&reply)? {
1276                return Ok(result);
1277            }
1278        }
1279    }
1280}
1281
1282fn redirect(fields: &[p::Field]) -> Result<(String, String)> {
1283    let network = p::decode(p::data(fields, 5)?)?;
1284    let hosts = p::decode(p::data(&network, 1)?)?;
1285    let target = |source: &[u8]| -> Result<String> {
1286        let mut out = None;
1287        for f in hosts.iter().filter(|f| f.number == 2) {
1288            let p::Value::Bytes(raw) = &f.value else {
1289                bail!("Invalid redirect map");
1290            };
1291            let item = p::decode(raw)?;
1292            if p::data(&item, 1)? == source {
1293                ensure!(out.is_none(), "Ambiguous redirect");
1294                let name = std::str::from_utf8(p::data(&item, 2)?)?.to_string();
1295                mmtls::validate_host(&name)?;
1296                out = Some(name);
1297            }
1298        }
1299        out.ok_or_else(|| anyhow::anyhow!("Missing redirect mapping"))
1300    };
1301    Ok((
1302        target(b"long.weixin.qq.com")?,
1303        target(b"short.weixin.qq.com")?,
1304    ))
1305}
1306fn reduce_auth(
1307    plain: &[u8],
1308    previous: Option<&AccountState>,
1309    inner: &Ecdh,
1310    device: &DeviceProfile,
1311    cookie: &[u8],
1312) -> Result<AccountState> {
1313    let f = p::decode(plain)?;
1314    ensure!(p::base_code(&f)? == 0, "Rejected auth response");
1315    let mask = u32::try_from(p::scalar(&f, 2)?)?;
1316    for (bit, n) in [(1, 3), (2, 4), (4, 5)] {
1317        if mask & bit != 0 {
1318            p::data(&f, n)?;
1319        }
1320    }
1321    let mut state = previous.cloned().unwrap_or_else(|| AccountState {
1322        schema: 1,
1323        device: device.clone(),
1324        uin: 0,
1325        own_username: String::new(),
1326        cookie: vec![],
1327        session_key: vec![],
1328        request_key: vec![],
1329        response_key: vec![],
1330        auto_auth_key: vec![],
1331        key_info: vec![],
1332        database_password: vec![],
1333        auth_snapshot: AuthSnapshot::default(),
1334        long_host: "long.weixin.qq.com".into(),
1335        short_host: "short.weixin.qq.com".into(),
1336    });
1337    ensure!(
1338        plain.len() <= 1024 * 1024,
1339        "Auth response snapshot too large"
1340    );
1341    state.auth_snapshot.update_mask = mask;
1342    state.auth_snapshot.response = plain.to_vec();
1343    for (number, target) in [
1344        (3, &mut state.auth_snapshot.auth),
1345        (4, &mut state.auth_snapshot.account),
1346        (5, &mut state.auth_snapshot.network),
1347        (6, &mut state.auth_snapshot.ax_auth),
1348    ] {
1349        if let Some(section) = p::optional_data(&f, number)? {
1350            *target = section.to_vec();
1351        }
1352    }
1353    if let Some(account) = p::optional_data(&f, 4)? {
1354        let a = p::decode(account)?;
1355        if let Some(name) = p::optional_data(&a, 1)? {
1356            let name = std::str::from_utf8(name)?;
1357            ensure!(
1358                previous.is_none_or(|s| s.own_username == name),
1359                "Auth account identity changed"
1360            );
1361            state.own_username = name.to_string();
1362        }
1363    }
1364    if mask & 1 != 0 {
1365        let auth = p::data(&f, 3)?;
1366        let a = p::decode(auth)?;
1367        let uin = u32::try_from(p::scalar(&a, 1)?)?;
1368        ensure!(
1369            uin != 0 && previous.is_none_or(|s| s.uin == uin),
1370            "Auth UIN changed"
1371        );
1372        let (session, request, response) = business::account_keys(inner, auth)?;
1373        state.uin = uin;
1374        state.session_key = session;
1375        state.request_key = request;
1376        state.response_key = response;
1377        state.auto_auth_key = p::optional_data(&a, 4)?
1378            .map(p::unbuffer)
1379            .transpose()?
1380            .unwrap_or_default();
1381        if let Some(password) = p::optional_data(&a, 9)? {
1382            let password = p::unbuffer(password)?;
1383            ensure!(
1384                password.is_empty() || password.len() == 32,
1385                "Invalid native database password"
1386            );
1387            state.database_password.zeroize();
1388            state.database_password = password;
1389        }
1390        state.key_info = p::optional_data(&a, 10)?
1391            .map(p::unbuffer)
1392            .transpose()?
1393            .unwrap_or_default();
1394    } else {
1395        ensure!(previous.is_some(), "Initial auth has no key section");
1396    }
1397    ensure!(cookie.len() <= 15, "Invalid auth cookie");
1398    if !cookie.is_empty() {
1399        state.cookie = cookie.to_vec();
1400    }
1401    state.validate()?;
1402    Ok(state)
1403}
1404
1405fn apply_login_notification(
1406    attempt: &mut LoginAttempt,
1407    response: wire::PollResponse,
1408    start_lan: impl FnOnce(&mut LoginAttempt) -> Result<std::sync::Arc<crate::lan_login::Challenge>>,
1409) -> Result<LoginStatus> {
1410    if response.lan_challenge && matches!(response.status, Some(0..=2)) {
1411        // A risk challenge is not approval, including a notification carrying
1412        // the ordinary approval status without the final credentials yet.
1413        attempt.credential3.zeroize();
1414        attempt.credential4.zeroize();
1415        attempt.credential3 = None;
1416        attempt.credential4 = None;
1417        attempt.status = LoginStatus::Scanned;
1418        if let Some(challenge) = &attempt.lan_challenge {
1419            if challenge.active() {
1420                return Ok(attempt.status);
1421            }
1422            attempt.stop_lan_login();
1423            return Err(crate::lan_login::failure("listener_stopped"));
1424        }
1425        if attempt.lan_upload_attempted {
1426            return Err(crate::lan_login::failure("upload_already_attempted"));
1427        }
1428        // Record before submission. A lost upload response must not produce an
1429        // automatic new nonce/token exchange on the next repeatable QR poll.
1430        attempt.lan_upload_attempted = true;
1431        attempt.lan_challenge = Some(start_lan(attempt)?);
1432        return Ok(attempt.status);
1433    }
1434    if let Some(status) = response.status {
1435        attempt.status = match status {
1436            0 if attempt.status == LoginStatus::Scanned => LoginStatus::Scanned,
1437            0 => LoginStatus::Pending,
1438            1 => LoginStatus::Scanned,
1439            2 => LoginStatus::Approved,
1440            // Unknown terminal states never count as approval or local expiry.
1441            _ => LoginStatus::Rejected,
1442        };
1443        if attempt.status == LoginStatus::Approved {
1444            attempt.credential3 = response.credential3;
1445            attempt.credential4 = response.credential4;
1446        }
1447        if matches!(
1448            attempt.status,
1449            LoginStatus::Approved | LoginStatus::Rejected
1450        ) {
1451            attempt.stop_lan_login();
1452        }
1453    }
1454    if attempt
1455        .lan_challenge
1456        .as_ref()
1457        .is_some_and(|challenge| !challenge.active())
1458    {
1459        attempt.stop_lan_login();
1460        return Err(crate::lan_login::failure("listener_stopped"));
1461    }
1462    Ok(attempt.status)
1463}
1464
1465#[cfg(test)]
1466mod tests {
1467    use super::*;
1468    fn attempt(status: LoginStatus, qr: bool) -> LoginAttempt {
1469        LoginAttempt {
1470            schema: 1,
1471            device: state().device.clone(),
1472            computer_name: "synthetic desktop".into(),
1473            qr_png: if qr { vec![1] } else { Vec::new() },
1474            width: 0,
1475            height: 0,
1476            created_at: 1000,
1477            expires_at: 1240,
1478            identifier: vec![1; 16],
1479            notify_key: vec![2; 16],
1480            cookie: vec![3; 15],
1481            key_version: 10014,
1482            long_host: "long.weixin.qq.com".into(),
1483            short_host: "short.weixin.qq.com".into(),
1484            status,
1485            key_info: b"persisted installation key-info".to_vec(),
1486            credential3: Some(vec![4; 16]),
1487            credential4: Some(vec![5; 16]),
1488            lan_upload_attempted: false,
1489            lan_challenge: None,
1490        }
1491    }
1492    #[test]
1493    fn final_approval_request_restores_key_info_for_both_saved_device_and_qr() {
1494        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1495        for qr in [false, true] {
1496            let attempt = attempt(LoginStatus::Approved, qr);
1497            let restored: LoginAttempt =
1498                serde_json::from_slice(&serde_json::to_vec(&attempt).unwrap()).unwrap();
1499            assert_eq!(restored.key_info, attempt.key_info);
1500            let request = p::decode(
1501                &restored
1502                    .manual_auth_request(&inner.public().unwrap())
1503                    .unwrap(),
1504            )
1505            .unwrap();
1506            let aes = p::decode(p::data(&request, 2).unwrap()).unwrap();
1507            let base = p::decode(p::data(&aes, 1).unwrap()).unwrap();
1508            // Retained official requests use1 for both approval paths.
1509            assert_eq!(p::scalar(&base, 6).unwrap(), 1);
1510            let auth = p::decode(p::data(&aes, 2).unwrap()).unwrap();
1511            assert_eq!(
1512                p::unbuffer(p::data(&auth, 5).unwrap()).unwrap(),
1513                attempt.key_info
1514            );
1515            assert!(!format!("{restored:?}").contains("persisted installation"));
1516        }
1517    }
1518    #[test]
1519    fn scanned_and_saved_device_approval_outlive_image_expiry_but_remain_bounded() {
1520        let qr = attempt(LoginStatus::Pending, true);
1521        assert!(qr.validate_at(1239).is_ok());
1522        assert!(qr.validate_at(1240).is_err());
1523        for (status, qr) in [
1524            (LoginStatus::Scanned, true),
1525            (LoginStatus::Approved, true),
1526            (LoginStatus::Pending, false),
1527        ] {
1528            let attempt = attempt(status, qr);
1529            assert!(attempt.validate_at(1240).is_ok());
1530            assert!(attempt.validate_at(1899).is_ok());
1531            assert!(attempt.validate_at(1900).is_err());
1532        }
1533    }
1534    fn notification(status: u32, lan_challenge: bool) -> wire::PollResponse {
1535        wire::PollResponse {
1536            status: Some(status),
1537            lan_challenge,
1538            credential3: (status == 2).then(|| vec![4; 16]),
1539            credential4: (status == 2).then(|| vec![5; 16]),
1540        }
1541    }
1542    fn synthetic_lan() -> std::sync::Arc<crate::lan_login::Challenge> {
1543        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1544        let challenge = crate::lan_login::Challenge::from_listener(
1545            listener,
1546            vec![std::net::Ipv4Addr::LOCALHOST],
1547            crate::lan_login::Scope::new(
1548                std::time::Instant::now() + Duration::from_secs(5),
1549                || false,
1550            ),
1551        )
1552        .unwrap();
1553        challenge.arm(b"synthetic temporary token").unwrap();
1554        std::sync::Arc::new(challenge)
1555    }
1556    #[test]
1557    fn repeated_risk_notifications_upload_once_and_never_install_approval() {
1558        let mut attempt = attempt(LoginStatus::Pending, true);
1559        let challenge = synthetic_lan();
1560        assert_eq!(
1561            apply_login_notification(&mut attempt, notification(2, true), |_| Ok(
1562                challenge.clone()
1563            ))
1564            .unwrap(),
1565            LoginStatus::Scanned
1566        );
1567        assert!(attempt.credential3.is_none() && attempt.credential4.is_none());
1568        assert_eq!(attempt.lan_login_state(), Some("waiting_for_phone"));
1569        for _ in 0..3 {
1570            assert_eq!(
1571                apply_login_notification(&mut attempt, notification(1, true), |_| panic!(
1572                    "duplicate upload"
1573                ))
1574                .unwrap(),
1575                LoginStatus::Scanned
1576            );
1577        }
1578        // A normal pending poll while the phone works retains the same listener.
1579        apply_login_notification(&mut attempt, notification(0, false), |_| {
1580            panic!("ordinary poll started LAN")
1581        })
1582        .unwrap();
1583        assert_eq!(attempt.status, LoginStatus::Scanned);
1584        assert!(challenge.active());
1585        let serialized = serde_json::to_string(&attempt).unwrap();
1586        assert!(!serialized.contains("synthetic temporary token"));
1587        assert!(!format!("{attempt:?}").contains("synthetic temporary token"));
1588        let restored: LoginAttempt = serde_json::from_str(&serialized).unwrap();
1589        assert!(restored.lan_login_state().is_none());
1590        assert!(restored.lan_upload_attempted); // No restart replay of an uncertain upload.
1591        assert_eq!(
1592            apply_login_notification(&mut attempt, notification(2, false), |_| panic!(
1593                "approval started LAN"
1594            ))
1595            .unwrap(),
1596            LoginStatus::Approved
1597        );
1598        assert!(attempt.lan_login_state().is_none());
1599        assert!(!challenge.active());
1600        assert!(attempt.credential3.is_some() && attempt.credential4.is_some());
1601    }
1602    #[test]
1603    fn failed_upload_is_not_retried_by_the_repeatable_poll_path() {
1604        let mut attempt = attempt(LoginStatus::Pending, true);
1605        let error = apply_login_notification(&mut attempt, notification(1, true), |_| {
1606            Err(crate::lan_login::failure("upload_failed"))
1607        })
1608        .unwrap_err();
1609        assert!(!is_retryable_login_poll_error(&error));
1610        assert_eq!(crate::login_lan_error_reason(&error), Some("upload_failed"));
1611        assert!(attempt.lan_login_state().is_none());
1612        let error = apply_login_notification(&mut attempt, notification(1, true), |_| {
1613            panic!("resubmitted upload")
1614        })
1615        .unwrap_err();
1616        assert_eq!(
1617            crate::login_lan_error_reason(&error),
1618            Some("upload_already_attempted")
1619        );
1620        assert!(attempt.credential3.is_none() && attempt.credential4.is_none());
1621    }
1622    #[test]
1623    fn rejection_or_attempt_drop_closes_the_shared_listener() {
1624        for reject in [false, true] {
1625            let mut attempt = attempt(LoginStatus::Pending, false);
1626            let challenge = synthetic_lan();
1627            apply_login_notification(&mut attempt, notification(1, true), |_| {
1628                Ok(challenge.clone())
1629            })
1630            .unwrap();
1631            if reject {
1632                assert_eq!(
1633                    apply_login_notification(&mut attempt, notification(3, true), |_| panic!(
1634                        "rejection started LAN"
1635                    ))
1636                    .unwrap(),
1637                    LoginStatus::Rejected
1638                );
1639            }
1640            drop(attempt);
1641            assert!(!challenge.active());
1642        }
1643    }
1644    #[test]
1645    fn ordinary_login_does_not_open_a_lan_listener() {
1646        let mut attempt = attempt(LoginStatus::Pending, true);
1647        for status in [0, 1, 2] {
1648            apply_login_notification(&mut attempt, notification(status, false), |_| {
1649                panic!("ordinary login opened a socket")
1650            })
1651            .unwrap();
1652            assert!(attempt.lan_login_state().is_none());
1653            assert!(!attempt.lan_upload_attempted);
1654        }
1655    }
1656    fn state() -> AccountState {
1657        AccountState {
1658            schema: 1,
1659            device: DeviceProfile {
1660                device_id: vec![1; 16],
1661                device_type: "UnifiedPCLinux synthetic x86_64".into(),
1662                client_version: -227271398,
1663            },
1664            uin: 123,
1665            own_username: "synthetic-user".into(),
1666            cookie: vec![5],
1667            session_key: vec![6; 16],
1668            request_key: vec![7; 24],
1669            response_key: vec![8; 24],
1670            auto_auth_key: vec![],
1671            key_info: vec![],
1672            database_password: vec![],
1673            auth_snapshot: AuthSnapshot::default(),
1674            long_host: "long.weixin.qq.com".into(),
1675            short_host: "short.weixin.qq.com".into(),
1676        }
1677    }
1678    #[test]
1679    fn challenge_poll_retries_only_transport_availability_failures() {
1680        use std::io::{Error, ErrorKind};
1681        for kind in [
1682            ErrorKind::TimedOut,
1683            ErrorKind::WouldBlock,
1684            ErrorKind::ConnectionReset,
1685            ErrorKind::UnexpectedEof,
1686        ] {
1687            assert!(is_retryable_login_poll_error(
1688                &anyhow::Error::new(Error::from(kind)).context("polling challenge")
1689            ));
1690        }
1691        for message in [
1692            "MMTLS deadline exceeded",
1693            "MMTLS connection closed",
1694            "Native shortlink HTTP status 503",
1695        ] {
1696            assert!(is_retryable_login_poll_error(&anyhow::anyhow!("{message}")));
1697        }
1698        for message in [
1699            "Login poll returned -13",
1700            "Native envelope returned -13",
1701            "Native shortlink HTTP status 403",
1702            "Native shortlink HTTP status 5030",
1703            "Missing authentication tag",
1704            "Unexpected MMTLS header",
1705            "Truncated structure",
1706        ] {
1707            assert!(!is_retryable_login_poll_error(&anyhow::anyhow!(
1708                "{message}"
1709            )));
1710        }
1711        assert!(!is_retryable_login_poll_error(
1712            &Error::from(ErrorKind::PermissionDenied).into()
1713        ));
1714    }
1715    #[test]
1716    fn secrets_not_debugged() {
1717        let s = state();
1718        let text = format!("{s:?}");
1719        assert!(!text.contains("synthetic-user"));
1720        assert!(!text.contains("123"));
1721        assert!(text.contains("redacted"));
1722    }
1723    #[test]
1724    fn account_state_roundtrips_without_number_loss() {
1725        let s = state();
1726        let json = serde_json::to_vec(&s).unwrap();
1727        let restored: AccountState = serde_json::from_slice(&json).unwrap();
1728        restored.validate().unwrap();
1729        assert_eq!(restored.uin, s.uin);
1730    }
1731    #[test]
1732    fn database_password_follows_auth_update_gate_and_optional_presence() {
1733        let mut old = state();
1734        old.database_password = vec![11; 32];
1735        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1736        let response = |mask, password: Option<&[u8]>| {
1737            let mut fields = vec![
1738                p::uint(1, old.uin as u64),
1739                p::bytes(3, &p::buffer(&[1; 16])),
1740                p::uint(19, 0),
1741            ];
1742            if let Some(bytes) = password {
1743                fields.push(p::bytes(9, &p::buffer(bytes)));
1744            }
1745            p::message(&[
1746                p::bytes(1, &p::uint(1, 0)),
1747                p::uint(2, mask),
1748                p::bytes(3, &p::message(&fields).unwrap()),
1749                p::bytes(6, b"synthetic-ax"),
1750            ])
1751            .unwrap()
1752        };
1753        let gated_off = response(0, Some(&[22; 32]));
1754        let unchanged = reduce_auth(&gated_off, Some(&old), &inner, &old.device, &[]).unwrap();
1755        assert_eq!(unchanged.database_password, old.database_password);
1756        assert_eq!(unchanged.auth_snapshot.update_mask, 0);
1757        assert_eq!(unchanged.auth_snapshot.response, gated_off);
1758        assert_eq!(unchanged.auth_snapshot.ax_auth, b"synthetic-ax");
1759        let accepted = reduce_auth(
1760            &response(1, Some(&[22; 32])),
1761            Some(&old),
1762            &inner,
1763            &old.device,
1764            &[],
1765        )
1766        .unwrap();
1767        assert_eq!(accepted.database_password, vec![22; 32]);
1768        let absent = reduce_auth(
1769            &response(1, None),
1770            Some(&accepted),
1771            &inner,
1772            &old.device,
1773            &[],
1774        )
1775        .unwrap();
1776        assert_eq!(absent.database_password, accepted.database_password);
1777        let empty = reduce_auth(
1778            &response(1, Some(&[])),
1779            Some(&absent),
1780            &inner,
1781            &old.device,
1782            &[],
1783        )
1784        .unwrap();
1785        assert!(empty.database_password.is_empty());
1786        assert!(reduce_auth(
1787            &response(1, Some(&[22; 31])),
1788            Some(&old),
1789            &inner,
1790            &old.device,
1791            &[]
1792        )
1793        .is_err());
1794        let mut legacy = serde_json::to_value(&old).unwrap();
1795        legacy.as_object_mut().unwrap().remove("database_password");
1796        legacy.as_object_mut().unwrap().remove("auth_snapshot");
1797        let restored: AccountState = serde_json::from_value(legacy).unwrap();
1798        restored.validate().unwrap();
1799        assert!(restored.database_password.is_empty());
1800    }
1801    #[test]
1802    fn refresh_identity_and_clear_tokens() {
1803        let old = state();
1804        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1805        let auth = p::message(&[
1806            p::uint(1, old.uin as u64),
1807            p::bytes(3, &p::buffer(&[1; 16])),
1808            p::uint(19, 0),
1809        ])
1810        .unwrap();
1811        let reply = p::message(&[
1812            p::bytes(1, &p::uint(1, 0)),
1813            p::uint(2, 1),
1814            p::bytes(3, &auth),
1815            p::bytes(4, &p::bytes(1, old.own_username.as_bytes())),
1816        ])
1817        .unwrap();
1818        let next = reduce_auth(&reply, Some(&old), &inner, &old.device, &[]).unwrap();
1819        assert!(next.auto_auth_key.is_empty());
1820        assert!(next.request_key.is_empty());
1821        let bad = p::message(&[
1822            p::bytes(1, &p::uint(1, 0)),
1823            p::uint(2, 0),
1824            p::bytes(4, &p::bytes(1, b"foreign")),
1825        ])
1826        .unwrap();
1827        assert!(reduce_auth(&bad, Some(&old), &inner, &old.device, &[]).is_err());
1828    }
1829}