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#[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#[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 #[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 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 #[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 let hard_deadline = self.created_at.saturating_add(900);
390 let deadline = if self.qr_png.is_empty()
391 || matches!(self.status, LoginStatus::Scanned | LoginStatus::Approved)
392 {
393 hard_deadline
394 } else {
395 self.expires_at.min(hard_deadline)
396 };
397 now >= deadline
398 }
399 fn validate_at(&self, now: u32) -> Result<()> {
400 ensure!(
401 self.schema == 1 && !self.locally_expired(now),
402 "Login attempt expired"
403 );
404 self.device.validate()?;
405 ensure!(
406 !self.identifier.is_empty()
407 && self.identifier.len() <= 4096
408 && !self.notify_key.is_empty()
409 && self.notify_key.len() <= 1024
410 && self.cookie.len() <= 15
411 && self.key_info.len() <= 1024 * 1024,
412 "Invalid login attempt"
413 );
414 mmtls::validate_host(&self.long_host)?;
415 mmtls::validate_host(&self.short_host)?;
416 ensure!(
417 [10014, 50005].contains(&self.key_version),
418 "Unsupported login key version"
419 );
420 Ok(())
421 }
422 fn manual_auth_request(&self, point: &[u8]) -> Result<Vec<u8>> {
423 wire::manual_auth(
424 &self.device,
425 &self.computer_name,
426 self.credential3
427 .as_deref()
428 .ok_or_else(|| anyhow::anyhow!("Missing approval"))?,
429 self.credential4
430 .as_deref()
431 .ok_or_else(|| anyhow::anyhow!("Missing approval"))?,
432 point,
433 &self.key_info,
434 )
435 }
436}
437#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
438#[serde(rename_all = "snake_case")]
439pub enum LoginStatus {
440 Pending,
441 Scanned,
442 Approved,
443 Expired,
444 Cancelled,
445 Rejected,
446 Completed,
447}
448#[derive(Clone, Debug)]
449pub struct NativeOptions {
450 pub long_host: String,
451 pub short_host: String,
452 pub timeout: Duration,
453 pub receive_budget: usize,
454}
455impl Default for NativeOptions {
456 fn default() -> Self {
457 Self {
458 long_host: "long.weixin.qq.com".to_string(),
459 short_host: "short.weixin.qq.com".to_string(),
460 timeout: Duration::from_secs(30),
461 receive_budget: crate::MAX_MESSAGE_BYTES,
462 }
463 }
464}
465pub struct NativeClient {
472 options: NativeOptions,
473}
474pub 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 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 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 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 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 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 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 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 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 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 pub fn renew_session(
880 &self,
881 previous: &AccountState,
882 computer_name: &str,
883 ) -> Result<AccountState> {
884 previous.validate()?;
885 let inner = Ecdh::generate(Nid::SECP224R1)?;
886 let request = wire::auto_auth(previous, &inner.public()?, computer_name)?;
887 let mut long = previous.long_host.clone();
888 let mut short = previous.short_host.clone();
889 let mut cookie = previous.cookie.clone();
890 for redirected in 0..=1 {
891 let (reply, next_cookie, _) = self.prelogin_rpc(
892 &long,
893 &short,
894 763,
895 "/cgi-bin/micromsg-bin/secautoauth",
896 &request,
897 &cookie,
898 &previous.auto_auth_key,
899 None,
900 )?;
901 let fields = p::decode(&reply)?;
902 let code = p::base_code(&fields)?;
903 if code == -301 && redirected == 0 {
904 (long, short) = redirect(&fields)?;
905 cookie = next_cookie;
906 continue;
907 }
908 ensure!(code == 0, "Automatic authentication returned {code}");
909 let mut next = reduce_auth(
910 &reply,
911 Some(previous),
912 &inner,
913 &previous.device,
914 &next_cookie,
915 )?;
916 next.long_host = long;
917 next.short_host = short;
918 next.validate()?;
919 return Ok(next);
920 }
921 bail!("AutoAuth redirect limit reached")
922 }
923 pub(crate) fn authenticated_rpc(
924 &self,
925 state: &mut AccountState,
926 cgi: u16,
927 path: &str,
928 request: &[u8],
929 ) -> Result<Vec<u8>> {
930 let session = self.session(&state.long_host)?;
931 let packet = AuthenticatedPacket::prepare(state, cgi, request)?;
932 let reply = mmtls::short_exchange(
933 &session.short,
934 &state.short_host,
935 path,
936 &packet.packet,
937 self.options.timeout,
938 )?;
939 let (plain, cookie) = packet.decode_verified_transport_reply(&reply)?;
940 if !cookie.is_empty() {
941 state.cookie = cookie;
942 }
943 Ok(plain)
944 }
945 pub fn sync(&self, state: &mut AccountState, cursor: &[u8]) -> Result<SyncBatch> {
950 self.sync_scene(state, cursor, 6)
951 }
952 pub fn sync_scene(
953 &self,
954 state: &mut AccountState,
955 cursor: &[u8],
956 scene: u32,
957 ) -> Result<SyncBatch> {
958 ensure!([6, 7].contains(&scene), "Unsupported sync scene");
959 let wire_cursor = if cursor.is_empty() { &[0][..] } else { cursor };
960 let request = wire::sync_request(wire_cursor, &state.device.device_type, scene)?;
961 wire::decode_sync(
962 &self.authenticated_rpc(state, 138, "/cgi-bin/micromsg-bin/newsync", &request)?,
963 cursor,
964 )
965 }
966 pub fn heartbeat(&self, state: &mut AccountState, cursor: &[u8]) -> Result<Heartbeat> {
969 let request = wire::heartbeat_request(state, cursor)?;
970 wire::decode_heartbeat(&self.authenticated_rpc(
971 state,
972 518,
973 "/cgi-bin/micromsg-bin/heartbeat",
974 &request,
975 )?)
976 }
977 pub fn send_text(
986 &self,
987 state: &mut AccountState,
988 destination: &str,
989 content: &str,
990 created_at: u32,
991 client_message_id: u32,
992 ) -> Result<TextSendResult> {
993 self.send_text_with_source(
994 state,
995 destination,
996 content,
997 created_at,
998 client_message_id,
999 None,
1000 )
1001 }
1002 pub fn send_text_with_source(
1005 &self,
1006 state: &mut AccountState,
1007 destination: &str,
1008 content: &str,
1009 created_at: u32,
1010 client_message_id: u32,
1011 source: Option<&str>,
1012 ) -> Result<TextSendResult> {
1013 let request = wire::text_send_request_with_source(
1014 destination,
1015 content,
1016 created_at,
1017 client_message_id,
1018 source,
1019 )?;
1020 wire::decode_text_send(
1021 &self.authenticated_rpc(state, 522, "/cgi-bin/micromsg-bin/newsendmsg", &request)?,
1022 destination,
1023 client_message_id,
1024 )
1025 }
1026 pub fn lookup_contacts(
1029 &self,
1030 state: &mut AccountState,
1031 usernames: &[String],
1032 ) -> Result<ContactLookup> {
1033 let request = wire::contact_request(state, usernames)?;
1034 wire::decode_contacts(
1035 &self.authenticated_rpc(state, 182, "/cgi-bin/micromsg-bin/getcontact", &request)?,
1036 usernames,
1037 )
1038 }
1039 pub fn lookup_room(
1040 &self,
1041 state: &mut AccountState,
1042 room_id: &str,
1043 version: u32,
1044 ) -> Result<RoomRoster> {
1045 let request = wire::room_request(state, room_id, version)?;
1046 wire::decode_room(
1047 &self.authenticated_rpc(
1048 state,
1049 551,
1050 "/cgi-bin/micromsg-bin/getchatroommemberdetail",
1051 &request,
1052 )?,
1053 room_id,
1054 )
1055 }
1056 pub fn init_contacts(
1057 &self,
1058 state: &mut AccountState,
1059 contact_sequence: u32,
1060 room_sequence: u32,
1061 ) -> Result<InitContactPage> {
1062 let request =
1063 wire::init_contacts_request(&state.own_username, contact_sequence, room_sequence)?;
1064 let page = wire::decode_init_contacts(&self.authenticated_rpc(
1065 state,
1066 851,
1067 "/cgi-bin/micromsg-bin/initcontact",
1068 &request,
1069 )?)?;
1070 ensure!(
1071 !page.more
1072 || page.contact_sequence != contact_sequence
1073 || page.room_sequence != room_sequence,
1074 "Contact bootstrap made no cursor progress"
1075 );
1076 Ok(page)
1077 }
1078 pub fn download_avatars(
1079 &self,
1080 state: &mut AccountState,
1081 usernames: &[String],
1082 ) -> Result<Vec<AvatarBytes>> {
1083 let request = wire::avatar_request(state, usernames)?;
1084 wire::decode_avatars(
1085 &self.authenticated_rpc(
1086 state,
1087 123,
1088 "/cgi-bin/micromsg-bin/batchgetheadimg",
1089 &request,
1090 )?,
1091 usernames,
1092 )
1093 }
1094 pub fn download_voice_chunk(
1095 &self,
1096 state: &mut AccountState,
1097 server_message_id: u64,
1098 total_length: u32,
1099 offset: u32,
1100 ) -> Result<VoiceChunk> {
1101 let request = wire::voice_request(state, server_message_id, total_length, offset)?;
1102 wire::decode_voice(
1103 &self.authenticated_rpc(state, 128, "/cgi-bin/micromsg-bin/downloadvoice", &request)?,
1104 server_message_id,
1105 total_length,
1106 offset,
1107 )
1108 }
1109 pub fn cdn_config(&self, state: &mut AccountState) -> Result<crate::media::CdnConfig> {
1110 let request = p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?);
1111 let reply =
1112 self.authenticated_rpc(state, 379, "/cgi-bin/micromsg-bin/getcdndns", &request)?;
1113 crate::media::decode_cdn_config(&reply, state)
1114 }
1115 pub fn large_cdn_config(
1116 &self,
1117 state: &mut AccountState,
1118 route: crate::media::LargeCdnRoute,
1119 ) -> Result<crate::media::CdnConfig> {
1120 let request = p::bytes(1, &wire::base_request(&state.device, state.uin, 0)?);
1121 let reply =
1122 self.authenticated_rpc(state, 379, "/cgi-bin/micromsg-bin/getcdndns", &request)?;
1123 crate::media::decode_large_cdn_config(&reply, state, route)
1124 }
1125 pub fn send_share(
1128 &self,
1129 state: &mut AccountState,
1130 destination: &str,
1131 client_id: &str,
1132 created_at: u32,
1133 content: &str,
1134 ) -> Result<crate::media_send::MediaSendResult> {
1135 let request = crate::media_send::share_send_request(
1136 state,
1137 destination,
1138 client_id,
1139 created_at,
1140 content,
1141 )?;
1142 crate::media_send::decode_file_send(
1143 &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1144 client_id,
1145 )
1146 }
1147 pub fn send_quote(
1150 &self,
1151 state: &mut AccountState,
1152 destination: &str,
1153 client_id: &str,
1154 created_at: u32,
1155 content: &str,
1156 ) -> Result<crate::media_send::MediaSendResult> {
1157 self.send_quote_with_source(state, destination, client_id, created_at, content, None)
1158 }
1159 pub fn send_quote_with_source(
1160 &self,
1161 state: &mut AccountState,
1162 destination: &str,
1163 client_id: &str,
1164 created_at: u32,
1165 content: &str,
1166 source: Option<&str>,
1167 ) -> Result<crate::media_send::MediaSendResult> {
1168 let request = crate::quote_send::quote_send_request_with_source(
1169 state,
1170 destination,
1171 client_id,
1172 created_at,
1173 content,
1174 source,
1175 )?;
1176 crate::media_send::decode_file_send(
1177 &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1178 client_id,
1179 )
1180 }
1181 pub fn reserve_file_upload(
1184 &self,
1185 state: &mut AccountState,
1186 destination: &str,
1187 progress_client_id: &str,
1188 file_name: &str,
1189 plaintext_bytes: usize,
1190 md5: &str,
1191 ) -> Result<crate::file_upload::FileUploadReservation> {
1192 let request = crate::file_upload::file_upload_request(
1193 state,
1194 destination,
1195 progress_client_id,
1196 file_name,
1197 plaintext_bytes,
1198 md5,
1199 )?;
1200 crate::file_upload::decode_file_upload_reservation(&self.authenticated_rpc(
1201 state,
1202 6691,
1203 "/cgi-bin/micromsg-bin/sendfileuploadmsg",
1204 &request,
1205 )?)
1206 }
1207 pub fn send_file(
1208 &self,
1209 state: &mut AccountState,
1210 destination: &str,
1211 client_id: &str,
1212 created_at: u32,
1213 upload: &crate::media::UploadedMedia,
1214 ) -> Result<crate::media_send::MediaSendResult> {
1215 let request = crate::media_send::file_send_request(
1216 state,
1217 destination,
1218 client_id,
1219 created_at,
1220 upload,
1221 )?;
1222 crate::media_send::decode_file_send(
1223 &self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?,
1224 client_id,
1225 )
1226 }
1227 #[allow(clippy::too_many_arguments)]
1228 pub fn send_file_with_reservation(
1229 &self,
1230 state: &mut AccountState,
1231 destination: &str,
1232 client_id: &str,
1233 created_at: u32,
1234 upload: &crate::media::UploadedMedia,
1235 reservation: Option<&crate::file_upload::FileUploadReservation>,
1236 ) -> Result<crate::media_send::MediaSendResult> {
1237 let request = crate::media_send::file_send_request_with_reservation(
1238 state,
1239 destination,
1240 client_id,
1241 created_at,
1242 upload,
1243 reservation,
1244 )?;
1245 let reply =
1246 self.authenticated_rpc(state, 222, "/cgi-bin/micromsg-bin/sendappmsg", &request)?;
1247 if reservation.is_some() {
1248 crate::media_send::decode_file_send_correlated(
1249 &reply,
1250 client_id,
1251 &state.own_username,
1252 destination,
1253 &upload.descriptor.aes_key,
1254 )
1255 } else {
1256 crate::media_send::decode_file_send(&reply, client_id)
1257 }
1258 }
1259 pub fn send_image(
1260 &self,
1261 state: &mut AccountState,
1262 destination: &str,
1263 client_id: &str,
1264 upload: &crate::media::UploadedImage,
1265 ) -> Result<crate::media_send::MediaSendResult> {
1266 let request = crate::media_send::image_send_request(state, destination, client_id, upload)?;
1267 crate::media_send::decode_image_send(
1268 &self.authenticated_rpc(state, 110, "/cgi-bin/micromsg-bin/uploadmsgimg", &request)?,
1269 client_id,
1270 &state.own_username,
1271 destination,
1272 )
1273 }
1274 pub fn send_video(
1275 &self,
1276 state: &mut AccountState,
1277 destination: &str,
1278 client_id: &str,
1279 upload: &crate::media::UploadedVideo,
1280 play_seconds: u32,
1281 ) -> Result<crate::media_send::MediaSendResult> {
1282 let request = crate::media_send::video_send_request(
1283 state,
1284 destination,
1285 client_id,
1286 upload,
1287 play_seconds,
1288 )?;
1289 crate::media_send::decode_video_send(
1290 &self.authenticated_rpc(state, 149, "/cgi-bin/micromsg-bin/uploadvideo", &request)?,
1291 client_id,
1292 )
1293 }
1294 pub fn send_voice(
1298 &self,
1299 state: &mut AccountState,
1300 destination: &str,
1301 client_id: &str,
1302 created_at: u32,
1303 duration_ms: u32,
1304 encoded: &[u8],
1305 ) -> Result<crate::media_send::MediaSendResult> {
1306 let mut upload = crate::media_send::VoiceUpload::new(
1307 destination,
1308 client_id,
1309 created_at,
1310 duration_ms,
1311 encoded,
1312 )?;
1313 loop {
1314 let request = upload.request(state)?;
1315 let reply =
1316 self.authenticated_rpc(state, 127, "/cgi-bin/micromsg-bin/uploadvoice", &request)?;
1317 if let Some(result) = upload.accept_reply(&reply)? {
1318 return Ok(result);
1319 }
1320 }
1321 }
1322}
1323
1324fn redirect(fields: &[p::Field]) -> Result<(String, String)> {
1325 let network = p::decode(p::data(fields, 5)?)?;
1326 let hosts = p::decode(p::data(&network, 1)?)?;
1327 let target = |source: &[u8]| -> Result<String> {
1328 let mut out = None;
1329 for f in hosts.iter().filter(|f| f.number == 2) {
1330 let p::Value::Bytes(raw) = &f.value else {
1331 bail!("Invalid redirect map");
1332 };
1333 let item = p::decode(raw)?;
1334 if p::data(&item, 1)? == source {
1335 ensure!(out.is_none(), "Ambiguous redirect");
1336 let name = std::str::from_utf8(p::data(&item, 2)?)?.to_string();
1337 mmtls::validate_host(&name)?;
1338 out = Some(name);
1339 }
1340 }
1341 out.ok_or_else(|| anyhow::anyhow!("Missing redirect mapping"))
1342 };
1343 Ok((
1344 target(b"long.weixin.qq.com")?,
1345 target(b"short.weixin.qq.com")?,
1346 ))
1347}
1348fn reduce_auth(
1349 plain: &[u8],
1350 previous: Option<&AccountState>,
1351 inner: &Ecdh,
1352 device: &DeviceProfile,
1353 cookie: &[u8],
1354) -> Result<AccountState> {
1355 let f = p::decode(plain)?;
1356 ensure!(p::base_code(&f)? == 0, "Rejected auth response");
1357 let mask = u32::try_from(p::scalar(&f, 2)?)?;
1358 for (bit, n) in [(1, 3), (2, 4), (4, 5)] {
1359 if mask & bit != 0 {
1360 p::data(&f, n)?;
1361 }
1362 }
1363 let mut state = previous.cloned().unwrap_or_else(|| AccountState {
1364 schema: 1,
1365 device: device.clone(),
1366 uin: 0,
1367 own_username: String::new(),
1368 cookie: vec![],
1369 session_key: vec![],
1370 request_key: vec![],
1371 response_key: vec![],
1372 auto_auth_key: vec![],
1373 key_info: vec![],
1374 database_password: vec![],
1375 auth_snapshot: AuthSnapshot::default(),
1376 long_host: "long.weixin.qq.com".into(),
1377 short_host: "short.weixin.qq.com".into(),
1378 });
1379 ensure!(
1380 plain.len() <= 1024 * 1024,
1381 "Auth response snapshot too large"
1382 );
1383 state.auth_snapshot.update_mask = mask;
1384 state.auth_snapshot.response = plain.to_vec();
1385 for (number, target) in [
1386 (3, &mut state.auth_snapshot.auth),
1387 (4, &mut state.auth_snapshot.account),
1388 (5, &mut state.auth_snapshot.network),
1389 (6, &mut state.auth_snapshot.ax_auth),
1390 ] {
1391 if let Some(section) = p::optional_data(&f, number)? {
1392 *target = section.to_vec();
1393 }
1394 }
1395 if let Some(account) = p::optional_data(&f, 4)? {
1396 let a = p::decode(account)?;
1397 if let Some(name) = p::optional_data(&a, 1)? {
1398 let name = std::str::from_utf8(name)?;
1399 ensure!(
1400 previous.is_none_or(|s| s.own_username == name),
1401 "Auth account identity changed"
1402 );
1403 state.own_username = name.to_string();
1404 }
1405 }
1406 if mask & 1 != 0 {
1407 let auth = p::data(&f, 3)?;
1408 let a = p::decode(auth)?;
1409 let uin = u32::try_from(p::scalar(&a, 1)?)?;
1410 ensure!(
1411 uin != 0 && previous.is_none_or(|s| s.uin == uin),
1412 "Auth UIN changed"
1413 );
1414 let (session, request, response) = business::account_keys(inner, auth)?;
1415 state.uin = uin;
1416 state.session_key = session;
1417 state.request_key = request;
1418 state.response_key = response;
1419 state.auto_auth_key = p::optional_data(&a, 4)?
1420 .map(p::unbuffer)
1421 .transpose()?
1422 .unwrap_or_default();
1423 if let Some(password) = p::optional_data(&a, 9)? {
1424 let password = p::unbuffer(password)?;
1425 ensure!(
1426 password.is_empty() || password.len() == 32,
1427 "Invalid native database password"
1428 );
1429 state.database_password.zeroize();
1430 state.database_password = password;
1431 }
1432 state.key_info = p::optional_data(&a, 10)?
1433 .map(p::unbuffer)
1434 .transpose()?
1435 .unwrap_or_default();
1436 } else {
1437 ensure!(previous.is_some(), "Initial auth has no key section");
1438 }
1439 ensure!(cookie.len() <= 15, "Invalid auth cookie");
1440 if !cookie.is_empty() {
1441 state.cookie = cookie.to_vec();
1442 }
1443 state.validate()?;
1444 Ok(state)
1445}
1446
1447fn apply_login_notification(
1448 attempt: &mut LoginAttempt,
1449 response: wire::PollResponse,
1450 start_lan: impl FnOnce(&mut LoginAttempt) -> Result<std::sync::Arc<crate::lan_login::Challenge>>,
1451) -> Result<LoginStatus> {
1452 if response.lan_challenge && matches!(response.status, Some(0..=2)) {
1453 attempt.credential3.zeroize();
1456 attempt.credential4.zeroize();
1457 attempt.credential3 = None;
1458 attempt.credential4 = None;
1459 attempt.status = LoginStatus::Scanned;
1460 if let Some(challenge) = &attempt.lan_challenge {
1461 if challenge.active() {
1462 return Ok(attempt.status);
1463 }
1464 attempt.stop_lan_login();
1465 return Err(crate::lan_login::failure("listener_stopped"));
1466 }
1467 if attempt.lan_upload_attempted {
1468 return Err(crate::lan_login::failure("upload_already_attempted"));
1469 }
1470 attempt.lan_upload_attempted = true;
1473 attempt.lan_challenge = Some(start_lan(attempt)?);
1474 return Ok(attempt.status);
1475 }
1476 if let Some(status) = response.status {
1477 attempt.status = match status {
1478 0 if attempt.status == LoginStatus::Scanned => LoginStatus::Scanned,
1479 0 => LoginStatus::Pending,
1480 1 => LoginStatus::Scanned,
1481 2 => LoginStatus::Approved,
1482 _ => LoginStatus::Rejected,
1484 };
1485 if attempt.status == LoginStatus::Approved {
1486 attempt.credential3 = response.credential3;
1487 attempt.credential4 = response.credential4;
1488 }
1489 if matches!(
1490 attempt.status,
1491 LoginStatus::Approved | LoginStatus::Rejected
1492 ) {
1493 attempt.stop_lan_login();
1494 }
1495 }
1496 if attempt
1497 .lan_challenge
1498 .as_ref()
1499 .is_some_and(|challenge| !challenge.active())
1500 {
1501 attempt.stop_lan_login();
1502 return Err(crate::lan_login::failure("listener_stopped"));
1503 }
1504 Ok(attempt.status)
1505}
1506
1507#[cfg(test)]
1508mod tests {
1509 use super::*;
1510 fn attempt(status: LoginStatus, qr: bool) -> LoginAttempt {
1511 LoginAttempt {
1512 schema: 1,
1513 device: state().device.clone(),
1514 computer_name: "synthetic desktop".into(),
1515 qr_png: if qr { vec![1] } else { Vec::new() },
1516 width: 0,
1517 height: 0,
1518 created_at: 1000,
1519 expires_at: 1240,
1520 identifier: vec![1; 16],
1521 notify_key: vec![2; 16],
1522 cookie: vec![3; 15],
1523 key_version: 10014,
1524 long_host: "long.weixin.qq.com".into(),
1525 short_host: "short.weixin.qq.com".into(),
1526 status,
1527 key_info: b"persisted installation key-info".to_vec(),
1528 credential3: Some(vec![4; 16]),
1529 credential4: Some(vec![5; 16]),
1530 lan_upload_attempted: false,
1531 lan_challenge: None,
1532 }
1533 }
1534 #[test]
1535 fn final_approval_request_restores_key_info_for_both_saved_device_and_qr() {
1536 let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1537 for qr in [false, true] {
1538 let attempt = attempt(LoginStatus::Approved, qr);
1539 let restored: LoginAttempt =
1540 serde_json::from_slice(&serde_json::to_vec(&attempt).unwrap()).unwrap();
1541 assert_eq!(restored.key_info, attempt.key_info);
1542 let request = p::decode(
1543 &restored
1544 .manual_auth_request(&inner.public().unwrap())
1545 .unwrap(),
1546 )
1547 .unwrap();
1548 let aes = p::decode(p::data(&request, 2).unwrap()).unwrap();
1549 let base = p::decode(p::data(&aes, 1).unwrap()).unwrap();
1550 assert_eq!(p::scalar(&base, 6).unwrap(), 1);
1552 let auth = p::decode(p::data(&aes, 2).unwrap()).unwrap();
1553 assert_eq!(
1554 p::unbuffer(p::data(&auth, 5).unwrap()).unwrap(),
1555 attempt.key_info
1556 );
1557 assert!(!format!("{restored:?}").contains("persisted installation"));
1558 }
1559 }
1560 #[test]
1561 fn scanned_and_saved_device_approval_outlive_image_expiry_but_remain_bounded() {
1562 let qr = attempt(LoginStatus::Pending, true);
1563 assert!(qr.validate_at(1239).is_ok());
1564 assert!(qr.validate_at(1240).is_err());
1565 for (status, qr) in [
1566 (LoginStatus::Scanned, true),
1567 (LoginStatus::Approved, true),
1568 (LoginStatus::Pending, false),
1569 ] {
1570 let attempt = attempt(status, qr);
1571 assert!(attempt.validate_at(1240).is_ok());
1572 assert!(attempt.validate_at(1899).is_ok());
1573 assert!(attempt.validate_at(1900).is_err());
1574 }
1575 }
1576 fn notification(status: u32, lan_challenge: bool) -> wire::PollResponse {
1577 wire::PollResponse {
1578 status: Some(status),
1579 lan_challenge,
1580 credential3: (status == 2).then(|| vec![4; 16]),
1581 credential4: (status == 2).then(|| vec![5; 16]),
1582 }
1583 }
1584 fn synthetic_lan() -> std::sync::Arc<crate::lan_login::Challenge> {
1585 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1586 let challenge = crate::lan_login::Challenge::from_listener(
1587 listener,
1588 vec![std::net::Ipv4Addr::LOCALHOST],
1589 crate::lan_login::Scope::new(
1590 std::time::Instant::now() + Duration::from_secs(5),
1591 || false,
1592 ),
1593 )
1594 .unwrap();
1595 challenge.arm(b"synthetic temporary token").unwrap();
1596 std::sync::Arc::new(challenge)
1597 }
1598 #[test]
1599 fn repeated_risk_notifications_upload_once_and_never_install_approval() {
1600 let mut attempt = attempt(LoginStatus::Pending, true);
1601 let challenge = synthetic_lan();
1602 assert_eq!(
1603 apply_login_notification(&mut attempt, notification(2, true), |_| Ok(
1604 challenge.clone()
1605 ))
1606 .unwrap(),
1607 LoginStatus::Scanned
1608 );
1609 assert!(attempt.credential3.is_none() && attempt.credential4.is_none());
1610 assert_eq!(attempt.lan_login_state(), Some("waiting_for_phone"));
1611 for _ in 0..3 {
1612 assert_eq!(
1613 apply_login_notification(&mut attempt, notification(1, true), |_| panic!(
1614 "duplicate upload"
1615 ))
1616 .unwrap(),
1617 LoginStatus::Scanned
1618 );
1619 }
1620 apply_login_notification(&mut attempt, notification(0, false), |_| {
1622 panic!("ordinary poll started LAN")
1623 })
1624 .unwrap();
1625 assert_eq!(attempt.status, LoginStatus::Scanned);
1626 assert!(challenge.active());
1627 let serialized = serde_json::to_string(&attempt).unwrap();
1628 assert!(!serialized.contains("synthetic temporary token"));
1629 assert!(!format!("{attempt:?}").contains("synthetic temporary token"));
1630 let restored: LoginAttempt = serde_json::from_str(&serialized).unwrap();
1631 assert!(restored.lan_login_state().is_none());
1632 assert!(restored.lan_upload_attempted); assert_eq!(
1634 apply_login_notification(&mut attempt, notification(2, false), |_| panic!(
1635 "approval started LAN"
1636 ))
1637 .unwrap(),
1638 LoginStatus::Approved
1639 );
1640 assert!(attempt.lan_login_state().is_none());
1641 assert!(!challenge.active());
1642 assert!(attempt.credential3.is_some() && attempt.credential4.is_some());
1643 }
1644 #[test]
1645 fn failed_upload_is_not_retried_by_the_repeatable_poll_path() {
1646 let mut attempt = attempt(LoginStatus::Pending, true);
1647 let error = apply_login_notification(&mut attempt, notification(1, true), |_| {
1648 Err(crate::lan_login::failure("upload_failed"))
1649 })
1650 .unwrap_err();
1651 assert!(!is_retryable_login_poll_error(&error));
1652 assert_eq!(crate::login_lan_error_reason(&error), Some("upload_failed"));
1653 assert!(attempt.lan_login_state().is_none());
1654 let error = apply_login_notification(&mut attempt, notification(1, true), |_| {
1655 panic!("resubmitted upload")
1656 })
1657 .unwrap_err();
1658 assert_eq!(
1659 crate::login_lan_error_reason(&error),
1660 Some("upload_already_attempted")
1661 );
1662 assert!(attempt.credential3.is_none() && attempt.credential4.is_none());
1663 }
1664 #[test]
1665 fn rejection_or_attempt_drop_closes_the_shared_listener() {
1666 for reject in [false, true] {
1667 let mut attempt = attempt(LoginStatus::Pending, false);
1668 let challenge = synthetic_lan();
1669 apply_login_notification(&mut attempt, notification(1, true), |_| {
1670 Ok(challenge.clone())
1671 })
1672 .unwrap();
1673 if reject {
1674 assert_eq!(
1675 apply_login_notification(&mut attempt, notification(3, true), |_| panic!(
1676 "rejection started LAN"
1677 ))
1678 .unwrap(),
1679 LoginStatus::Rejected
1680 );
1681 }
1682 drop(attempt);
1683 assert!(!challenge.active());
1684 }
1685 }
1686 #[test]
1687 fn ordinary_login_does_not_open_a_lan_listener() {
1688 let mut attempt = attempt(LoginStatus::Pending, true);
1689 for status in [0, 1, 2] {
1690 apply_login_notification(&mut attempt, notification(status, false), |_| {
1691 panic!("ordinary login opened a socket")
1692 })
1693 .unwrap();
1694 assert!(attempt.lan_login_state().is_none());
1695 assert!(!attempt.lan_upload_attempted);
1696 }
1697 }
1698 fn state() -> AccountState {
1699 AccountState {
1700 schema: 1,
1701 device: DeviceProfile {
1702 device_id: vec![1; 16],
1703 device_type: "UnifiedPCLinux synthetic x86_64".into(),
1704 client_version: -227271398,
1705 },
1706 uin: 123,
1707 own_username: "synthetic-user".into(),
1708 cookie: vec![5],
1709 session_key: vec![6; 16],
1710 request_key: vec![7; 24],
1711 response_key: vec![8; 24],
1712 auto_auth_key: vec![],
1713 key_info: vec![],
1714 database_password: vec![],
1715 auth_snapshot: AuthSnapshot::default(),
1716 long_host: "long.weixin.qq.com".into(),
1717 short_host: "short.weixin.qq.com".into(),
1718 }
1719 }
1720 #[test]
1721 fn challenge_poll_retries_only_transport_availability_failures() {
1722 use std::io::{Error, ErrorKind};
1723 for kind in [
1724 ErrorKind::TimedOut,
1725 ErrorKind::WouldBlock,
1726 ErrorKind::ConnectionReset,
1727 ErrorKind::UnexpectedEof,
1728 ] {
1729 assert!(is_retryable_login_poll_error(
1730 &anyhow::Error::new(Error::from(kind)).context("polling challenge")
1731 ));
1732 }
1733 for message in [
1734 "MMTLS deadline exceeded",
1735 "MMTLS connection closed",
1736 "Native shortlink HTTP status 503",
1737 ] {
1738 assert!(is_retryable_login_poll_error(&anyhow::anyhow!("{message}")));
1739 }
1740 for message in [
1741 "Login poll returned -13",
1742 "Native envelope returned -13",
1743 "Native shortlink HTTP status 403",
1744 "Native shortlink HTTP status 5030",
1745 "Missing authentication tag",
1746 "Unexpected MMTLS header",
1747 "Truncated structure",
1748 ] {
1749 assert!(!is_retryable_login_poll_error(&anyhow::anyhow!(
1750 "{message}"
1751 )));
1752 }
1753 assert!(!is_retryable_login_poll_error(
1754 &Error::from(ErrorKind::PermissionDenied).into()
1755 ));
1756 }
1757 #[test]
1758 fn secrets_not_debugged() {
1759 let s = state();
1760 let text = format!("{s:?}");
1761 assert!(!text.contains("synthetic-user"));
1762 assert!(!text.contains("123"));
1763 assert!(text.contains("redacted"));
1764 }
1765 #[test]
1766 fn account_state_roundtrips_without_number_loss() {
1767 let s = state();
1768 let json = serde_json::to_vec(&s).unwrap();
1769 let restored: AccountState = serde_json::from_slice(&json).unwrap();
1770 restored.validate().unwrap();
1771 assert_eq!(restored.uin, s.uin);
1772 }
1773 #[test]
1774 fn database_password_follows_auth_update_gate_and_optional_presence() {
1775 let mut old = state();
1776 old.database_password = vec![11; 32];
1777 let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1778 let response = |mask, password: Option<&[u8]>| {
1779 let mut fields = vec![
1780 p::uint(1, old.uin as u64),
1781 p::bytes(3, &p::buffer(&[1; 16])),
1782 p::uint(19, 0),
1783 ];
1784 if let Some(bytes) = password {
1785 fields.push(p::bytes(9, &p::buffer(bytes)));
1786 }
1787 p::message(&[
1788 p::bytes(1, &p::uint(1, 0)),
1789 p::uint(2, mask),
1790 p::bytes(3, &p::message(&fields).unwrap()),
1791 p::bytes(6, b"synthetic-ax"),
1792 ])
1793 .unwrap()
1794 };
1795 let gated_off = response(0, Some(&[22; 32]));
1796 let unchanged = reduce_auth(&gated_off, Some(&old), &inner, &old.device, &[]).unwrap();
1797 assert_eq!(unchanged.database_password, old.database_password);
1798 assert_eq!(unchanged.auth_snapshot.update_mask, 0);
1799 assert_eq!(unchanged.auth_snapshot.response, gated_off);
1800 assert_eq!(unchanged.auth_snapshot.ax_auth, b"synthetic-ax");
1801 let accepted = reduce_auth(
1802 &response(1, Some(&[22; 32])),
1803 Some(&old),
1804 &inner,
1805 &old.device,
1806 &[],
1807 )
1808 .unwrap();
1809 assert_eq!(accepted.database_password, vec![22; 32]);
1810 let absent = reduce_auth(
1811 &response(1, None),
1812 Some(&accepted),
1813 &inner,
1814 &old.device,
1815 &[],
1816 )
1817 .unwrap();
1818 assert_eq!(absent.database_password, accepted.database_password);
1819 let empty = reduce_auth(
1820 &response(1, Some(&[])),
1821 Some(&absent),
1822 &inner,
1823 &old.device,
1824 &[],
1825 )
1826 .unwrap();
1827 assert!(empty.database_password.is_empty());
1828 assert!(reduce_auth(
1829 &response(1, Some(&[22; 31])),
1830 Some(&old),
1831 &inner,
1832 &old.device,
1833 &[]
1834 )
1835 .is_err());
1836 let mut legacy = serde_json::to_value(&old).unwrap();
1837 legacy.as_object_mut().unwrap().remove("database_password");
1838 legacy.as_object_mut().unwrap().remove("auth_snapshot");
1839 let restored: AccountState = serde_json::from_value(legacy).unwrap();
1840 restored.validate().unwrap();
1841 assert!(restored.database_password.is_empty());
1842 }
1843 #[test]
1844 fn refresh_identity_and_clear_tokens() {
1845 let old = state();
1846 let inner = Ecdh::generate(Nid::SECP224R1).unwrap();
1847 let auth = p::message(&[
1848 p::uint(1, old.uin as u64),
1849 p::bytes(3, &p::buffer(&[1; 16])),
1850 p::uint(19, 0),
1851 ])
1852 .unwrap();
1853 let reply = p::message(&[
1854 p::bytes(1, &p::uint(1, 0)),
1855 p::uint(2, 1),
1856 p::bytes(3, &auth),
1857 p::bytes(4, &p::bytes(1, old.own_username.as_bytes())),
1858 ])
1859 .unwrap();
1860 let next = reduce_auth(&reply, Some(&old), &inner, &old.device, &[]).unwrap();
1861 assert!(next.auto_auth_key.is_empty());
1862 assert!(next.request_key.is_empty());
1863 let bad = p::message(&[
1864 p::bytes(1, &p::uint(1, 0)),
1865 p::uint(2, 0),
1866 p::bytes(4, &p::bytes(1, b"foreign")),
1867 ])
1868 .unwrap();
1869 assert!(reduce_auth(&bad, Some(&old), &inner, &old.device, &[]).is_err());
1870 }
1871}