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