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    /// Perform one experimental call host RPC using its fixed CGI/path pair.
924    /// The owner must first retain its intent, hold account mutation authority and
925    /// fence account/logout generations. This method does not retry uncertain
926    /// operations. An active Ax policy is unsupported here; there is no ordinary
927    /// packing fallback. Returned CommonResponse/application results still need
928    /// their own checks. Accepted cookie updates follow normal native transport.
929    pub fn experimental_call_rpc(
930        &self,
931        state: &mut AccountState,
932        route: crate::calls::host_policy::CallHostRoute,
933        request: &[u8],
934    ) -> Result<Vec<u8>> {
935        self.experimental_call_rpc_guarded(state, route, request, || Ok(()))
936    }
937
938    /// As [`Self::experimental_call_rpc`], with one final owner check after
939    /// handshake/preparation and before handing business bytes to transport.
940    /// A guard cannot retract bytes after transport handoff. Handshake traffic
941    /// can already have occurred when this guard stops the business request.
942    pub fn experimental_call_rpc_guarded(
943        &self,
944        state: &mut AccountState,
945        route: crate::calls::host_policy::CallHostRoute,
946        request: &[u8],
947        before_submit: impl FnOnce() -> Result<()>,
948    ) -> Result<Vec<u8>> {
949        use crate::calls::{
950            host_policy::{selected_policy, CallHostPolicy},
951            host_transport::{CallHostRpcError, CallHostStage},
952        };
953        mmtls::validate_short_route(route.path())
954            .map_err(|error| CallHostRpcError::from_cause(CallHostStage::Route, &error))?;
955        let policy = selected_policy(&state.auth_snapshot, route.command())
956            .map_err(|_| CallHostRpcError::new(CallHostStage::Route, "policy", None))?;
957        if policy != CallHostPolicy::Ordinary {
958            return Err(CallHostRpcError::new(CallHostStage::Route, "ax_unsupported", None).into());
959        }
960        let session = self
961            .session(&state.long_host)
962            .map_err(|error| CallHostRpcError::from_cause(CallHostStage::Handshake, &error))?;
963        let packet = AuthenticatedPacket::prepare(state, route.command(), request)
964            .map_err(|error| CallHostRpcError::from_cause(CallHostStage::Prepare, &error))?;
965        before_submit()?;
966        let reply = mmtls::short_exchange(
967            &session.short,
968            &state.short_host,
969            route.path(),
970            &packet.packet,
971            self.options.timeout,
972        )
973        .map_err(|error| CallHostRpcError::from_cause(CallHostStage::Exchange, &error))?;
974        let (plain, cookie) =
975            crate::calls::host_transport::decode_verified_reply(state, route.command(), &reply)?;
976        if !cookie.is_empty() {
977            state.cookie = cookie;
978        }
979        Ok(plain)
980    }
981
982    pub(crate) fn authenticated_rpc(
983        &self,
984        state: &mut AccountState,
985        cgi: u16,
986        path: &str,
987        request: &[u8],
988    ) -> Result<Vec<u8>> {
989        let session = self.session(&state.long_host)?;
990        let packet = AuthenticatedPacket::prepare(state, cgi, request)?;
991        let reply = mmtls::short_exchange(
992            &session.short,
993            &state.short_host,
994            path,
995            &packet.packet,
996            self.options.timeout,
997        )?;
998        let (plain, cookie) = packet.decode_verified_transport_reply(&reply)?;
999        if !cookie.is_empty() {
1000            state.cookie = cookie;
1001        }
1002        Ok(plain)
1003    }
1004    /// Fetch one scene-6 sync page. An empty cursor starts an initial sync.
1005    /// Returned commands include their original payloads. Persist and handle all
1006    /// required commands before committing the returned cursor; this call does
1007    /// not write history or manage a checkpoint for the application.
1008    pub fn sync(&self, state: &mut AccountState, cursor: &[u8]) -> Result<SyncBatch> {
1009        self.sync_scene(state, cursor, 6)
1010    }
1011    pub fn sync_scene(
1012        &self,
1013        state: &mut AccountState,
1014        cursor: &[u8],
1015        scene: u32,
1016    ) -> Result<SyncBatch> {
1017        ensure!([6, 7].contains(&scene), "Unsupported sync scene");
1018        let wire_cursor = if cursor.is_empty() { &[0][..] } else { cursor };
1019        let request = wire::sync_request(wire_cursor, &state.device.device_type, scene)?;
1020        wire::decode_sync(
1021            &self.authenticated_rpc(state, 138, "/cgi-bin/micromsg-bin/newsync", &request)?,
1022            cursor,
1023        )
1024    }
1025    /// Request heartbeat hints for a committed sync cursor. Hints are not message
1026    /// contents; the application still needs to fetch and handle sync commands.
1027    pub fn heartbeat(&self, state: &mut AccountState, cursor: &[u8]) -> Result<Heartbeat> {
1028        let request = wire::heartbeat_request(state, cursor)?;
1029        wire::decode_heartbeat(&self.authenticated_rpc(
1030            state,
1031            518,
1032            "/cgi-bin/micromsg-bin/heartbeat",
1033            &request,
1034        )?)
1035    }
1036    /// Submit exactly once. An I/O error after submission is an uncertain outcome;
1037    /// services must persist the intent and must not automatically retry this call.
1038    ///
1039    /// `destination` is an exact WeChat username, not a display name or daemon
1040    /// account ID. `created_at` is Unix seconds; `client_message_id` is a nonzero
1041    /// protocol ID derived with [`wire::client_message_id`] from the caller's task
1042    /// ID. Check acceptance and both returned-identity matches in the receipt.
1043    /// No official-profile history is written by this method.
1044    pub fn send_text(
1045        &self,
1046        state: &mut AccountState,
1047        destination: &str,
1048        content: &str,
1049        created_at: u32,
1050        client_message_id: u32,
1051    ) -> Result<TextSendResult> {
1052        self.send_text_with_source(
1053            state,
1054            destination,
1055            content,
1056            created_at,
1057            client_message_id,
1058            None,
1059        )
1060    }
1061    /// Submit text with optional native message-source XML. For a simple send use
1062    /// [`Self::send_text`]. This has the same single-submission/uncertainty contract.
1063    pub fn send_text_with_source(
1064        &self,
1065        state: &mut AccountState,
1066        destination: &str,
1067        content: &str,
1068        created_at: u32,
1069        client_message_id: u32,
1070        source: Option<&str>,
1071    ) -> Result<TextSendResult> {
1072        let request = wire::text_send_request_with_source(
1073            destination,
1074            content,
1075            created_at,
1076            client_message_id,
1077            source,
1078        )?;
1079        wire::decode_text_send(
1080            &self.authenticated_rpc(state, 522, "/cgi-bin/micromsg-bin/newsendmsg", &request)?,
1081            destination,
1082            client_message_id,
1083        )
1084    }
1085    /// Look up exact usernames on the server. The returned records are not
1086    /// automatically written into a local profile.
1087    pub fn lookup_contacts(
1088        &self,
1089        state: &mut AccountState,
1090        usernames: &[String],
1091    ) -> Result<ContactLookup> {
1092        let request = wire::contact_request(state, usernames)?;
1093        wire::decode_contacts(
1094            &self.authenticated_rpc(state, 182, "/cgi-bin/micromsg-bin/getcontact", &request)?,
1095            usernames,
1096        )
1097    }
1098    pub fn lookup_room(
1099        &self,
1100        state: &mut AccountState,
1101        room_id: &str,
1102        version: u32,
1103    ) -> Result<RoomRoster> {
1104        let request = wire::room_request(state, room_id, version)?;
1105        wire::decode_room(
1106            &self.authenticated_rpc(
1107                state,
1108                551,
1109                "/cgi-bin/micromsg-bin/getchatroommemberdetail",
1110                &request,
1111            )?,
1112            room_id,
1113        )
1114    }
1115    pub fn init_contacts(
1116        &self,
1117        state: &mut AccountState,
1118        contact_sequence: u32,
1119        room_sequence: u32,
1120    ) -> Result<InitContactPage> {
1121        let request =
1122            wire::init_contacts_request(&state.own_username, contact_sequence, room_sequence)?;
1123        let page = wire::decode_init_contacts(&self.authenticated_rpc(
1124            state,
1125            851,
1126            "/cgi-bin/micromsg-bin/initcontact",
1127            &request,
1128        )?)?;
1129        ensure!(
1130            !page.more
1131                || page.contact_sequence != contact_sequence
1132                || page.room_sequence != room_sequence,
1133            "Contact bootstrap made no cursor progress"
1134        );
1135        Ok(page)
1136    }
1137    pub fn download_avatars(
1138        &self,
1139        state: &mut AccountState,
1140        usernames: &[String],
1141    ) -> Result<Vec<AvatarBytes>> {
1142        let request = wire::avatar_request(state, usernames)?;
1143        wire::decode_avatars(
1144            &self.authenticated_rpc(
1145                state,
1146                123,
1147                "/cgi-bin/micromsg-bin/batchgetheadimg",
1148                &request,
1149            )?,
1150            usernames,
1151        )
1152    }
1153    pub fn download_voice_chunk(
1154        &self,
1155        state: &mut AccountState,
1156        server_message_id: u64,
1157        total_length: u32,
1158        offset: u32,
1159    ) -> Result<VoiceChunk> {
1160        let request = wire::voice_request(state, server_message_id, total_length, offset)?;
1161        wire::decode_voice(
1162            &self.authenticated_rpc(state, 128, "/cgi-bin/micromsg-bin/downloadvoice", &request)?,
1163            server_message_id,
1164            total_length,
1165            offset,
1166        )
1167    }
1168    pub fn cdn_config(&self, state: &mut AccountState) -> Result<crate::media::CdnConfig> {
1169        let request = p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?);
1170        let reply =
1171            self.authenticated_rpc(state, 379, "/cgi-bin/micromsg-bin/getcdndns", &request)?;
1172        crate::media::decode_cdn_config(&reply, state)
1173    }
1174    pub fn large_cdn_config(
1175        &self,
1176        state: &mut AccountState,
1177        route: crate::media::LargeCdnRoute,
1178    ) -> Result<crate::media::CdnConfig> {
1179        let request = p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?);
1180        let reply =
1181            self.authenticated_rpc(state, 379, "/cgi-bin/micromsg-bin/getcdndns", &request)?;
1182        crate::media::decode_large_cdn_config(&reply, state, route)
1183    }
1184    /// Submit a URL card once through the app-message carrier. The caller owns
1185    /// the durable intent and must inspect an uncertain submission outcome.
1186    pub fn send_share(
1187        &self,
1188        state: &mut AccountState,
1189        destination: &str,
1190        client_id: &str,
1191        created_at: u32,
1192        content: &str,
1193    ) -> Result<crate::media_send::MediaSendResult> {
1194        let request = crate::media_send::share_send_request(
1195            state,
1196            destination,
1197            client_id,
1198            created_at,
1199            content,
1200        )?;
1201        crate::media_send::decode_file_send(
1202            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1203            client_id,
1204        )
1205    }
1206    /// Submit a quoted reply once. The caller owns the persisted XML, intent and
1207    /// uncertain-submission handling, as for other app-message sends.
1208    pub fn send_quote(
1209        &self,
1210        state: &mut AccountState,
1211        destination: &str,
1212        client_id: &str,
1213        created_at: u32,
1214        content: &str,
1215    ) -> Result<crate::media_send::MediaSendResult> {
1216        self.send_quote_with_source(state, destination, client_id, created_at, content, None)
1217    }
1218    pub fn send_quote_with_source(
1219        &self,
1220        state: &mut AccountState,
1221        destination: &str,
1222        client_id: &str,
1223        created_at: u32,
1224        content: &str,
1225        source: Option<&str>,
1226    ) -> Result<crate::media_send::MediaSendResult> {
1227        let request = crate::quote_send::quote_send_request_with_source(
1228            state,
1229            destination,
1230            client_id,
1231            created_at,
1232            content,
1233            source,
1234        )?;
1235        crate::media_send::decode_file_send(
1236            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1237            client_id,
1238        )
1239    }
1240    /// Reserve one file-upload message. An uncertain response must not be
1241    /// retried automatically; retain the returned receipt with the send intent.
1242    pub fn reserve_file_upload(
1243        &self,
1244        state: &mut AccountState,
1245        destination: &str,
1246        progress_client_id: &str,
1247        file_name: &str,
1248        plaintext_bytes: usize,
1249        md5: &str,
1250    ) -> Result<crate::file_upload::FileUploadReservation> {
1251        let request = crate::file_upload::file_upload_request(
1252            state,
1253            destination,
1254            progress_client_id,
1255            file_name,
1256            plaintext_bytes,
1257            md5,
1258        )?;
1259        crate::file_upload::decode_file_upload_reservation(&self.authenticated_rpc(
1260            state,
1261            6691,
1262            "/cgi-bin/micromsg-bin/sendfileuploadmsg",
1263            &request,
1264        )?)
1265    }
1266    pub fn send_file(
1267        &self,
1268        state: &mut AccountState,
1269        destination: &str,
1270        client_id: &str,
1271        created_at: u32,
1272        upload: &crate::media::UploadedMedia,
1273    ) -> Result<crate::media_send::MediaSendResult> {
1274        let request = crate::media_send::file_send_request(
1275            state,
1276            destination,
1277            client_id,
1278            created_at,
1279            upload,
1280        )?;
1281        crate::media_send::decode_file_send(
1282            &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1283            client_id,
1284        )
1285    }
1286    #[allow(clippy::too_many_arguments)]
1287    pub fn send_file_with_reservation(
1288        &self,
1289        state: &mut AccountState,
1290        destination: &str,
1291        client_id: &str,
1292        created_at: u32,
1293        upload: &crate::media::UploadedMedia,
1294        reservation: Option<&crate::file_upload::FileUploadReservation>,
1295    ) -> Result<crate::media_send::MediaSendResult> {
1296        let request = crate::media_send::file_send_request_with_reservation(
1297            state,
1298            destination,
1299            client_id,
1300            created_at,
1301            upload,
1302            reservation,
1303        )?;
1304        let reply =
1305            self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?;
1306        if reservation.is_some() {
1307            crate::media_send::decode_file_send_correlated(
1308                &reply,
1309                client_id,
1310                &state.own_username,
1311                destination,
1312                &upload.descriptor.aes_key,
1313            )
1314        } else {
1315            crate::media_send::decode_file_send(&reply, client_id)
1316        }
1317    }
1318    pub fn send_image(
1319        &self,
1320        state: &mut AccountState,
1321        destination: &str,
1322        client_id: &str,
1323        upload: &crate::media::UploadedImage,
1324    ) -> Result<crate::media_send::MediaSendResult> {
1325        let request = crate::media_send::image_send_request(state, destination, client_id, upload)?;
1326        crate::media_send::decode_image_send(
1327            &self.authenticated_rpc(state, 110, "/cgi-bin/micromsg-bin/uploadmsgimg", &request)?,
1328            client_id,
1329            &state.own_username,
1330            destination,
1331        )
1332    }
1333    pub fn send_video(
1334        &self,
1335        state: &mut AccountState,
1336        destination: &str,
1337        client_id: &str,
1338        upload: &crate::media::UploadedVideo,
1339        play_seconds: u32,
1340    ) -> Result<crate::media_send::MediaSendResult> {
1341        let request = crate::media_send::video_send_request(
1342            state,
1343            destination,
1344            client_id,
1345            upload,
1346            play_seconds,
1347        )?;
1348        crate::media_send::decode_video_send(
1349            &self.authenticated_rpc(state, 149, "/cgi-bin/micromsg-bin/uploadvideo", &request)?,
1350            client_id,
1351        )
1352    }
1353    /// Submit one voice note, as consecutive byte ranges when it exceeds one
1354    /// request. Each range is sent once; an error after the first submission is
1355    /// an uncertain outcome that the caller must not retry automatically.
1356    pub fn send_voice(
1357        &self,
1358        state: &mut AccountState,
1359        destination: &str,
1360        client_id: &str,
1361        created_at: u32,
1362        duration_ms: u32,
1363        encoded: &[u8],
1364    ) -> Result<crate::media_send::MediaSendResult> {
1365        let mut upload = crate::media_send::VoiceUpload::new(
1366            destination,
1367            client_id,
1368            created_at,
1369            duration_ms,
1370            encoded,
1371        )?;
1372        loop {
1373            let request = upload.request(state)?;
1374            let reply =
1375                self.authenticated_rpc(state, 127, "/cgi-bin/micromsg-bin/uploadvoice", &request)?;
1376            if let Some(result) = upload.accept_reply(&reply)? {
1377                return Ok(result);
1378            }
1379        }
1380    }
1381}
1382
1383fn redirect(fields: &[p::Field]) -> Result<(String, String)> {
1384    let network = p::decode(p::data(fields, 5)?)?;
1385    let hosts = p::decode(p::data(&network, 1)?)?;
1386    let target = |source: &[u8]| -> Result<String> {
1387        let mut out = None;
1388        for f in hosts.iter().filter(|f| f.number == 2) {
1389            let p::Value::Bytes(raw) = &f.value else {
1390                bail!("Invalid redirect map");
1391            };
1392            let item = p::decode(raw)?;
1393            if p::data(&item, 1)? == source {
1394                ensure!(out.is_none(), "Ambiguous redirect");
1395                let name = std::str::from_utf8(p::data(&item, 2)?)?.to_string();
1396                mmtls::validate_host(&name)?;
1397                out = Some(name);
1398            }
1399        }
1400        out.ok_or_else(|| anyhow::anyhow!("Missing redirect mapping"))
1401    };
1402    Ok((
1403        target(b"long.weixin.qq.com")?,
1404        target(b"short.weixin.qq.com")?,
1405    ))
1406}
1407fn reduce_auth(
1408    plain: &[u8],
1409    previous: Option<&AccountState>,
1410    inner: &Ecdh,
1411    device: &DeviceProfile,
1412    cookie: &[u8],
1413) -> Result<AccountState> {
1414    let f = p::decode(plain)?;
1415    ensure!(p::base_code(&f)? == 0, "Rejected auth response");
1416    let mask = u32::try_from(p::scalar(&f, 2)?)?;
1417    for (bit, n) in [(1, 3), (2, 4), (4, 5)] {
1418        if mask & bit != 0 {
1419            p::data(&f, n)?;
1420        }
1421    }
1422    let mut state = previous.cloned().unwrap_or_else(|| AccountState {
1423        schema: 1,
1424        device: device.clone(),
1425        uin: 0,
1426        own_username: String::new(),
1427        cookie: vec![],
1428        session_key: vec![],
1429        request_key: vec![],
1430        response_key: vec![],
1431        auto_auth_key: vec![],
1432        key_info: vec![],
1433        database_password: vec![],
1434        auth_snapshot: AuthSnapshot::default(),
1435        long_host: "long.weixin.qq.com".into(),
1436        short_host: "short.weixin.qq.com".into(),
1437    });
1438    ensure!(
1439        plain.len() <= 1024 * 1024,
1440        "Auth response snapshot too large"
1441    );
1442    state.auth_snapshot.update_mask = mask;
1443    state.auth_snapshot.response = plain.to_vec();
1444    for (number, target) in [
1445        (3, &mut state.auth_snapshot.auth),
1446        (4, &mut state.auth_snapshot.account),
1447        (5, &mut state.auth_snapshot.network),
1448        (6, &mut state.auth_snapshot.ax_auth),
1449    ] {
1450        if let Some(section) = p::optional_data(&f, number)? {
1451            *target = section.to_vec();
1452        }
1453    }
1454    if let Some(account) = p::optional_data(&f, 4)? {
1455        let a = p::decode(account)?;
1456        if let Some(name) = p::optional_data(&a, 1)? {
1457            let name = std::str::from_utf8(name)?;
1458            ensure!(
1459                previous.is_none_or(|s| s.own_username == name),
1460                "Auth account identity changed"
1461            );
1462            state.own_username = name.to_string();
1463        }
1464    }
1465    if mask & 1 != 0 {
1466        let auth = p::data(&f, 3)?;
1467        let a = p::decode(auth)?;
1468        let uin = u32::try_from(p::scalar(&a, 1)?)?;
1469        ensure!(
1470            uin != 0 && previous.is_none_or(|s| s.uin == uin),
1471            "Auth UIN changed"
1472        );
1473        let (session, request, response) = business::account_keys(inner, auth)?;
1474        state.uin = uin;
1475        state.session_key = session;
1476        state.request_key = request;
1477        state.response_key = response;
1478        state.auto_auth_key = p::optional_data(&a, 4)?
1479            .map(p::unbuffer)
1480            .transpose()?
1481            .unwrap_or_default();
1482        if let Some(password) = p::optional_data(&a, 9)? {
1483            let password = p::unbuffer(password)?;
1484            ensure!(
1485                password.is_empty() || password.len() == 32,
1486                "Invalid native database password"
1487            );
1488            state.database_password.zeroize();
1489            state.database_password = password;
1490        }
1491        state.key_info = p::optional_data(&a, 10)?
1492            .map(p::unbuffer)
1493            .transpose()?
1494            .unwrap_or_default();
1495    } else {
1496        ensure!(previous.is_some(), "Initial auth has no key section");
1497    }
1498    ensure!(cookie.len() <= 15, "Invalid auth cookie");
1499    if !cookie.is_empty() {
1500        state.cookie = cookie.to_vec();
1501    }
1502    state.validate()?;
1503    Ok(state)
1504}
1505
1506fn apply_login_notification(
1507    attempt: &mut LoginAttempt,
1508    response: wire::PollResponse,
1509    start_lan: impl FnOnce(&mut LoginAttempt) -> Result<std::sync::Arc<crate::lan_login::Challenge>>,
1510) -> Result<LoginStatus> {
1511    if response.lan_challenge && matches!(response.status, Some(0..=2)) {
1512        // A risk challenge is not approval, including a notification carrying
1513        // the ordinary approval status without the final credentials yet.
1514        attempt.credential3.zeroize();
1515        attempt.credential4.zeroize();
1516        attempt.credential3 = None;
1517        attempt.credential4 = None;
1518        attempt.status = LoginStatus::Scanned;
1519        if let Some(challenge) = &attempt.lan_challenge {
1520            if challenge.active() {
1521                return Ok(attempt.status);
1522            }
1523            attempt.stop_lan_login();
1524            return Err(crate::lan_login::failure("listener_stopped"));
1525        }
1526        if attempt.lan_upload_attempted {
1527            return Err(crate::lan_login::failure("upload_already_attempted"));
1528        }
1529        // Record before submission. A lost upload response must not produce an
1530        // automatic new nonce/token exchange on the next repeatable QR poll.
1531        attempt.lan_upload_attempted = true;
1532        attempt.lan_challenge = Some(start_lan(attempt)?);
1533        return Ok(attempt.status);
1534    }
1535    if let Some(status) = response.status {
1536        attempt.status = match status {
1537            0 if attempt.status == LoginStatus::Scanned => LoginStatus::Scanned,
1538            0 => LoginStatus::Pending,
1539            1 => LoginStatus::Scanned,
1540            2 => LoginStatus::Approved,
1541            // Unknown terminal states never count as approval or local expiry.
1542            _ => LoginStatus::Rejected,
1543        };
1544        if attempt.status == LoginStatus::Approved {
1545            attempt.credential3 = response.credential3;
1546            attempt.credential4 = response.credential4;
1547        }
1548        if matches!(
1549            attempt.status,
1550            LoginStatus::Approved | LoginStatus::Rejected
1551        ) {
1552            attempt.stop_lan_login();
1553        }
1554    }
1555    if attempt
1556        .lan_challenge
1557        .as_ref()
1558        .is_some_and(|challenge| !challenge.active())
1559    {
1560        attempt.stop_lan_login();
1561        return Err(crate::lan_login::failure("listener_stopped"));
1562    }
1563    Ok(attempt.status)
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568    use super::*;
1569    fn attempt(status: LoginStatus, qr: bool) -> LoginAttempt {
1570        LoginAttempt {
1571            schema: 1,
1572            device: state().device.clone(),
1573            computer_name: "synthetic desktop".into(),
1574            qr_png: if qr { vec![1] } else { Vec::new() },
1575            width: 0,
1576            height: 0,
1577            created_at: 1000,
1578            expires_at: 1240,
1579            identifier: vec![1; 16],
1580            notify_key: vec![2; 16],
1581            cookie: vec![3; 15],
1582            key_version: 10014,
1583            long_host: "long.weixin.qq.com".into(),
1584            short_host: "short.weixin.qq.com".into(),
1585            status,
1586            key_info: b"persisted installation key-info".to_vec(),
1587            credential3: Some(vec![4; 16]),
1588            credential4: Some(vec![5; 16]),
1589            lan_upload_attempted: false,
1590            lan_challenge: None,
1591        }
1592    }
1593    #[test]
1594    fn final_approval_request_restores_key_info_for_both_saved_device_and_qr() {
1595        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1596        for qr in [false, true] {
1597            let attempt = attempt(LoginStatus::Approved, qr);
1598            let restored: LoginAttempt =
1599                serde_json::from_slice(&serde_json::to_vec(&attempt).unwrap()).unwrap();
1600            assert_eq!(restored.key_info, attempt.key_info);
1601            let request = p::decode(
1602                &restored
1603                    .manual_auth_request(&inner.public().unwrap())
1604                    .unwrap(),
1605            )
1606            .unwrap();
1607            let aes = p::decode(p::data(&request, 2).unwrap()).unwrap();
1608            let base = p::decode(p::data(&aes, 1).unwrap()).unwrap();
1609            // Retained official requests use1 for both approval paths.
1610            assert_eq!(p::scalar(&base, 6).unwrap(), 1);
1611            let auth = p::decode(p::data(&aes, 2).unwrap()).unwrap();
1612            assert_eq!(
1613                p::unbuffer(p::data(&auth, 5).unwrap()).unwrap(),
1614                attempt.key_info
1615            );
1616            assert!(!format!("{restored:?}").contains("persisted installation"));
1617        }
1618    }
1619    #[test]
1620    fn scanned_and_saved_device_approval_outlive_image_expiry_but_remain_bounded() {
1621        let qr = attempt(LoginStatus::Pending, true);
1622        assert!(qr.validate_at(1239).is_ok());
1623        assert!(qr.validate_at(1240).is_err());
1624        for (status, qr) in [
1625            (LoginStatus::Scanned, true),
1626            (LoginStatus::Approved, true),
1627            (LoginStatus::Pending, false),
1628        ] {
1629            let attempt = attempt(status, qr);
1630            assert!(attempt.validate_at(1240).is_ok());
1631            assert!(attempt.validate_at(1899).is_ok());
1632            assert!(attempt.validate_at(1900).is_err());
1633        }
1634    }
1635    fn notification(status: u32, lan_challenge: bool) -> wire::PollResponse {
1636        wire::PollResponse {
1637            status: Some(status),
1638            lan_challenge,
1639            credential3: (status == 2).then(|| vec![4; 16]),
1640            credential4: (status == 2).then(|| vec![5; 16]),
1641        }
1642    }
1643    fn synthetic_lan() -> std::sync::Arc<crate::lan_login::Challenge> {
1644        let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1645        let challenge = crate::lan_login::Challenge::from_listener(
1646            listener,
1647            vec![std::net::Ipv4Addr::LOCALHOST],
1648            crate::lan_login::Scope::new(
1649                std::time::Instant::now() + Duration::from_secs(5),
1650                || false,
1651            ),
1652        )
1653        .unwrap();
1654        challenge.arm(b"synthetic temporary token").unwrap();
1655        std::sync::Arc::new(challenge)
1656    }
1657    #[test]
1658    fn repeated_risk_notifications_upload_once_and_never_install_approval() {
1659        let mut attempt = attempt(LoginStatus::Pending, true);
1660        let challenge = synthetic_lan();
1661        assert_eq!(
1662            apply_login_notification(&mut attempt, notification(2, true), |_| Ok(
1663                challenge.clone()
1664            ))
1665            .unwrap(),
1666            LoginStatus::Scanned
1667        );
1668        assert!(attempt.credential3.is_none() && attempt.credential4.is_none());
1669        assert_eq!(attempt.lan_login_state(), Some("waiting_for_phone"));
1670        for _ in 0..3 {
1671            assert_eq!(
1672                apply_login_notification(&mut attempt, notification(1, true), |_| panic!(
1673                    "duplicate upload"
1674                ))
1675                .unwrap(),
1676                LoginStatus::Scanned
1677            );
1678        }
1679        // A normal pending poll while the phone works retains the same listener.
1680        apply_login_notification(&mut attempt, notification(0, false), |_| {
1681            panic!("ordinary poll started LAN")
1682        })
1683        .unwrap();
1684        assert_eq!(attempt.status, LoginStatus::Scanned);
1685        assert!(challenge.active());
1686        let serialized = serde_json::to_string(&attempt).unwrap();
1687        assert!(!serialized.contains("synthetic temporary token"));
1688        assert!(!format!("{attempt:?}").contains("synthetic temporary token"));
1689        let restored: LoginAttempt = serde_json::from_str(&serialized).unwrap();
1690        assert!(restored.lan_login_state().is_none());
1691        assert!(restored.lan_upload_attempted); // No restart replay of an uncertain upload.
1692        assert_eq!(
1693            apply_login_notification(&mut attempt, notification(2, false), |_| panic!(
1694                "approval started LAN"
1695            ))
1696            .unwrap(),
1697            LoginStatus::Approved
1698        );
1699        assert!(attempt.lan_login_state().is_none());
1700        assert!(!challenge.active());
1701        assert!(attempt.credential3.is_some() && attempt.credential4.is_some());
1702    }
1703    #[test]
1704    fn failed_upload_is_not_retried_by_the_repeatable_poll_path() {
1705        let mut attempt = attempt(LoginStatus::Pending, true);
1706        let error = apply_login_notification(&mut attempt, notification(1, true), |_| {
1707            Err(crate::lan_login::failure("upload_failed"))
1708        })
1709        .unwrap_err();
1710        assert!(!is_retryable_login_poll_error(&error));
1711        assert_eq!(crate::login_lan_error_reason(&error), Some("upload_failed"));
1712        assert!(attempt.lan_login_state().is_none());
1713        let error = apply_login_notification(&mut attempt, notification(1, true), |_| {
1714            panic!("resubmitted upload")
1715        })
1716        .unwrap_err();
1717        assert_eq!(
1718            crate::login_lan_error_reason(&error),
1719            Some("upload_already_attempted")
1720        );
1721        assert!(attempt.credential3.is_none() && attempt.credential4.is_none());
1722    }
1723    #[test]
1724    fn rejection_or_attempt_drop_closes_the_shared_listener() {
1725        for reject in [false, true] {
1726            let mut attempt = attempt(LoginStatus::Pending, false);
1727            let challenge = synthetic_lan();
1728            apply_login_notification(&mut attempt, notification(1, true), |_| {
1729                Ok(challenge.clone())
1730            })
1731            .unwrap();
1732            if reject {
1733                assert_eq!(
1734                    apply_login_notification(&mut attempt, notification(3, true), |_| panic!(
1735                        "rejection started LAN"
1736                    ))
1737                    .unwrap(),
1738                    LoginStatus::Rejected
1739                );
1740            }
1741            drop(attempt);
1742            assert!(!challenge.active());
1743        }
1744    }
1745    #[test]
1746    fn ordinary_login_does_not_open_a_lan_listener() {
1747        let mut attempt = attempt(LoginStatus::Pending, true);
1748        for status in [0, 1, 2] {
1749            apply_login_notification(&mut attempt, notification(status, false), |_| {
1750                panic!("ordinary login opened a socket")
1751            })
1752            .unwrap();
1753            assert!(attempt.lan_login_state().is_none());
1754            assert!(!attempt.lan_upload_attempted);
1755        }
1756    }
1757    fn state() -> AccountState {
1758        AccountState {
1759            schema: 1,
1760            device: DeviceProfile {
1761                device_id: vec![1; 16],
1762                device_type: "UnifiedPCLinux synthetic x86_64".into(),
1763                client_version: -227271398,
1764            },
1765            uin: 123,
1766            own_username: "synthetic-user".into(),
1767            cookie: vec![5],
1768            session_key: vec![6; 16],
1769            request_key: vec![7; 24],
1770            response_key: vec![8; 24],
1771            auto_auth_key: vec![],
1772            key_info: vec![],
1773            database_password: vec![],
1774            auth_snapshot: AuthSnapshot::default(),
1775            long_host: "long.weixin.qq.com".into(),
1776            short_host: "short.weixin.qq.com".into(),
1777        }
1778    }
1779    #[test]
1780    fn challenge_poll_retries_only_transport_availability_failures() {
1781        use std::io::{Error, ErrorKind};
1782        for kind in [
1783            ErrorKind::TimedOut,
1784            ErrorKind::WouldBlock,
1785            ErrorKind::ConnectionReset,
1786            ErrorKind::UnexpectedEof,
1787        ] {
1788            assert!(is_retryable_login_poll_error(
1789                &anyhow::Error::new(Error::from(kind)).context("polling challenge")
1790            ));
1791        }
1792        for message in [
1793            "MMTLS deadline exceeded",
1794            "MMTLS connection closed",
1795            "Native shortlink HTTP status 503",
1796        ] {
1797            assert!(is_retryable_login_poll_error(&anyhow::anyhow!("{message}")));
1798        }
1799        for message in [
1800            "Login poll returned -13",
1801            "Native envelope returned -13",
1802            "Native shortlink HTTP status 403",
1803            "Native shortlink HTTP status 5030",
1804            "Missing authentication tag",
1805            "Unexpected MMTLS header",
1806            "Truncated structure",
1807        ] {
1808            assert!(!is_retryable_login_poll_error(&anyhow::anyhow!(
1809                "{message}"
1810            )));
1811        }
1812        assert!(!is_retryable_login_poll_error(
1813            &Error::from(ErrorKind::PermissionDenied).into()
1814        ));
1815    }
1816    #[test]
1817    fn secrets_not_debugged() {
1818        let s = state();
1819        let text = format!("{s:?}");
1820        assert!(!text.contains("synthetic-user"));
1821        assert!(!text.contains("123"));
1822        assert!(text.contains("redacted"));
1823    }
1824    #[test]
1825    fn account_state_roundtrips_without_number_loss() {
1826        let s = state();
1827        let json = serde_json::to_vec(&s).unwrap();
1828        let restored: AccountState = serde_json::from_slice(&json).unwrap();
1829        restored.validate().unwrap();
1830        assert_eq!(restored.uin, s.uin);
1831    }
1832    #[test]
1833    fn database_password_follows_auth_update_gate_and_optional_presence() {
1834        let mut old = state();
1835        old.database_password = vec![11; 32];
1836        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1837        let response = |mask, password: Option<&[u8]>| {
1838            let mut fields = vec![
1839                p::uint(1, old.uin as u64),
1840                p::bytes(3, &p::buffer(&[1; 16])),
1841                p::uint(19, 0),
1842            ];
1843            if let Some(bytes) = password {
1844                fields.push(p::bytes(9, &p::buffer(bytes)));
1845            }
1846            p::message(&[
1847                p::bytes(1, &p::uint(1, 0)),
1848                p::uint(2, mask),
1849                p::bytes(3, &p::message(&fields).unwrap()),
1850                p::bytes(6, b"synthetic-ax"),
1851            ])
1852            .unwrap()
1853        };
1854        let gated_off = response(0, Some(&[22; 32]));
1855        let unchanged = reduce_auth(&gated_off, Some(&old), &inner, &old.device, &[]).unwrap();
1856        assert_eq!(unchanged.database_password, old.database_password);
1857        assert_eq!(unchanged.auth_snapshot.update_mask, 0);
1858        assert_eq!(unchanged.auth_snapshot.response, gated_off);
1859        assert_eq!(unchanged.auth_snapshot.ax_auth, b"synthetic-ax");
1860        let accepted = reduce_auth(
1861            &response(1, Some(&[22; 32])),
1862            Some(&old),
1863            &inner,
1864            &old.device,
1865            &[],
1866        )
1867        .unwrap();
1868        assert_eq!(accepted.database_password, vec![22; 32]);
1869        let absent = reduce_auth(
1870            &response(1, None),
1871            Some(&accepted),
1872            &inner,
1873            &old.device,
1874            &[],
1875        )
1876        .unwrap();
1877        assert_eq!(absent.database_password, accepted.database_password);
1878        let empty = reduce_auth(
1879            &response(1, Some(&[])),
1880            Some(&absent),
1881            &inner,
1882            &old.device,
1883            &[],
1884        )
1885        .unwrap();
1886        assert!(empty.database_password.is_empty());
1887        assert!(reduce_auth(
1888            &response(1, Some(&[22; 31])),
1889            Some(&old),
1890            &inner,
1891            &old.device,
1892            &[]
1893        )
1894        .is_err());
1895        let mut legacy = serde_json::to_value(&old).unwrap();
1896        legacy.as_object_mut().unwrap().remove("database_password");
1897        legacy.as_object_mut().unwrap().remove("auth_snapshot");
1898        let restored: AccountState = serde_json::from_value(legacy).unwrap();
1899        restored.validate().unwrap();
1900        assert!(restored.database_password.is_empty());
1901    }
1902    #[test]
1903    fn refresh_identity_and_clear_tokens() {
1904        let old = state();
1905        let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1906        let auth = p::message(&[
1907            p::uint(1, old.uin as u64),
1908            p::bytes(3, &p::buffer(&[1; 16])),
1909            p::uint(19, 0),
1910        ])
1911        .unwrap();
1912        let reply = p::message(&[
1913            p::bytes(1, &p::uint(1, 0)),
1914            p::uint(2, 1),
1915            p::bytes(3, &auth),
1916            p::bytes(4, &p::bytes(1, old.own_username.as_bytes())),
1917        ])
1918        .unwrap();
1919        let next = reduce_auth(&reply, Some(&old), &inner, &old.device, &[]).unwrap();
1920        assert!(next.auto_auth_key.is_empty());
1921        assert!(next.request_key.is_empty());
1922        let bad = p::message(&[
1923            p::bytes(1, &p::uint(1, 0)),
1924            p::uint(2, 0),
1925            p::bytes(4, &p::bytes(1, b"foreign")),
1926        ])
1927        .unwrap();
1928        assert!(reduce_auth(&bad, Some(&old), &inner, &old.device, &[]).is_err());
1929    }
1930}