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