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