use crate::{business, crypto, AccountState, MAX_MESSAGE_BYTES};
use openssl::symm::Mode;
use zeroize::Zeroizing;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum CallHostStage {
Route,
Handshake,
Prepare,
Exchange,
Decode,
}
impl CallHostStage {
pub const fn as_str(self) -> &'static str {
match self {
Self::Route => "route",
Self::Handshake => "handshake",
Self::Prepare => "prepare",
Self::Exchange => "exchange",
Self::Decode => "decode",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CallHostRpcError {
pub stage: CallHostStage,
pub code: &'static str,
pub value: Option<i64>,
}
impl std::fmt::Display for CallHostRpcError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Call host {} failed: {}", self.stage.as_str(), self.code)?;
if let Some(value) = self.value {
write!(f, " ({value})")?;
}
Ok(())
}
}
impl std::error::Error for CallHostRpcError {}
impl CallHostRpcError {
pub(crate) fn new(stage: CallHostStage, code: &'static str, value: Option<i64>) -> Self {
Self { stage, code, value }
}
pub fn may_have_submitted(&self) -> bool {
matches!(self.stage, CallHostStage::Exchange | CallHostStage::Decode)
&& self.code != "unsupported_route"
}
pub(crate) fn from_cause(stage: CallHostStage, cause: &anyhow::Error) -> Self {
let mut result = Self::new(stage, "rejected", None);
if cause.is::<crate::mmtls::UnsupportedNativeRoute>() {
result.code = "unsupported_route";
} else if let Some(http) = cause.downcast_ref::<reqwest::Error>() {
result.code = if http.is_timeout() {
"timeout"
} else if http.is_connect() {
"connect"
} else if let Some(status) = http.status() {
result.value = Some(i64::from(status.as_u16()));
"http_status"
} else {
"http_transport"
};
} else if let Some(io) = cause.downcast_ref::<std::io::Error>() {
result.code = match io.kind() {
std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock => "timeout",
std::io::ErrorKind::ConnectionRefused => "connection_refused",
std::io::ErrorKind::ConnectionReset => "connection_reset",
std::io::ErrorKind::UnexpectedEof => "unexpected_eof",
std::io::ErrorKind::PermissionDenied => "permission_denied",
std::io::ErrorKind::ConnectionAborted => "connection_aborted",
std::io::ErrorKind::NetworkUnreachable => "network_unreachable",
std::io::ErrorKind::HostUnreachable => "host_unreachable",
std::io::ErrorKind::AddrNotAvailable => "address_unavailable",
std::io::ErrorKind::BrokenPipe => "broken_pipe",
_ => "io",
};
} else if cause.is::<openssl::error::ErrorStack>() {
result.code = "openssl_crypto";
} else if let Some((code, value)) = cause
.chain()
.find_map(|error| known_transport_literal(&error.to_string()))
{
result.code = code;
result.value = value;
}
result
}
}
fn known_transport_literal(message: &str) -> Option<(&'static str, Option<i64>)> {
let code = match message {
"Unsupported native server host" => "host_policy",
"Invalid receive budget" => "receive_budget_configuration",
"Invalid MMTLS timeout" => "timeout_configuration",
"Native connection failed" => "connect_failed",
"MMTLS deadline exceeded" => "deadline",
"MMTLS connection closed" => "connection_closed",
"Unexpected MMTLS header" => "record_header",
"Receive overflow" | "MMTLS receive budget exceeded" => "receive_budget",
"Server returned MMTLS alert" => "server_alert",
"Expected handshake" => "expected_server_hello",
"Unsupported ServerHello" => "server_hello_selection",
"Unoffered key" => "unoffered_key",
"Duplicate extension" => "duplicate_extension",
"Invalid ECDH point" => "ecdh_point",
"Missing ECDH selection" => "missing_ecdh_selection",
"Missing certificate selection" => "missing_certificate_selection",
"Expected signature record" => "expected_signature_record",
"Unsupported MMTLS certificate version" => "certificate_version",
"Unoffered MMTLS pin" => "unoffered_pin",
"Unsupported signature pin curve" => "signature_curve",
"Pinned server signature verification failed" => "signature_verification",
"Expected ticket record" => "expected_ticket_record",
"Expected tickets" => "expected_tickets",
"Invalid ticket count" => "ticket_count",
"Invalid shortlink ticket" => "ticket_shape",
"Expected Finished" => "expected_finished_record",
"Server Finished verification failed" => "finished_verification",
"Expected NOOP reply" => "expected_noop_record",
"Unexpected NOOP reply" => "noop_reply",
"Truncated structure" => "truncated_structure",
"Integer too wide" => "integer_width",
"Trailing structure bytes" => "trailing_structure",
"Unexpected handshake message" => "handshake_message",
"Missing authentication tag" => "authentication_tag_missing",
"Invalid IV" | "Invalid GCM nonce" => "iv_shape",
"Invalid GCM key" | "Expected AES192 key" => "key_shape",
"Invalid GCM envelope" => "gcm_envelope",
"Invalid HKDF size" => "hkdf_size",
"Invalid MMTLS record" => "record_shape",
"Section too large" | "MMTLS plaintext too large" | "Payload too large" => "request_size",
"Shortlink receive budget exceeded" | "Shortlink plaintext too large" => {
"shortlink_reply_size"
}
"Invalid shortlink header" => "shortlink_header",
"Invalid shortlink sequence" => "shortlink_sequence",
"Unsupported shortlink selection" => "shortlink_selection",
"Unsupported shortlink extensions" => "shortlink_extensions",
"Shortlink Finished failed" => "shortlink_finished",
"Unexpected shortlink close" => "shortlink_close",
"Invalid longlink envelope" => "longlink_envelope",
_ => {
let value = message.strip_prefix("Native shortlink HTTP status ")?;
if value.len() != 3 || !value.bytes().all(|byte| byte.is_ascii_digit()) {
return None;
}
let status = value.parse::<u16>().ok()?;
return (100..=599)
.contains(&status)
.then_some(("http_status", Some(i64::from(status))));
}
};
Some((code, None))
}
fn decode_error(code: &'static str, value: Option<i64>) -> CallHostRpcError {
CallHostRpcError::new(CallHostStage::Decode, code, value)
}
pub(crate) fn decode_verified_reply(
state: &AccountState,
cgi: u16,
input: &[u8],
) -> Result<(Vec<u8>, Vec<u8>), CallHostRpcError> {
if input.len() > MAX_MESSAGE_BYTES {
return Err(decode_error("reply_size", None));
}
let envelope = business::split(input).map_err(|_| decode_error("envelope", None))?;
if envelope.return_code != 0 {
return Err(decode_error(
"business_return",
Some(i64::from(envelope.return_code)),
));
}
if envelope.cgi != u64::from(cgi) {
return Err(decode_error("command", None));
}
if ![1, 2].contains(&envelope.compression) {
return Err(decode_error(
"compression",
Some(i64::from(envelope.compression)),
));
}
let body = Zeroizing::new(
match envelope.encryption {
13 if state.response_key.len() == 24 => {
crypto::envelope_open(&envelope.payload, &state.response_key, b"")
}
5 if state.session_key.len() == 16 => {
crypto::cbc(&envelope.payload, &state.session_key, Mode::Decrypt)
}
5 | 13 => {
return Err(decode_error(
"response_key",
Some(i64::from(envelope.encryption)),
))
}
mode => return Err(decode_error("encryption_mode", Some(i64::from(mode)))),
}
.map_err(|_| decode_error("decrypt", Some(i64::from(envelope.encryption))))?,
);
if body.len() != envelope.pre_encryption_length {
return Err(decode_error("decrypted_length", None));
}
let plain = Zeroizing::new(if envelope.compression == 1 {
crypto::inflate(&body).map_err(|_| decode_error("inflate", None))?
} else {
body.to_vec()
});
if plain.len() != envelope.original_length {
return Err(decode_error("plaintext_length", None));
}
Ok((plain.to_vec(), envelope.cookie))
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> AccountState {
AccountState {
schema: 1,
device: crate::DeviceProfile {
device_id: vec![b'x'; 16],
device_type: "Linux".into(),
client_version: -227271398,
},
uin: 1,
own_username: "synthetic".into(),
cookie: vec![],
session_key: vec![7; 16],
request_key: vec![8; 24],
response_key: vec![9; 24],
auto_auth_key: vec![],
key_info: vec![],
database_password: vec![],
auth_snapshot: crate::AuthSnapshot::default(),
long_host: "long.weixin.qq.com".into(),
short_host: "short.weixin.qq.com".into(),
}
}
fn reply(state: &AccountState, cgi: u16, mode: u8, compressed: bool) -> Vec<u8> {
let plain = b"synthetic call reply";
let body = if compressed {
crypto::deflate(plain).unwrap()
} else {
plain.to_vec()
};
let cipher = match mode {
13 => crypto::envelope_seal(&body, &state.response_key, b"", [3; 12]).unwrap(),
_ => crypto::cbc(&body, &state.session_key, Mode::Encrypt).unwrap(),
};
let cookie = b"new";
let mut tail = Vec::new();
for n in [u64::from(cgi), plain.len() as u64, body.len() as u64, 0, 0] {
tail.extend(crate::protobuf::varint(n));
}
let size = 10 + cookie.len() + tail.len();
assert!(size < 64);
let flags = (u16::from(mode) << 12)
| ((cookie.len() as u16) << 8)
| ((size as u16) << 2)
| if compressed { 1 } else { 2 };
let mut raw = flags.to_le_bytes().to_vec();
raw.extend(0i32.to_be_bytes());
raw.extend(state.uin.to_be_bytes());
raw.extend(cookie);
raw.extend(tail);
raw.extend(cipher);
raw
}
#[test]
fn call_reply_mode_is_independent_while_generic_policy_stays_strict() {
let mut s = state();
let generic = business::AuthenticatedPacket::prepare(&s, 7994, b"request").unwrap();
let origin_reply = reply(&s, 7994, 5, false);
assert!(generic
.decode_verified_transport_reply(&origin_reply)
.is_err());
assert_eq!(
decode_verified_reply(&s, 7994, &origin_reply).unwrap(),
(b"synthetic call reply".to_vec(), b"new".to_vec())
);
for compressed in [false, true] {
assert_eq!(
decode_verified_reply(&s, 7994, &reply(&s, 7994, 13, compressed))
.unwrap()
.0,
b"synthetic call reply"
);
}
s.request_key.clear();
s.response_key.clear();
let generic_origin = business::AuthenticatedPacket::prepare(&s, 7994, b"request").unwrap();
let main_reply = reply(&state(), 7994, 13, false);
assert!(generic_origin
.decode_verified_transport_reply(&main_reply)
.is_err());
assert_eq!(
decode_verified_reply(&s, 7994, &main_reply)
.unwrap_err()
.code,
"response_key"
);
}
#[test]
fn wrong_owned_keys_commands_modes_and_lengths_do_not_admit_cookie() {
let s = state();
for mode in [5, 13] {
let packet = reply(&s, 7994, mode, false);
let mut wrong = state();
wrong.session_key.fill(0);
wrong.response_key.fill(0);
assert_eq!(
decode_verified_reply(&wrong, 7994, &packet)
.unwrap_err()
.code,
"decrypt"
);
}
assert_eq!(
decode_verified_reply(&s, 8069, &reply(&s, 7994, 5, false))
.unwrap_err()
.code,
"command"
);
assert_eq!(
decode_verified_reply(&s, 7994, &reply(&s, 7994, 11, false))
.unwrap_err()
.value,
Some(11)
);
let mut short = reply(&s, 7994, 13, false);
short.pop();
assert_eq!(
decode_verified_reply(&s, 7994, &short).unwrap_err().code,
"decrypt"
);
}
#[test]
fn diagnostics_never_copy_underlying_error_text() {
let error = CallHostRpcError::from_cause(
CallHostStage::Handshake,
&anyhow::anyhow!("synthetic-private-key-and-body"),
);
assert_eq!(error.to_string(), "Call host handshake failed: rejected");
let timeout = anyhow::Error::new(std::io::Error::from(std::io::ErrorKind::TimedOut));
assert_eq!(
CallHostRpcError::from_cause(CallHostStage::Exchange, &timeout).code,
"timeout"
);
assert!(
CallHostRpcError::from_cause(CallHostStage::Exchange, &timeout).may_have_submitted()
);
assert!(!error.may_have_submitted());
let rejected = crate::mmtls::validate_short_route("/bad").unwrap_err();
let diagnostic = CallHostRpcError::from_cause(CallHostStage::Exchange, &rejected);
assert_eq!(diagnostic.code, "unsupported_route");
assert!(!diagnostic.may_have_submitted());
}
}