1use super::host_policy::CallHostRoute;
6use crate::{business, crypto, AccountState, MAX_MESSAGE_BYTES};
7use openssl::symm::Mode;
8use std::time::{Duration, Instant};
9use zeroize::Zeroizing;
10
11#[derive(Clone, Copy, Debug, PartialEq, Eq)]
12pub enum CallHostStage {
13 Route,
14 Handshake,
15 Prepare,
16 Exchange,
17 Decode,
18}
19impl CallHostStage {
20 pub const fn as_str(self) -> &'static str {
21 match self {
22 Self::Route => "route",
23 Self::Handshake => "handshake",
24 Self::Prepare => "prepare",
25 Self::Exchange => "exchange",
26 Self::Decode => "decode",
27 }
28 }
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct CallHostRpcError {
35 pub stage: CallHostStage,
36 pub code: &'static str,
37 pub value: Option<i64>,
38}
39impl std::fmt::Display for CallHostRpcError {
40 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
41 write!(f, "Call host {} failed: {}", self.stage.as_str(), self.code)?;
42 if let Some(value) = self.value {
43 write!(f, " ({value})")?;
44 }
45 Ok(())
46 }
47}
48impl std::error::Error for CallHostRpcError {}
49impl CallHostRpcError {
50 pub(crate) fn new(stage: CallHostStage, code: &'static str, value: Option<i64>) -> Self {
51 Self { stage, code, value }
52 }
53 pub fn may_have_submitted(&self) -> bool {
56 matches!(self.stage, CallHostStage::Exchange | CallHostStage::Decode)
57 && self.code != "unsupported_route"
58 }
59 pub(crate) fn from_cause(stage: CallHostStage, cause: &anyhow::Error) -> Self {
60 let mut result = Self::new(stage, "rejected", None);
61 if cause.is::<crate::mmtls::UnsupportedNativeRoute>() {
62 result.code = "unsupported_route";
63 } else if let Some(http) = cause.downcast_ref::<reqwest::Error>() {
64 result.code = if http.is_timeout() {
65 "timeout"
66 } else if http.is_connect() {
67 "connect"
68 } else if let Some(status) = http.status() {
69 result.value = Some(i64::from(status.as_u16()));
70 "http_status"
71 } else {
72 "http_transport"
73 };
74 } else if let Some(io) = cause.downcast_ref::<std::io::Error>() {
75 result.code = match io.kind() {
76 std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timeout",
77 std::io::ErrorKind::ConnectionRefused => "connection_refused",
78 std::io::ErrorKind::ConnectionReset => "connection_reset",
79 std::io::ErrorKind::UnexpectedEof => "unexpected_eof",
80 std::io::ErrorKind::PermissionDenied => "permission_denied",
81 std::io::ErrorKind::ConnectionAborted => "connection_aborted",
82 std::io::ErrorKind::NetworkUnreachable => "network_unreachable",
83 std::io::ErrorKind::HostUnreachable => "host_unreachable",
84 std::io::ErrorKind::AddrNotAvailable => "address_unavailable",
85 std::io::ErrorKind::BrokenPipe => "broken_pipe",
86 _ => "io",
87 };
88 } else if cause.is::<openssl::error::ErrorStack>() {
89 result.code = "openssl_crypto";
90 } else if let Some((code, value)) = cause
91 .chain()
92 .find_map(|error| known_transport_literal(&error.to_string()))
93 {
94 result.code = code;
95 result.value = value;
96 }
97 result
98 }
99}
100
101pub(crate) fn connect_call_session<T>(
102 route: CallHostRoute,
103 timeout: Duration,
104 mut connect: impl FnMut(Duration) -> anyhow::Result<T>,
105) -> Result<T, CallHostRpcError> {
106 let started = Instant::now();
107 let diagnose = |error| CallHostRpcError::from_cause(CallHostStage::Handshake, &error);
108 let first = connect(timeout).map_err(diagnose);
109 if route == CallHostRoute::AppAuth
112 && first
113 .as_ref()
114 .is_err_and(|error| error.code == "openssl_crypto")
115 {
116 let remaining = timeout.saturating_sub(started.elapsed());
117 if !remaining.is_zero() {
118 return connect(remaining).map_err(diagnose);
119 }
120 }
121 first
122}
123
124fn known_transport_literal(message: &str) -> Option<(&'static str, Option<i64>)> {
129 let code = match message {
130 "Unsupported native server host" => "host_policy",
131 "Invalid receive budget" => "receive_budget_configuration",
132 "Invalid MMTLS timeout" => "timeout_configuration",
133 "Native connection failed" => "connect_failed",
134 "MMTLS deadline exceeded" => "deadline",
135 "MMTLS connection closed" => "connection_closed",
136 "Unexpected MMTLS header" => "record_header",
137 "Receive overflow" | "MMTLS receive budget exceeded" => "receive_budget",
138 "Server returned MMTLS alert" => "server_alert",
139 "Expected handshake" => "expected_server_hello",
140 "Unsupported ServerHello" => "server_hello_selection",
141 "Unoffered key" => "unoffered_key",
142 "Duplicate extension" => "duplicate_extension",
143 "Invalid ECDH point" => "ecdh_point",
144 "Missing ECDH selection" => "missing_ecdh_selection",
145 "Missing certificate selection" => "missing_certificate_selection",
146 "Expected signature record" => "expected_signature_record",
147 "Unsupported MMTLS certificate version" => "certificate_version",
148 "Unoffered MMTLS pin" => "unoffered_pin",
149 "Unsupported signature pin curve" => "signature_curve",
150 "Pinned server signature verification failed" => "signature_verification",
151 "Expected ticket record" => "expected_ticket_record",
152 "Expected tickets" => "expected_tickets",
153 "Invalid ticket count" => "ticket_count",
154 "Invalid shortlink ticket" => "ticket_shape",
155 "Expected Finished" => "expected_finished_record",
156 "Server Finished verification failed" => "finished_verification",
157 "Expected NOOP reply" => "expected_noop_record",
158 "Unexpected NOOP reply" => "noop_reply",
159 "Truncated structure" => "truncated_structure",
160 "Integer too wide" => "integer_width",
161 "Trailing structure bytes" => "trailing_structure",
162 "Unexpected handshake message" => "handshake_message",
163 "Missing authentication tag" => "authentication_tag_missing",
164 "Invalid IV" | "Invalid GCM nonce" => "iv_shape",
165 "Invalid GCM key" | "Expected AES192 key" => "key_shape",
166 "Invalid GCM envelope" => "gcm_envelope",
167 "Invalid HKDF size" => "hkdf_size",
168 "Invalid MMTLS record" => "record_shape",
169 "Section too large" | "MMTLS plaintext too large" | "Payload too large" => "request_size",
170 "Shortlink receive budget exceeded" | "Shortlink plaintext too large" => {
171 "shortlink_reply_size"
172 }
173 "Invalid shortlink header" => "shortlink_header",
174 "Invalid shortlink sequence" => "shortlink_sequence",
175 "Unsupported shortlink selection" => "shortlink_selection",
176 "Unsupported shortlink extensions" => "shortlink_extensions",
177 "Shortlink Finished failed" => "shortlink_finished",
178 "Unexpected shortlink close" => "shortlink_close",
179 "Invalid longlink envelope" => "longlink_envelope",
180 _ => {
181 let value = message.strip_prefix("Native shortlink HTTP status ")?;
182 if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_digit()) {
183 return None;
184 }
185 let status = value.parse::<u16>().ok()?;
186 return (100..=599)
187 .contains(&status)
188 .then_some(("http_status", Some(i64::from(status))));
189 }
190 };
191 Some((code, None))
192}
193fn decode_error(code: &'static str, value: Option<i64>) -> CallHostRpcError {
194 CallHostRpcError::new(CallHostStage::Decode, code, value)
195}
196
197pub(crate) fn decode_verified_reply(
202 state: &AccountState,
203 cgi: u16,
204 input: &[u8],
205) -> Result<(Vec<u8>, Vec<u8>), CallHostRpcError> {
206 if input.len() > MAX_MESSAGE_BYTES {
207 return Err(decode_error("reply_size", None));
208 }
209 let envelope = business::split(input).map_err(|_| decode_error("envelope", None))?;
210 if envelope.return_code != 0 {
211 return Err(decode_error(
212 "business_return",
213 Some(i64::from(envelope.return_code)),
214 ));
215 }
216 if envelope.cgi != u64::from(cgi) {
217 return Err(decode_error("command", None));
218 }
219 if ![1, 2].contains(&envelope.compression) {
220 return Err(decode_error(
221 "compression",
222 Some(i64::from(envelope.compression)),
223 ));
224 }
225 let body = Zeroizing::new(
226 match envelope.encryption {
227 13 if state.response_key.len() == 24 => {
228 crypto::envelope_open(&envelope.payload, &state.response_key, b"")
229 }
230 5 if state.session_key.len() == 16 => {
231 crypto::cbc(&envelope.payload, &state.session_key, Mode::Decrypt)
232 }
233 5 | 13 => {
234 return Err(decode_error(
235 "response_key",
236 Some(i64::from(envelope.encryption)),
237 ))
238 }
239 mode => return Err(decode_error("encryption_mode", Some(i64::from(mode)))),
240 }
241 .map_err(|_| decode_error("decrypt", Some(i64::from(envelope.encryption))))?,
242 );
243 if body.len() != envelope.pre_encryption_length {
244 return Err(decode_error("decrypted_length", None));
245 }
246 let plain = Zeroizing::new(if envelope.compression == 1 {
247 crypto::inflate(&body).map_err(|_| decode_error("inflate", None))?
248 } else {
249 body.to_vec()
250 });
251 if plain.len() != envelope.original_length {
252 return Err(decode_error("plaintext_length", None));
253 }
254 Ok((plain.to_vec(), envelope.cookie))
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260 fn state() -> AccountState {
261 AccountState {
262 schema: 1,
263 device: crate::DeviceProfile {
264 device_id: vec![b'x'; 16],
265 device_type: "Linux".into(),
266 client_version: -227271398,
267 },
268 uin: 1,
269 own_username: "synthetic".into(),
270 cookie: vec![],
271 session_key: vec![7; 16],
272 request_key: vec![8; 24],
273 response_key: vec![9; 24],
274 auto_auth_key: vec![],
275 key_info: vec![],
276 database_password: vec![],
277 auth_snapshot: crate::AuthSnapshot::default(),
278 long_host: "long.weixin.qq.com".into(),
279 short_host: "short.weixin.qq.com".into(),
280 }
281 }
282 fn reply(state: &AccountState, cgi: u16, mode: u8, compressed: bool) -> Vec<u8> {
285 let plain = b"synthetic call reply";
286 let body = if compressed {
287 crypto::deflate(plain).unwrap()
288 } else {
289 plain.to_vec()
290 };
291 let cipher = match mode {
292 13 => crypto::envelope_seal(&body, &state.response_key, b"", [3; 12]).unwrap(),
293 _ => crypto::cbc(&body, &state.session_key, Mode::Encrypt).unwrap(),
294 };
295 let cookie = b"new";
296 let mut tail = Vec::new();
297 for n in [u64::from(cgi), plain.len() as u64, body.len() as u64, 0, 0] {
298 tail.extend(crate::protobuf::varint(n));
299 }
300 let size = 10 + cookie.len() + tail.len();
301 assert!(size < 64);
302 let flags = (u16::from(mode) << 12)
303 | ((cookie.len() as u16) << 8)
304 | ((size as u16) << 2)
305 | if compressed { 1 } else { 2 };
306 let mut raw = flags.to_le_bytes().to_vec();
307 raw.extend(0i32.to_be_bytes());
308 raw.extend(state.uin.to_be_bytes());
309 raw.extend(cookie);
310 raw.extend(tail);
311 raw.extend(cipher);
312 raw
313 }
314 #[test]
315 fn call_reply_mode_is_independent_while_generic_policy_stays_strict() {
316 let mut s = state();
317 let generic = business::AuthenticatedPacket::prepare(&s, 7994, b"request").unwrap();
318 let origin_reply = reply(&s, 7994, 5, false);
319 assert!(generic
320 .decode_verified_transport_reply(&origin_reply)
321 .is_err());
322 assert_eq!(
323 decode_verified_reply(&s, 7994, &origin_reply).unwrap(),
324 (b"synthetic call reply".to_vec(), b"new".to_vec())
325 );
326 for compressed in [false, true] {
327 assert_eq!(
328 decode_verified_reply(&s, 7994, &reply(&s, 7994, 13, compressed))
329 .unwrap()
330 .0,
331 b"synthetic call reply"
332 );
333 }
334 s.request_key.clear();
335 s.response_key.clear();
336 let generic_origin = business::AuthenticatedPacket::prepare(&s, 7994, b"request").unwrap();
337 let main_reply = reply(&state(), 7994, 13, false);
338 assert!(generic_origin
339 .decode_verified_transport_reply(&main_reply)
340 .is_err());
341 assert_eq!(
342 decode_verified_reply(&s, 7994, &main_reply)
343 .unwrap_err()
344 .code,
345 "response_key"
346 );
347 }
348 #[test]
349 fn wrong_owned_keys_commands_modes_and_lengths_do_not_admit_cookie() {
350 let s = state();
351 for mode in [5, 13] {
352 let packet = reply(&s, 7994, mode, false);
353 let mut wrong = state();
354 wrong.session_key.fill(0);
355 wrong.response_key.fill(0);
356 assert_eq!(
357 decode_verified_reply(&wrong, 7994, &packet)
358 .unwrap_err()
359 .code,
360 "decrypt"
361 );
362 }
363 assert_eq!(
364 decode_verified_reply(&s, 8069, &reply(&s, 7994, 5, false))
365 .unwrap_err()
366 .code,
367 "command"
368 );
369 assert_eq!(
370 decode_verified_reply(&s, 7994, &reply(&s, 7994, 11, false))
371 .unwrap_err()
372 .value,
373 Some(11)
374 );
375 let mut short = reply(&s, 7994, 13, false);
376 short.pop();
377 assert_eq!(
378 decode_verified_reply(&s, 7994, &short).unwrap_err().code,
379 "decrypt"
380 );
381 }
382 #[test]
383 fn diagnostics_never_copy_underlying_error_text() {
384 let error = CallHostRpcError::from_cause(
385 CallHostStage::Handshake,
386 &anyhow::anyhow!("synthetic-private-key-and-body"),
387 );
388 assert_eq!(error.to_string(), "Call host handshake failed: rejected");
389 let timeout = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::TimedOut));
390 assert_eq!(
391 CallHostRpcError::from_cause(CallHostStage::Exchange, &timeout).code,
392 "timeout"
393 );
394 assert!(
395 CallHostRpcError::from_cause(CallHostStage::Exchange, &timeout).may_have_submitted()
396 );
397 assert!(!error.may_have_submitted());
398 let rejected = crate::mmtls::validate_short_route("/bad").unwrap_err();
399 let diagnostic = CallHostRpcError::from_cause(CallHostStage::Exchange, &rejected);
400 assert_eq!(diagnostic.code, "unsupported_route");
401 assert!(!diagnostic.may_have_submitted());
402 }
403
404 #[test]
405 fn call_auth_retries_a_crypto_handshake_once_with_the_remaining_budget() {
406 let timeout = Duration::from_secs(30);
407 let mut attempts = Vec::new();
408 let session = connect_call_session(CallHostRoute::AppAuth, timeout, |remaining| {
409 attempts.push(remaining);
410 if attempts.len() == 1 {
411 return Err(openssl::error::ErrorStack::get().into());
412 }
413 Ok("fresh session")
414 });
415 assert_eq!(session.unwrap(), "fresh session");
416 assert_eq!(attempts.len(), 2);
417 assert_eq!(attempts[0], timeout);
418 assert!(!attempts[1].is_zero() && attempts[1] <= timeout);
419 }
420
421 #[test]
422 fn call_handshake_retry_is_limited_to_call_auth_crypto_failures() {
423 let timeout = Duration::from_secs(30);
424 for (route, budget, crypto_failure, expected_attempts) in [
425 (CallHostRoute::AppAuth, timeout, true, 2),
426 (CallHostRoute::LiveSync, timeout, true, 1),
427 (CallHostRoute::AppAuth, timeout, false, 1),
428 (CallHostRoute::AppAuth, Duration::ZERO, true, 1),
429 ] {
430 let mut attempts = 0;
431 let error = connect_call_session::<()>(route, budget, |_| {
432 attempts += 1;
433 if crypto_failure {
434 return Err(openssl::error::ErrorStack::get().into());
435 }
436 Err(anyhow::anyhow!(
437 "Pinned server signature verification failed"
438 ))
439 })
440 .unwrap_err();
441 assert_eq!(attempts, expected_attempts);
442 assert_eq!(error.stage, CallHostStage::Handshake);
443 assert!(!error.may_have_submitted());
444 }
445 }
446}