Skip to main content

wechat_protocol/calls/
host_transport.rs

1//! Call-specific ordinary reply policy and non-sensitive transport diagnostics.
2//! A verified host transport may return either owned ordinary key mode. This
3//! does not change request selection or authorize Ax/app/media key substitution.
4
5use 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/// Only fixed labels and optional numeric protocol/status codes are retained.
32/// Underlying error text, hosts, response bytes and keys are never included.
33#[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    /// False only when the call request is known not to have been submitted.
54    /// Exchange/decode failures remain uncertain, including connection timeouts.
55    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    // A fresh, fully verified handshake can recover call authentication before
110    // any business request has been prepared or handed to the transport.
111    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
124// Only exact messages emitted by the maintained session/framing/crypto checks
125// are admitted. Formatting unknown errors is transient; none of that text is
126// returned or persisted. Context strings and payload-bearing suffixes cannot
127// become diagnostic labels.
128fn 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
197/// Only call after authenticated MMTLS has verified these bytes. Keys belong to
198/// the same exclusively borrowed account state used to prepare this request;
199/// account/logout generation admission remains the embedding actor's job.
200/// Cookies are returned only after all envelope, decryption and length checks.
201pub(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    // Response-header construction is independent of the request packer. Its
283    // five varints follow CGI, original size, encrypted-plain size, reserveds.
284    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}