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}
465/// Blocking client for supported WeChat server operations.
466///
467/// Construct with [`Self::new`], obtain an [`AccountState`] through approved login,
468/// then use methods such as [`Self::send_text`] or [`Self::sync`]. The state is
469/// passed mutably because authenticated responses can update credentials/routing.
470/// This client does not own a daemon, profile, send queue or recovery journal.
471pub struct NativeClient {
472    options: NativeOptions,
473}
474/// Polling an issued login challenge is repeatable. This does not authorize
475/// retrying QR creation, credential exchange, sends or rejected authentication.
476pub fn is_retryable_login_poll_error(error: &anyhow::Error) -> bool {
477    if crate::login_lan_error_reason(error).is_some() {
478        return false;
479    }
480    error.chain().any(|cause| {
481        if let Some(io) = cause.downcast_ref::<std::io::Error>() {
482            return matches!(
483                io.kind(),
484                std::io::ErrorKind::TimedOut
485                    | std::io::ErrorKind::WouldBlock
486                    | std::io::ErrorKind::ConnectionReset
487                    | std::io::ErrorKind::ConnectionAborted
488                    | std::io::ErrorKind::ConnectionRefused
489                    | std::io::ErrorKind::UnexpectedEof
490                    | std::io::ErrorKind::BrokenPipe
491                    | std::io::ErrorKind::NotConnected
492            );
493        }
494        if let Some(http) = cause.downcast_ref::<reqwest::Error>() {
495            return http.is_timeout() || http.is_connect();
496        }
497        let message = cause.to_string();
498        matches!(
499            message.as_str(),
500            "MMTLS deadline exceeded" | "MMTLS connection closed"
501        ) || message
502            .strip_prefix("Native shortlink HTTP status ")
503            .and_then(|s| s.parse::<u16>().ok())
504            .is_some_and(|status| matches!(status, 408 | 429 | 500 | 502 | 503 | 504))
505    })
506}
507impl NativeClient {
508    /// Validate transport options. Construction itself makes no network request.
509    pub fn new(options: NativeOptions) -> Result<Self> {
510        mmtls::validate_host(&options.long_host)?;
511        mmtls::validate_host(&options.short_host)?;
512        ensure!(
513            !options.timeout.is_zero() && options.timeout <= Duration::from_secs(120),
514            "Invalid native timeout"
515        );
516        ensure!(
517            (1024..=crate::MAX_MESSAGE_BYTES).contains(&options.receive_budget),
518            "Invalid receive budget"
519        );
520        Ok(Self { options })
521    }
522    fn session(&self, host: &str) -> Result<Session> {
523        Session::connect(host, self.options.timeout, self.options.receive_budget)
524    }
525    pub fn push_connection(&self, state: &AccountState) -> Result<crate::push::Connection> {
526        crate::push::Connection::open(state, self.options.timeout, self.options.receive_budget)
527    }
528    #[allow(clippy::too_many_arguments)]
529    fn prelogin_rpc(
530        &self,
531        long_host: &str,
532        short_host: &str,
533        cgi: u16,
534        path: &str,
535        plain: &[u8],
536        cookie: &[u8],
537        auth_key: &[u8],
538        fixed_version: Option<u16>,
539    ) -> Result<(Vec<u8>, Vec<u8>, u16)> {
540        let session = self.session(long_host)?;
541        let profile = if let Some(version) = fixed_version {
542            business::profile_for_region(if version == 50005 { 2 } else { 1 })?
543        } else {
544            business::profile_for_region(session.certificate_region)?
545        };
546        let prepared = Prelogin::prepare(plain, &profile, auth_key)?;
547        let packet = business::prelogin_packet(&prepared, cgi, profile.version, cookie)?;
548        let reply = mmtls::short_exchange(
549            &session.short,
550            short_host,
551            path,
552            &packet,
553            self.options.timeout,
554        )?;
555        let envelope = business::split(&reply)?;
556        ensure!(
557            envelope.return_code == 0,
558            "Native envelope returned {}",
559            envelope.return_code
560        );
561        ensure!(
562            envelope.cgi == cgi as u64 && envelope.encryption == 12,
563            "Unexpected prelogin response policy"
564        );
565        let plain = prepared.decrypt_reply(&envelope.payload)?;
566        Ok((plain, envelope.cookie, profile.version))
567    }
568    /// Request one QR challenge for the supplied device. Display
569    /// [`LoginAttempt::qr_png`] privately, poll for phone approval, then call
570    /// [`Self::complete_login`]. This does not grant authorization by itself.
571    pub fn start_qr_login(
572        &self,
573        device: DeviceProfile,
574        computer_name: &str,
575    ) -> Result<LoginAttempt> {
576        self.start_profile_qr_login(device, computer_name, &[])
577    }
578    /// QR approval for an existing installation retains its selected key-info.
579    /// This neither bypasses phone approval nor imports active authorization.
580    pub fn start_profile_qr_login(
581        &self,
582        device: DeviceProfile,
583        computer_name: &str,
584        key_info: &[u8],
585    ) -> Result<LoginAttempt> {
586        device.validate()?;
587        ensure!(key_info.len() <= 1024 * 1024, "Invalid saved key-info");
588        let plain = wire::qr_request(&device, computer_name)?;
589        let (reply, cookie, key_version) = self.prelogin_rpc(
590            &self.options.long_host,
591            &self.options.short_host,
592            502,
593            "/cgi-bin/micromsg-bin/getloginqrcode",
594            &plain,
595            &[],
596            &[],
597            None,
598        )?;
599        let qr = wire::decode_qr(&reply)?;
600        let now = wire::unix_time()?;
601        Ok(LoginAttempt {
602            schema: 1,
603            device,
604            computer_name: computer_name.to_string(),
605            qr_png: qr.image,
606            width: qr.width,
607            height: qr.height,
608            created_at: now,
609            expires_at: now.saturating_add(240),
610            identifier: qr.identifier,
611            notify_key: qr.notify_key,
612            cookie,
613            key_version,
614            long_host: self.options.long_host.clone(),
615            short_host: self.options.short_host.clone(),
616            status: LoginStatus::Pending,
617            key_info: key_info.to_vec(),
618            credential3: None,
619            credential4: None,
620            lan_upload_attempted: false,
621            lan_challenge: None,
622        })
623    }
624    /// Initiates phone approval for an existing remembered device without a QR.
625    /// The caller chooses this fallback explicitly; no automatic auth retries occur.
626    pub fn start_saved_device_login(
627        &self,
628        state: &AccountState,
629        computer_name: &str,
630    ) -> Result<LoginAttempt> {
631        state.validate()?;
632        self.start_saved_login(
633            &state.device,
634            &state.own_username,
635            &state.auto_auth_key,
636            &state.cookie,
637            &state.key_info,
638            &state.long_host,
639            &state.short_host,
640            computer_name,
641        )
642    }
643    /// Use remembered official-profile trust without inventing active session keys.
644    pub fn start_profile_saved_device_login(
645        &self,
646        device: &DeviceProfile,
647        username: &str,
648        auto_auth_key: &[u8],
649        cookie: &[u8],
650        key_info: &[u8],
651        computer_name: &str,
652    ) -> Result<LoginAttempt> {
653        self.start_saved_login(
654            device,
655            username,
656            auto_auth_key,
657            cookie,
658            key_info,
659            &self.options.long_host,
660            &self.options.short_host,
661            computer_name,
662        )
663    }
664    #[allow(clippy::too_many_arguments)]
665    fn start_saved_login(
666        &self,
667        device: &DeviceProfile,
668        username: &str,
669        auto_auth_key: &[u8],
670        cookie: &[u8],
671        key_info: &[u8],
672        long_host: &str,
673        short_host: &str,
674        computer_name: &str,
675    ) -> Result<LoginAttempt> {
676        device.validate()?;
677        ensure!(
678            !username.is_empty()
679                && username.len() <= 256
680                && !username.contains('\0')
681                && !auto_auth_key.is_empty()
682                && auto_auth_key.len() <= 1024 * 1024
683                && cookie.len() <= 15
684                && key_info.len() <= 1024 * 1024,
685            "Invalid saved-device credentials"
686        );
687        let rsa = Rsa::generate(1024)?;
688        let public = PKey::from_rsa(rsa)?.public_key_to_pem()?;
689        let request = wire::push_login(device, username, auto_auth_key, &public)?;
690        let (reply, cookie, key_version) = self.prelogin_rpc(
691            long_host,
692            short_host,
693            654,
694            "/cgi-bin/micromsg-bin/pushloginurl",
695            &request,
696            cookie,
697            &[],
698            None,
699        )?;
700        let (identifier, notify_key) = wire::decode_push(&reply)?;
701        let now = wire::unix_time()?;
702        Ok(LoginAttempt {
703            schema: 1,
704            device: device.clone(),
705            computer_name: computer_name.to_string(),
706            qr_png: vec![],
707            width: 0,
708            height: 0,
709            created_at: now,
710            expires_at: now.saturating_add(240),
711            identifier,
712            notify_key,
713            cookie,
714            key_version,
715            long_host: long_host.into(),
716            short_host: short_host.into(),
717            status: LoginStatus::Pending,
718            key_info: key_info.to_vec(),
719            credential3: None,
720            credential4: None,
721            lan_upload_attempted: false,
722            lan_challenge: None,
723        })
724    }
725    /// Poll an existing challenge. Only [`LoginStatus::Approved`] permits
726    /// [`Self::complete_login`]; expired/rejected/cancelled attempts must stop.
727    pub fn poll_login(&self, attempt: &mut LoginAttempt) -> Result<LoginStatus> {
728        self.poll_login_guarded(
729            attempt,
730            std::time::Instant::now() + Duration::from_secs(900),
731            || false,
732        )
733    }
734    /// The guard bounds the temporary LAN listener even while a blocking poll
735    /// or upload is in flight. It must not perform blocking work itself.
736    pub fn poll_login_guarded(
737        &self,
738        attempt: &mut LoginAttempt,
739        deadline: std::time::Instant,
740        cancelled: impl Fn() -> bool + Send + Sync + 'static,
741    ) -> Result<LoginStatus> {
742        let now = wire::unix_time()?;
743        if attempt.locally_expired(now) {
744            attempt.stop_lan_login();
745            attempt.status = LoginStatus::Expired;
746            return Ok(attempt.status);
747        }
748        attempt.validate_at(now)?;
749        ensure!(
750            !matches!(
751                attempt.status,
752                LoginStatus::Completed | LoginStatus::Cancelled | LoginStatus::Rejected
753            ),
754            "Login attempt is terminal"
755        );
756        let request = wire::poll_request(&attempt.device, &attempt.identifier)?;
757        let (reply, cookie, _) = self.prelogin_rpc(
758            &attempt.long_host,
759            &attempt.short_host,
760            503,
761            "/cgi-bin/micromsg-bin/checkloginqrcode",
762            &request,
763            &attempt.cookie,
764            &[],
765            Some(attempt.key_version),
766        )?;
767        let response = wire::decode_poll(&reply, &attempt.notify_key)?;
768        if !cookie.is_empty() {
769            attempt.cookie = cookie;
770        }
771        let remaining = attempt
772            .created_at
773            .saturating_add(900)
774            .saturating_sub(wire::unix_time()?);
775        let deadline =
776            deadline.min(std::time::Instant::now() + Duration::from_secs(u64::from(remaining)));
777        let scope = crate::lan_login::Scope::new(deadline, cancelled);
778        apply_login_notification(attempt, response, |attempt| {
779            self.start_lan_login(attempt, scope)
780        })
781    }
782    fn start_lan_login(
783        &self,
784        attempt: &mut LoginAttempt,
785        scope: crate::lan_login::Scope,
786    ) -> Result<std::sync::Arc<crate::lan_login::Challenge>> {
787        use crate::lan_login as lan;
788        let challenge = std::sync::Arc::new(lan::Challenge::bind(scope)?);
789        let random = zeroize::Zeroizing::new(crate::crypto::random::<16>()?);
790        let request = zeroize::Zeroizing::new(challenge.request(
791            &attempt.device,
792            &attempt.identifier,
793            &random,
794        )?);
795        let (reply, cookie, _) = self
796            .prelogin_rpc(
797                &attempt.long_host,
798                &attempt.short_host,
799                lan::CGI,
800                lan::PATH,
801                &request,
802                &attempt.cookie,
803                &[],
804                Some(attempt.key_version),
805            )
806            .map_err(|_| lan::failure("upload_failed"))?;
807        let reply = zeroize::Zeroizing::new(reply);
808        let token =
809            lan::response(&reply).map_err(|_| lan::failure("upload_rejected_or_invalid"))?;
810        challenge.arm(&token)?;
811        if !cookie.is_empty() {
812            attempt.cookie = cookie;
813        }
814        Ok(challenge)
815    }
816    /// Exchange an approved challenge for secret account credentials once.
817    /// Keep the returned state in memory or an encrypted application vault.
818    pub fn complete_login(&self, attempt: &mut LoginAttempt) -> Result<AccountState> {
819        attempt.stop_lan_login();
820        attempt.validate()?;
821        ensure!(
822            attempt.status == LoginStatus::Approved,
823            "Login has not been approved"
824        );
825        let inner = Ecdh::generate(Nid::SECP224R1)?;
826        let request = attempt.manual_auth_request(&inner.public()?)?;
827        let mut long_host = attempt.long_host.clone();
828        let mut short_host = attempt.short_host.clone();
829        let mut cookie = attempt.cookie.clone();
830        for redirected in 0..=1 {
831            let (reply, next_cookie, _) = self.prelogin_rpc(
832                &long_host,
833                &short_host,
834                252,
835                "/cgi-bin/micromsg-bin/secmanualauth",
836                &request,
837                &cookie,
838                &[],
839                None,
840            )?;
841            let fields = p::decode(&reply)?;
842            let code = p::base_code(&fields)?;
843            if code == -301 && redirected == 0 {
844                (long_host, short_host) = redirect(&fields)?;
845                cookie = next_cookie;
846                continue;
847            }
848            ensure!(code == 0, "Account authentication returned {code}");
849            let mut state = reduce_auth(&reply, None, &inner, &attempt.device, &next_cookie)?;
850            state.long_host = long_host;
851            state.short_host = short_host;
852            state.validate()?;
853            attempt.status = LoginStatus::Completed;
854            attempt.credential3.zeroize();
855            attempt.credential4.zeroize();
856            return Ok(state);
857        }
858        bail!("Native authentication redirect limit reached")
859    }
860    /// Replace an expired, unscanned QR while preserving installation key-info.
861    pub fn refresh_qr_login(&self, attempt: &LoginAttempt) -> Result<LoginAttempt> {
862        ensure!(
863            attempt.status == LoginStatus::Expired && !attempt.qr_png.is_empty(),
864            "Only an expired QR may be refreshed"
865        );
866        let options = NativeOptions {
867            long_host: attempt.long_host.clone(),
868            short_host: attempt.short_host.clone(),
869            ..self.options.clone()
870        };
871        Self::new(options)?.start_profile_qr_login(
872            attempt.device.clone(),
873            &attempt.computer_name,
874            &attempt.key_info,
875        )
876    }
877    /// One trusted AutoAuth exchange, retaining identity and replacing selected key
878    /// sections atomically in the returned value. Does not silently fall back to QR.
879    pub fn renew_session(
880        &self,
881        previous: &AccountState,
882        computer_name: &str,
883    ) -> Result<AccountState> {
884        previous.validate()?;
885        let inner = Ecdh::generate(Nid::SECP224R1)?;
886        let request = wire::auto_auth(previous, &inner.public()?, computer_name)?;
887        let mut long = previous.long_host.clone();
888        let mut short = previous.short_host.clone();
889        let mut cookie = previous.cookie.clone();
890        for redirected in 0..=1 {
891            let (reply, next_cookie, _) = self.prelogin_rpc(
892                &long,
893                &short,
894                763,
895                "/cgi-bin/micromsg-bin/secautoauth",
896                &request,
897                &cookie,
898                &previous.auto_auth_key,
899                None,
900            )?;
901            let fields = p::decode(&reply)?;
902            let code = p::base_code(&fields)?;
903            if code == -301 && redirected == 0 {
904                (long, short) = redirect(&fields)?;
905                cookie = next_cookie;
906                continue;
907            }
908            ensure!(code == 0, "Automatic authentication returned {code}");
909            let mut next = reduce_auth(
910                &reply,
911                Some(previous),
912                &inner,
913                &previous.device,
914                &next_cookie,
915            )?;
916            next.long_host = long;
917            next.short_host = short;
918            next.validate()?;
919            return Ok(next);
920        }
921        bail!("AutoAuth redirect limit reached")
922    }
923    pub(crate) fn authenticated_rpc(
924        &self,
925        state: &mut AccountState,
926        cgi: u16,
927        path: &str,
928        request: &[u8],
929    ) -> Result<Vec<u8>> {
930        let session = self.session(&state.long_host)?;
931        let packet = AuthenticatedPacket::prepare(state, cgi, request)?;
932        let reply = mmtls::short_exchange(
933            &session.short,
934            &state.short_host,
935            path,
936            &packet.packet,
937            self.options.timeout,
938        )?;
939        let (plain, cookie) = packet.decode_verified_transport_reply(&reply)?;
940        if !cookie.is_empty() {
941            state.cookie = cookie;
942        }
943        Ok(plain)
944    }
945    /// Fetch one scene-6 sync page. An empty cursor starts an initial sync.
946    /// Returned commands include their original payloads. Persist and handle all
947    /// required commands before committing the returned cursor; this call does
948    /// not write history or manage a checkpoint for the application.
949    pub fn sync(&self, state: &mut AccountState, cursor: &[u8]) -> Result<SyncBatch> {
950        self.sync_scene(state, cursor, 6)
951    }
952    pub fn sync_scene(
953        &self,
954        state: &mut AccountState,
955        cursor: &[u8],
956        scene: u32,
957    ) -> Result<SyncBatch> {
958        ensure!([6, 7].contains(&scene), "Unsupported sync scene");
959        let wire_cursor = if cursor.is_empty() { &[0][..] } else { cursor };
960        let request = wire::sync_request(wire_cursor, &state.device.device_type, scene)?;
961        wire::decode_sync(
962            &self.authenticated_rpc(state, 138, "/cgi-bin/micromsg-bin/newsync", &request)?,
963            cursor,
964        )
965    }
966    /// Request heartbeat hints for a committed sync cursor. Hints are not message
967    /// contents; the application still needs to fetch and handle sync commands.
968    pub fn heartbeat(&self, state: &mut AccountState, cursor: &[u8]) -> Result<Heartbeat> {
969        let request = wire::heartbeat_request(state, cursor)?;
970        wire::decode_heartbeat(&self.authenticated_rpc(
971            state,
972            518,
973            "/cgi-bin/micromsg-bin/heartbeat",
974            &request,
975        )?)
976    }
977    /// Submit exactly once. An I/O error after submission is an uncertain outcome;
978    /// services must persist the intent and must not automatically retry this call.
979    ///
980    /// `destination` is an exact WeChat username, not a display name or daemon
981    /// account ID. `created_at` is Unix seconds; `client_message_id` is a nonzero
982    /// protocol ID derived with [`wire::client_message_id`] from the caller's task
983    /// ID. Check acceptance and both returned-identity matches in the receipt.
984    /// No official-profile history is written by this method.
985    pub fn send_text(
986        &self,
987        state: &mut AccountState,
988        destination: &str,
989        content: &str,
990        created_at: u32,
991        client_message_id: u32,
992    ) -> Result<TextSendResult> {
993        self.send_text_with_source(
994            state,
995            destination,
996            content,
997            created_at,
998            client_message_id,
999            None,
1000        )
1001    }
1002    /// Submit text with optional native message-source XML. For a simple send use
1003    /// [`Self::send_text`]. This has the same single-submission/uncertainty contract.
1004    pub fn send_text_with_source(
1005        &self,
1006        state: &mut AccountState,
1007        destination: &str,
1008        content: &str,
1009        created_at: u32,
1010        client_message_id: u32,
1011        source: Option<&str>,
1012    ) -> Result<TextSendResult> {
1013        let request = wire::text_send_request_with_source(
1014            destination,
1015            content,
1016            created_at,
1017            client_message_id,
1018            source,
1019        )?;
1020        wire::decode_text_send(
1021            &self.authenticated_rpc(state, 522, "/cgi-bin/micromsg-bin/newsendmsg", &request)?,
1022            destination,
1023            client_message_id,
1024        )
1025    }
1026    /// Look up exact usernames on the server. The returned records are not
1027    /// automatically written into a local profile.
1028    pub fn lookup_contacts(
1029        &self,
1030        state: &mut AccountState,
1031        usernames: &[String],
1032    ) -> Result<ContactLookup> {
1033        let request = wire::contact_request(state, usernames)?;
1034        wire::decode_contacts(
1035            &self.authenticated_rpc(state, 182, "/cgi-bin/micromsg-bin/getcontact", &request)?,
1036            usernames,
1037        )
1038    }
1039    pub fn lookup_room(
1040        &self,
1041        state: &mut AccountState,
1042        room_id: &str,
1043        version: u32,
1044    ) -> Result<RoomRoster> {
1045        let request = wire::room_request(state, room_id, version)?;
1046        wire::decode_room(
1047            &self.authenticated_rpc(
1048                state,
1049                551,
1050                "/cgi-bin/micromsg-bin/getchatroommemberdetail",
1051                &request,
1052            )?,
1053            room_id,
1054        )
1055    }
1056    pub fn init_contacts(
1057        &self,
1058        state: &mut AccountState,
1059        contact_sequence: u32,
1060        room_sequence: u32,
1061    ) -> Result<InitContactPage> {
1062        let request =
1063            wire::init_contacts_request(&state.own_username, contact_sequence, room_sequence)?;
1064        let page = wire::decode_init_contacts(&self.authenticated_rpc(
1065            state,
1066            851,
1067            "/cgi-bin/micromsg-bin/initcontact",
1068            &request,
1069        )?)?;
1070        ensure!(
1071            !page.more
1072                || page.contact_sequence != contact_sequence
1073                || page.room_sequence != room_sequence,
1074            "Contact bootstrap made no cursor progress"
1075        );
1076        Ok(page)
1077    }
1078    pub fn download_avatars(
1079        &self,
1080        state: &mut AccountState,
1081        usernames: &[String],
1082    ) -> Result<Vec<AvatarBytes>> {
1083        let request = wire::avatar_request(state, usernames)?;
1084        wire::decode_avatars(
1085            &self.authenticated_rpc(
1086                state,
1087                123,
1088                "/cgi-bin/micromsg-bin/batchgetheadimg",
1089                &request,
1090            )?,
1091            usernames,
1092        )
1093    }
1094    pub fn download_voice_chunk(
1095        &self,
1096        state: &mut AccountState,
1097        server_message_id: u64,
1098        total_length: u32,
1099        offset: u32,
1100    ) -> Result<VoiceChunk> {
1101        let request = wire::voice_request(state, server_message_id, total_length, offset)?;
1102        wire::decode_voice(
1103            &self.authenticated_rpc(state, 128, "/cgi-bin/micromsg-bin/downloadvoice", &request)?,
1104            server_message_id,
1105            total_length,
1106            offset,
1107        )
1108    }
1109    pub fn cdn_config(&self, state: &mut AccountState) -> Result<crate::media::CdnConfig> {
1110        let request = p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?);
1111        let reply =
1112            self.authenticated_rpc(state, 379, "/cgi-bin/micromsg-bin/getcdndns", &request)?;
1113        crate::media::decode_cdn_config(&reply, state)
1114    }
1115    pub fn large_cdn_config(
1116        &self,
1117        state: &mut AccountState,
1118        route: crate::media::LargeCdnRoute,
1119    ) -> Result<crate::media::CdnConfig> {
1120        let request = p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?);
1121        let reply =
1122            self.authenticated_rpc(state, 379, "/cgi-bin/micromsg-bin/getcdndns", &request)?;
1123        crate::media::decode_large_cdn_config(&reply, state, route)
1124    }
1125    /// Submit a URL card once through the app-message carrier. The caller owns
1126    /// the durable intent and must inspect an uncertain submission outcome.
1127    pub fn send_share(
1128        &self,
1129        state: &mut AccountState,
1130        destination: &str,
1131        client_id: &str,
1132        created_at: u32,
1133        content: &str,
1134    ) -> Result<crate::media_send::MediaSendResult> {
1135        let request = crate::media_send::share_send_request(
1136            state,
1137            destination,
1138            client_id,
1139            created_at,
1140            content,
1141        )?;
1142        crate::media_send::decode_file_send(
1143            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1144            client_id,
1145        )
1146    }
1147    /// Submit a quoted reply once. The caller owns the persisted XML, intent and
1148    /// uncertain-submission handling, as for other app-message sends.
1149    pub fn send_quote(
1150        &self,
1151        state: &mut AccountState,
1152        destination: &str,
1153        client_id: &str,
1154        created_at: u32,
1155        content: &str,
1156    ) -> Result<crate::media_send::MediaSendResult> {
1157        self.send_quote_with_source(state, destination, client_id, created_at, content, None)
1158    }
1159    pub fn send_quote_with_source(
1160        &self,
1161        state: &mut AccountState,
1162        destination: &str,
1163        client_id: &str,
1164        created_at: u32,
1165        content: &str,
1166        source: Option<&str>,
1167    ) -> Result<crate::media_send::MediaSendResult> {
1168        let request = crate::quote_send::quote_send_request_with_source(
1169            state,
1170            destination,
1171            client_id,
1172            created_at,
1173            content,
1174            source,
1175        )?;
1176        crate::media_send::decode_file_send(
1177            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1178            client_id,
1179        )
1180    }
1181    /// Reserve one file-upload message. An uncertain response must not be
1182    /// retried automatically; retain the returned receipt with the send intent.
1183    pub fn reserve_file_upload(
1184        &self,
1185        state: &mut AccountState,
1186        destination: &str,
1187        progress_client_id: &str,
1188        file_name: &str,
1189        plaintext_bytes: usize,
1190        md5: &str,
1191    ) -> Result<crate::file_upload::FileUploadReservation> {
1192        let request = crate::file_upload::file_upload_request(
1193            state,
1194            destination,
1195            progress_client_id,
1196            file_name,
1197            plaintext_bytes,
1198            md5,
1199        )?;
1200        crate::file_upload::decode_file_upload_reservation(&self.authenticated_rpc(
1201            state,
1202            6691,
1203            "/cgi-bin/micromsg-bin/sendfileuploadmsg",
1204            &request,
1205        )?)
1206    }
1207    pub fn send_file(
1208        &self,
1209        state: &mut AccountState,
1210        destination: &str,
1211        client_id: &str,
1212        created_at: u32,
1213        upload: &crate::media::UploadedMedia,
1214    ) -> Result<crate::media_send::MediaSendResult> {
1215        let request = crate::media_send::file_send_request(
1216            state,
1217            destination,
1218            client_id,
1219            created_at,
1220            upload,
1221        )?;
1222        crate::media_send::decode_file_send(
1223            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1224            client_id,
1225        )
1226    }
1227    #[allow(clippy::too_many_arguments)]
1228    pub fn send_file_with_reservation(
1229        &self,
1230        state: &mut AccountState,
1231        destination: &str,
1232        client_id: &str,
1233        created_at: u32,
1234        upload: &crate::media::UploadedMedia,
1235        reservation: Option<&crate::file_upload::FileUploadReservation>,
1236    ) -> Result<crate::media_send::MediaSendResult> {
1237        let request = crate::media_send::file_send_request_with_reservation(
1238            state,
1239            destination,
1240            client_id,
1241            created_at,
1242            upload,
1243            reservation,
1244        )?;
1245        let reply =
1246            self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?;
1247        if reservation.is_some() {
1248            crate::media_send::decode_file_send_correlated(
1249                &reply,
1250                client_id,
1251                &state.own_username,
1252                destination,
1253                &upload.descriptor.aes_key,
1254            )
1255        } else {
1256            crate::media_send::decode_file_send(&reply, client_id)
1257        }
1258    }
1259    pub fn send_image(
1260        &self,
1261        state: &mut AccountState,
1262        destination: &str,
1263        client_id: &str,
1264        upload: &crate::media::UploadedImage,
1265    ) -> Result<crate::media_send::MediaSendResult> {
1266        let request = crate::media_send::image_send_request(state, destination, client_id, upload)?;
1267        crate::media_send::decode_image_send(
1268            &self.authenticated_rpc(state, 110, "/cgi-bin/micromsg-bin/uploadmsgimg", &request)?,
1269            client_id,
1270            &state.own_username,
1271            destination,
1272        )
1273    }
1274    pub fn send_video(
1275        &self,
1276        state: &mut AccountState,
1277        destination: &str,
1278        client_id: &str,
1279        upload: &crate::media::UploadedVideo,
1280        play_seconds: u32,
1281    ) -> Result<crate::media_send::MediaSendResult> {
1282        let request = crate::media_send::video_send_request(
1283            state,
1284            destination,
1285            client_id,
1286            upload,
1287            play_seconds,
1288        )?;
1289        crate::media_send::decode_video_send(
1290            &self.authenticated_rpc(state, 149, "/cgi-bin/micromsg-bin/uploadvideo", &request)?,
1291            client_id,
1292        )
1293    }
1294    /// Submit one voice note, as consecutive byte ranges when it exceeds one
1295    /// request. Each range is sent once; an error after the first submission is
1296    /// an uncertain outcome that the caller must not retry automatically.
1297    pub fn send_voice(
1298        &self,
1299        state: &mut AccountState,
1300        destination: &str,
1301        client_id: &str,
1302        created_at: u32,
1303        duration_ms: u32,
1304        encoded: &[u8],
1305    ) -> Result<crate::media_send::MediaSendResult> {
1306        let mut upload = crate::media_send::VoiceUpload::new(
1307            destination,
1308            client_id,
1309            created_at,
1310            duration_ms,
1311            encoded,
1312        )?;
1313        loop {
1314            let request = upload.request(state)?;
1315            let reply =
1316                self.authenticated_rpc(state, 127, "/cgi-bin/micromsg-bin/uploadvoice", &request)?;
1317            if let Some(result) = upload.accept_reply(&reply)? {
1318                return Ok(result);
1319            }
1320        }
1321    }
1322}
1323
1324fn redirect(fields: &[p::Field]) -> Result<(String, String)> {
1325    let network = p::decode(p::data(fields, 5)?)?;
1326    let hosts = p::decode(p::data(&network, 1)?)?;
1327    let target = |source: &[u8]| -> Result<String> {
1328        let mut out = None;
1329        for f in hosts.iter().filter(|f| f.number == 2) {
1330            let p::Value::Bytes(raw) = &f.value else {
1331                bail!("Invalid redirect map");
1332            };
1333            let item = p::decode(raw)?;
1334            if p::data(&item, 1)? == source {
1335                ensure!(out.is_none(), "Ambiguous redirect");
1336                let name = std::str::from_utf8(p::data(&item, 2)?)?.to_string();
1337                mmtls::validate_host(&name)?;
1338                out = Some(name);
1339            }
1340        }
1341        out.ok_or_else(|| anyhow::anyhow!("Missing redirect mapping"))
1342    };
1343    Ok((
1344        target(b"long.weixin.qq.com")?,
1345        target(b"short.weixin.qq.com")?,
1346    ))
1347}
1348fn reduce_auth(
1349    plain: &[u8],
1350    previous: Option<&AccountState>,
1351    inner: &Ecdh,
1352    device: &DeviceProfile,
1353    cookie: &[u8],
1354) -> Result<AccountState> {
1355    let f = p::decode(plain)?;
1356    ensure!(p::base_code(&f)? == 0, "Rejected auth response");
1357    let mask = u32::try_from(p::scalar(&f, 2)?)?;
1358    for (bit, n) in [(1, 3), (2, 4), (4, 5)] {
1359        if mask & bit != 0 {
1360            p::data(&f, n)?;
1361        }
1362    }
1363    let mut state = previous.cloned().unwrap_or_else(|| AccountState {
1364        schema: 1,
1365        device: device.clone(),
1366        uin: 0,
1367        own_username: String::new(),
1368        cookie: vec![],
1369        session_key: vec![],
1370        request_key: vec![],
1371        response_key: vec![],
1372        auto_auth_key: vec![],
1373        key_info: vec![],
1374        database_password: vec![],
1375        auth_snapshot: AuthSnapshot::default(),
1376        long_host: "long.weixin.qq.com".into(),
1377        short_host: "short.weixin.qq.com".into(),
1378    });
1379    ensure!(
1380        plain.len() <= 1024 * 1024,
1381        "Auth response snapshot too large"
1382    );
1383    state.auth_snapshot.update_mask = mask;
1384    state.auth_snapshot.response = plain.to_vec();
1385    for (number, target) in [
1386        (3, &mut state.auth_snapshot.auth),
1387        (4, &mut state.auth_snapshot.account),
1388        (5, &mut state.auth_snapshot.network),
1389        (6, &mut state.auth_snapshot.ax_auth),
1390    ] {
1391        if let Some(section) = p::optional_data(&f, number)? {
1392            *target = section.to_vec();
1393        }
1394    }
1395    if let Some(account) = p::optional_data(&f, 4)? {
1396        let a = p::decode(account)?;
1397        if let Some(name) = p::optional_data(&a, 1)? {
1398            let name = std::str::from_utf8(name)?;
1399            ensure!(
1400                previous.is_none_or(|s| s.own_username == name),
1401                "Auth account identity changed"
1402            );
1403            state.own_username = name.to_string();
1404        }
1405    }
1406    if mask & 1 != 0 {
1407        let auth = p::data(&f, 3)?;
1408        let a = p::decode(auth)?;
1409        let uin = u32::try_from(p::scalar(&a, 1)?)?;
1410        ensure!(
1411            uin != 0 && previous.is_none_or(|s| s.uin == uin),
1412            "Auth UIN changed"
1413        );
1414        let (session, request, response) = business::account_keys(inner, auth)?;
1415        state.uin = uin;
1416        state.session_key = session;
1417        state.request_key = request;
1418        state.response_key = response;
1419        state.auto_auth_key = p::optional_data(&a, 4)?
1420            .map(p::unbuffer)
1421            .transpose()?
1422            .unwrap_or_default();
1423        if let Some(password) = p::optional_data(&a, 9)? {
1424            let password = p::unbuffer(password)?;
1425            ensure!(
1426                password.is_empty() || password.len() == 32,
1427                "Invalid native database password"
1428            );
1429            state.database_password.zeroize();
1430            state.database_password = password;
1431        }
1432        state.key_info = p::optional_data(&a, 10)?
1433            .map(p::unbuffer)
1434            .transpose()?
1435            .unwrap_or_default();
1436    } else {
1437        ensure!(previous.is_some(), "Initial auth has no key section");
1438    }
1439    ensure!(cookie.len() <= 15, "Invalid auth cookie");
1440    if !cookie.is_empty() {
1441        state.cookie = cookie.to_vec();
1442    }
1443    state.validate()?;
1444    Ok(state)
1445}
1446
1447fn apply_login_notification(
1448    attempt: &mut LoginAttempt,
1449    response: wire::PollResponse,
1450    start_lan: impl FnOnce(&mut LoginAttempt) -> Result<std::sync::Arc<crate::lan_login::Challenge>>,
1451) -> Result<LoginStatus> {
1452    if response.lan_challenge && matches!(response.status, Some(0..=2)) {
1453        // A risk challenge is not approval, including a notification carrying
1454        // the ordinary approval status without the final credentials yet.
1455        attempt.credential3.zeroize();
1456        attempt.credential4.zeroize();
1457        attempt.credential3 = None;
1458        attempt.credential4 = None;
1459        attempt.status = LoginStatus::Scanned;
1460        if let Some(challenge) = &attempt.lan_challenge {
1461            if challenge.active() {
1462                return Ok(attempt.status);
1463            }
1464            attempt.stop_lan_login();
1465            return Err(crate::lan_login::failure("listener_stopped"));
1466        }
1467        if attempt.lan_upload_attempted {
1468            return Err(crate::lan_login::failure("upload_already_attempted"));
1469        }
1470        // Record before submission. A lost upload response must not produce an
1471        // automatic new nonce/token exchange on the next repeatable QR poll.
1472        attempt.lan_upload_attempted = true;
1473        attempt.lan_challenge = Some(start_lan(attempt)?);
1474        return Ok(attempt.status);
1475    }
1476    if let Some(status) = response.status {
1477        attempt.status = match status {
1478            0 if attempt.status == LoginStatus::Scanned => LoginStatus::Scanned,
1479            0 => LoginStatus::Pending,
1480            1 => LoginStatus::Scanned,
1481            2 => LoginStatus::Approved,
1482            // Unknown terminal states never count as approval or local expiry.
1483            _ => LoginStatus::Rejected,
1484        };
1485        if attempt.status == LoginStatus::Approved {
1486            attempt.credential3 = response.credential3;
1487            attempt.credential4 = response.credential4;
1488        }
1489        if matches!(
1490            attempt.status,
1491            LoginStatus::Approved | LoginStatus::Rejected
1492        ) {
1493            attempt.stop_lan_login();
1494        }
1495    }
1496    if attempt
1497        .lan_challenge
1498        .as_ref()
1499        .is_some_and(|challenge| !challenge.active())
1500    {
1501        attempt.stop_lan_login();
1502        return Err(crate::lan_login::failure("listener_stopped"));
1503    }
1504    Ok(attempt.status)
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509    use super::*;
1510    fn attempt(status: LoginStatus, qr: bool) -> LoginAttempt {
1511        LoginAttempt {
1512            schema: 1,
1513            device: state().device.clone(),
1514            computer_name: "synthetic desktop".into(),
1515            qr_png: if qr { vec![1] } else { Vec::new() },
1516            width: 0,
1517            height: 0,
1518            created_at: 1000,
1519            expires_at: 1240,
1520            identifier: vec![1; 16],
1521            notify_key: vec![2; 16],
1522            cookie: vec![3; 15],
1523            key_version: 10014,
1524            long_host: "long.weixin.qq.com".into(),
1525            short_host: "short.weixin.qq.com".into(),
1526            status,
1527            key_info: b"persisted installation key-info".to_vec(),
1528            credential3: Some(vec![4; 16]),
1529            credential4: Some(vec![5; 16]),
1530            lan_upload_attempted: false,
1531            lan_challenge: None,
1532        }
1533    }
1534    #[test]
1535    fn final_approval_request_restores_key_info_for_both_saved_device_and_qr() {
1536        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1537        for qr in [false, true] {
1538            let attempt = attempt(LoginStatus::Approved, qr);
1539            let restored: LoginAttempt =
1540                serde_json::from_slice(&serde_json::to_vec(&attempt).unwrap()).unwrap();
1541            assert_eq!(restored.key_info, attempt.key_info);
1542            let request = p::decode(
1543                &restored
1544                    .manual_auth_request(&inner.public().unwrap())
1545                    .unwrap(),
1546            )
1547            .unwrap();
1548            let aes = p::decode(p::data(&request, 2).unwrap()).unwrap();
1549            let base = p::decode(p::data(&aes, 1).unwrap()).unwrap();
1550            // Retained official requests use1 for both approval paths.
1551            assert_eq!(p::scalar(&base, 6).unwrap(), 1);
1552            let auth = p::decode(p::data(&aes, 2).unwrap()).unwrap();
1553            assert_eq!(
1554                p::unbuffer(p::data(&auth, 5).unwrap()).unwrap(),
1555                attempt.key_info
1556            );
1557            assert!(!format!("{restored:?}").contains("persisted installation"));
1558        }
1559    }
1560    #[test]
1561    fn scanned_and_saved_device_approval_outlive_image_expiry_but_remain_bounded() {
1562        let qr = attempt(LoginStatus::Pending, true);
1563        assert!(qr.validate_at(1239).is_ok());
1564        assert!(qr.validate_at(1240).is_err());
1565        for (status, qr) in [
1566            (LoginStatus::Scanned, true),
1567            (LoginStatus::Approved, true),
1568            (LoginStatus::Pending, false),
1569        ] {
1570            let attempt = attempt(status, qr);
1571            assert!(attempt.validate_at(1240).is_ok());
1572            assert!(attempt.validate_at(1899).is_ok());
1573            assert!(attempt.validate_at(1900).is_err());
1574        }
1575    }
1576    fn notification(status: u32, lan_challenge: bool) -> wire::PollResponse {
1577        wire::PollResponse {
1578            status: Some(status),
1579            lan_challenge,
1580            credential3: (status == 2).then(|| vec![4; 16]),
1581            credential4: (status == 2).then(|| vec![5; 16]),
1582        }
1583    }
1584    fn synthetic_lan() -> std::sync::Arc<crate::lan_login::Challenge> {
1585        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1586        let challenge = crate::lan_login::Challenge::from_listener(
1587            listener,
1588            vec![std::net::Ipv4Addr::LOCALHOST],
1589            crate::lan_login::Scope::new(
1590                std::time::Instant::now() + Duration::from_secs(5),
1591                || false,
1592            ),
1593        )
1594        .unwrap();
1595        challenge.arm(b"synthetic temporary token").unwrap();
1596        std::sync::Arc::new(challenge)
1597    }
1598    #[test]
1599    fn repeated_risk_notifications_upload_once_and_never_install_approval() {
1600        let mut attempt = attempt(LoginStatus::Pending, true);
1601        let challenge = synthetic_lan();
1602        assert_eq!(
1603            apply_login_notification(&mut attempt, notification(2, true), |_| Ok(
1604                challenge.clone()
1605            ))
1606            .unwrap(),
1607            LoginStatus::Scanned
1608        );
1609        assert!(attempt.credential3.is_none() && attempt.credential4.is_none());
1610        assert_eq!(attempt.lan_login_state(), Some("waiting_for_phone"));
1611        for _ in 0..3 {
1612            assert_eq!(
1613                apply_login_notification(&mut attempt, notification(1, true), |_| panic!(
1614                    "duplicate upload"
1615                ))
1616                .unwrap(),
1617                LoginStatus::Scanned
1618            );
1619        }
1620        // A normal pending poll while the phone works retains the same listener.
1621        apply_login_notification(&mut attempt, notification(0, false), |_| {
1622            panic!("ordinary poll started LAN")
1623        })
1624        .unwrap();
1625        assert_eq!(attempt.status, LoginStatus::Scanned);
1626        assert!(challenge.active());
1627        let serialized = serde_json::to_string(&attempt).unwrap();
1628        assert!(!serialized.contains("synthetic temporary token"));
1629        assert!(!format!("{attempt:?}").contains("synthetic temporary token"));
1630        let restored: LoginAttempt = serde_json::from_str(&serialized).unwrap();
1631        assert!(restored.lan_login_state().is_none());
1632        assert!(restored.lan_upload_attempted); // No restart replay of an uncertain upload.
1633        assert_eq!(
1634            apply_login_notification(&mut attempt, notification(2, false), |_| panic!(
1635                "approval started LAN"
1636            ))
1637            .unwrap(),
1638            LoginStatus::Approved
1639        );
1640        assert!(attempt.lan_login_state().is_none());
1641        assert!(!challenge.active());
1642        assert!(attempt.credential3.is_some() && attempt.credential4.is_some());
1643    }
1644    #[test]
1645    fn failed_upload_is_not_retried_by_the_repeatable_poll_path() {
1646        let mut attempt = attempt(LoginStatus::Pending, true);
1647        let error = apply_login_notification(&mut attempt, notification(1, true), |_| {
1648            Err(crate::lan_login::failure("upload_failed"))
1649        })
1650        .unwrap_err();
1651        assert!(!is_retryable_login_poll_error(&error));
1652        assert_eq!(crate::login_lan_error_reason(&error), Some("upload_failed"));
1653        assert!(attempt.lan_login_state().is_none());
1654        let error = apply_login_notification(&mut attempt, notification(1, true), |_| {
1655            panic!("resubmitted upload")
1656        })
1657        .unwrap_err();
1658        assert_eq!(
1659            crate::login_lan_error_reason(&error),
1660            Some("upload_already_attempted")
1661        );
1662        assert!(attempt.credential3.is_none() && attempt.credential4.is_none());
1663    }
1664    #[test]
1665    fn rejection_or_attempt_drop_closes_the_shared_listener() {
1666        for reject in [false, true] {
1667            let mut attempt = attempt(LoginStatus::Pending, false);
1668            let challenge = synthetic_lan();
1669            apply_login_notification(&mut attempt, notification(1, true), |_| {
1670                Ok(challenge.clone())
1671            })
1672            .unwrap();
1673            if reject {
1674                assert_eq!(
1675                    apply_login_notification(&mut attempt, notification(3, true), |_| panic!(
1676                        "rejection started LAN"
1677                    ))
1678                    .unwrap(),
1679                    LoginStatus::Rejected
1680                );
1681            }
1682            drop(attempt);
1683            assert!(!challenge.active());
1684        }
1685    }
1686    #[test]
1687    fn ordinary_login_does_not_open_a_lan_listener() {
1688        let mut attempt = attempt(LoginStatus::Pending, true);
1689        for status in [0, 1, 2] {
1690            apply_login_notification(&mut attempt, notification(status, false), |_| {
1691                panic!("ordinary login opened a socket")
1692            })
1693            .unwrap();
1694            assert!(attempt.lan_login_state().is_none());
1695            assert!(!attempt.lan_upload_attempted);
1696        }
1697    }
1698    fn state() -> AccountState {
1699        AccountState {
1700            schema: 1,
1701            device: DeviceProfile {
1702                device_id: vec![1; 16],
1703                device_type: "UnifiedPCLinux synthetic x86_64".into(),
1704                client_version: -227271398,
1705            },
1706            uin: 123,
1707            own_username: "synthetic-user".into(),
1708            cookie: vec![5],
1709            session_key: vec![6; 16],
1710            request_key: vec![7; 24],
1711            response_key: vec![8; 24],
1712            auto_auth_key: vec![],
1713            key_info: vec![],
1714            database_password: vec![],
1715            auth_snapshot: AuthSnapshot::default(),
1716            long_host: "long.weixin.qq.com".into(),
1717            short_host: "short.weixin.qq.com".into(),
1718        }
1719    }
1720    #[test]
1721    fn challenge_poll_retries_only_transport_availability_failures() {
1722        use std::io::{Error, ErrorKind};
1723        for kind in [
1724            ErrorKind::TimedOut,
1725            ErrorKind::WouldBlock,
1726            ErrorKind::ConnectionReset,
1727            ErrorKind::UnexpectedEof,
1728        ] {
1729            assert!(is_retryable_login_poll_error(
1730                &anyhow::Error::new(Error::from(kind)).context("polling challenge")
1731            ));
1732        }
1733        for message in [
1734            "MMTLS deadline exceeded",
1735            "MMTLS connection closed",
1736            "Native shortlink HTTP status 503",
1737        ] {
1738            assert!(is_retryable_login_poll_error(&anyhow::anyhow!("{message}")));
1739        }
1740        for message in [
1741            "Login poll returned -13",
1742            "Native envelope returned -13",
1743            "Native shortlink HTTP status 403",
1744            "Native shortlink HTTP status 5030",
1745            "Missing authentication tag",
1746            "Unexpected MMTLS header",
1747            "Truncated structure",
1748        ] {
1749            assert!(!is_retryable_login_poll_error(&anyhow::anyhow!(
1750                "{message}"
1751            )));
1752        }
1753        assert!(!is_retryable_login_poll_error(
1754            &Error::from(ErrorKind::PermissionDenied).into()
1755        ));
1756    }
1757    #[test]
1758    fn secrets_not_debugged() {
1759        let s = state();
1760        let text = format!("{s:?}");
1761        assert!(!text.contains("synthetic-user"));
1762        assert!(!text.contains("123"));
1763        assert!(text.contains("redacted"));
1764    }
1765    #[test]
1766    fn account_state_roundtrips_without_number_loss() {
1767        let s = state();
1768        let json = serde_json::to_vec(&s).unwrap();
1769        let restored: AccountState = serde_json::from_slice(&json).unwrap();
1770        restored.validate().unwrap();
1771        assert_eq!(restored.uin, s.uin);
1772    }
1773    #[test]
1774    fn database_password_follows_auth_update_gate_and_optional_presence() {
1775        let mut old = state();
1776        old.database_password = vec![11; 32];
1777        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1778        let response = |mask, password: Option<&[u8]>| {
1779            let mut fields = vec![
1780                p::uint(1, old.uin as u64),
1781                p::bytes(3, &p::buffer(&[1; 16])),
1782                p::uint(19, 0),
1783            ];
1784            if let Some(bytes) = password {
1785                fields.push(p::bytes(9, &p::buffer(bytes)));
1786            }
1787            p::message(&[
1788                p::bytes(1, &p::uint(1, 0)),
1789                p::uint(2, mask),
1790                p::bytes(3, &p::message(&fields).unwrap()),
1791                p::bytes(6, b"synthetic-ax"),
1792            ])
1793            .unwrap()
1794        };
1795        let gated_off = response(0, Some(&[22; 32]));
1796        let unchanged = reduce_auth(&gated_off, Some(&old), &inner, &old.device, &[]).unwrap();
1797        assert_eq!(unchanged.database_password, old.database_password);
1798        assert_eq!(unchanged.auth_snapshot.update_mask, 0);
1799        assert_eq!(unchanged.auth_snapshot.response, gated_off);
1800        assert_eq!(unchanged.auth_snapshot.ax_auth, b"synthetic-ax");
1801        let accepted = reduce_auth(
1802            &response(1, Some(&[22; 32])),
1803            Some(&old),
1804            &inner,
1805            &old.device,
1806            &[],
1807        )
1808        .unwrap();
1809        assert_eq!(accepted.database_password, vec![22; 32]);
1810        let absent = reduce_auth(
1811            &response(1, None),
1812            Some(&accepted),
1813            &inner,
1814            &old.device,
1815            &[],
1816        )
1817        .unwrap();
1818        assert_eq!(absent.database_password, accepted.database_password);
1819        let empty = reduce_auth(
1820            &response(1, Some(&[])),
1821            Some(&absent),
1822            &inner,
1823            &old.device,
1824            &[],
1825        )
1826        .unwrap();
1827        assert!(empty.database_password.is_empty());
1828        assert!(reduce_auth(
1829            &response(1, Some(&[22; 31])),
1830            Some(&old),
1831            &inner,
1832            &old.device,
1833            &[]
1834        )
1835        .is_err());
1836        let mut legacy = serde_json::to_value(&old).unwrap();
1837        legacy.as_object_mut().unwrap().remove("database_password");
1838        legacy.as_object_mut().unwrap().remove("auth_snapshot");
1839        let restored: AccountState = serde_json::from_value(legacy).unwrap();
1840        restored.validate().unwrap();
1841        assert!(restored.database_password.is_empty());
1842    }
1843    #[test]
1844    fn refresh_identity_and_clear_tokens() {
1845        let old = state();
1846        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1847        let auth = p::message(&[
1848            p::uint(1, old.uin as u64),
1849            p::bytes(3, &p::buffer(&[1; 16])),
1850            p::uint(19, 0),
1851        ])
1852        .unwrap();
1853        let reply = p::message(&[
1854            p::bytes(1, &p::uint(1, 0)),
1855            p::uint(2, 1),
1856            p::bytes(3, &auth),
1857            p::bytes(4, &p::bytes(1, old.own_username.as_bytes())),
1858        ])
1859        .unwrap();
1860        let next = reduce_auth(&reply, Some(&old), &inner, &old.device, &[]).unwrap();
1861        assert!(next.auto_auth_key.is_empty());
1862        assert!(next.request_key.is_empty());
1863        let bad = p::message(&[
1864            p::bytes(1, &p::uint(1, 0)),
1865            p::uint(2, 0),
1866            p::bytes(4, &p::bytes(1, b"foreign")),
1867        ])
1868        .unwrap();
1869        assert!(reduce_auth(&bad, Some(&old), &inner, &old.device, &[]).is_err());
1870    }
1871}