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