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