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 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/// Only fixed labels and optional numeric protocol/status codes are retained.
30/// Underlying error text, hosts, response bytes and keys are never included.
31#[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    /// False only when the call request is known not to have been submitted.
52    /// Exchange/decode failures remain uncertain, including connection timeouts.
53    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
99// Only exact messages emitted by the maintained session/framing/crypto checks
100// are admitted. Formatting unknown errors is transient; none of that text is
101// returned or persisted. Context strings and payload-bearing suffixes cannot
102// become diagnostic labels.
103fn 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
172/// Only call after authenticated MMTLS has verified these bytes. Keys belong to
173/// the same exclusively borrowed account state used to prepare this request;
174/// account/logout generation admission remains the embedding actor's job.
175/// Cookies are returned only after all envelope, decryption and length checks.
176pub(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    // Response-header construction is independent of the request packer. Its
258    // five varints follow CGI, original size, encrypted-plain size, reserveds.
259    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}