Skip to main content

wechat_protocol/calls/
host_policy.rs

1//! Inspect the active authentication response before selecting a call host RPC.
2//! A selected Ax route must not silently fall back to ordinary host packing.
3//! This module selects no keys, prepares no request and performs no transport.
4
5use crate::{protobuf as p, AuthSnapshot};
6use anyhow::{ensure, Result};
7use zeroize::Zeroize;
8
9const MAX_AUTH_BYTES: usize = 1024 * 1024;
10const MAX_ENTRIES: usize = 4096;
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq)]
13pub enum CallHostPolicy {
14    /// No active per-CGI Ax entry; the host's ordinary transport policy applies.
15    Ordinary,
16    /// An active Ax entry selects a different body/header policy. An adapter that
17    /// does not implement it must return unsupported, never use Ordinary.
18    AxSelected,
19}
20
21/// Fixed host CGI/path pairs for the selected call service. Cloud operation IDs
22/// remain inside the transfer envelope, never substituted for a host CGI.
23#[derive(Clone, Copy, Debug, PartialEq, Eq)]
24pub enum CallHostRoute {
25    AppAuth,
26    RingingAck,
27    Transfer,
28    Invite,
29    Cancel,
30    LiveSync,
31}
32impl CallHostRoute {
33    pub const fn command(self) -> u16 {
34        match self {
35            Self::AppAuth => 7994,
36            Self::RingingAck => 8069,
37            Self::Transfer => 11421,
38            Self::Invite => 10342,
39            Self::Cancel => 9332,
40            Self::LiveSync => 9262,
41        }
42    }
43    pub const fn path(self) -> &'static str {
44        match self {
45            Self::AppAuth => "/cgi-bin/micromsg-bin/mm_ilink_cloud_app_auth",
46            Self::RingingAck => "/cgi-bin/micromsg-bin/voipilinkack",
47            Self::Transfer => "/cgi-bin/micromsg-bin/mmilinktransfer",
48            Self::Invite => "/cgi-bin/micromsg-bin/voipilinkinvite",
49            Self::Cancel => "/cgi-bin/micromsg-bin/voipilinkcancel",
50            Self::LiveSync => "/cgi-bin/micromsg-bin/voipilinklivesync",
51        }
52    }
53}
54/// Identify the supported terminal operations from the owned request itself.
55/// A generic transfer is not a teardown: delegate command/path and nested task
56/// command must all identify Exit. This does not replace account/room fencing.
57pub fn is_teardown_request(route: CallHostRoute, common: &[u8]) -> Result<bool> {
58    if route == CallHostRoute::Cancel {
59        return Ok(true);
60    }
61    is_terminal_transfer(route, common, super::signaling::CallOperation::Exit)
62}
63/// An explicit broker-winning callee rejection has a separate admission ticket.
64/// This recognizer does not grant that permission or turn an expiry into Reject.
65pub fn is_reject_request(route: CallHostRoute, common: &[u8]) -> Result<bool> {
66    is_terminal_transfer(route, common, super::signaling::CallOperation::Reject)
67}
68fn is_terminal_transfer(
69    route: CallHostRoute,
70    common: &[u8],
71    operation: super::signaling::CallOperation,
72) -> Result<bool> {
73    if route != CallHostRoute::Transfer {
74        return Ok(false);
75    }
76    let command = operation.command_id();
77    let rejecting = operation == super::signaling::CallOperation::Reject;
78    let common = super::signaling::CommonRequest::decode(common)?;
79    if !common.unknown_fields.is_empty() {
80        return Ok(false);
81    }
82    let body = common
83        .body
84        .ok_or_else(|| anyhow::anyhow!("Missing call transfer body"))?;
85    let delegate = Fields::decode(&body)?;
86    if delegate.0.iter().any(|f| !(1..=5).contains(&f.number))
87        || delegate.required_u64(1)? != 1901
88        || delegate.required_u64(2)? != u64::from(command)
89        || delegate.required_bytes(3)? != b"wechat"
90        || delegate.required_bytes(4)? != operation.path().as_bytes()
91    {
92        return Ok(false);
93    }
94    let frame = super::app_frame::TaskFrame::decode(delegate.required_bytes(5)?, command)?;
95    const INTEGER_KEYS: &[u32] = &[
96        1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 17, 18, 20, 21, 22, 23, 25, 26, 28, 29, 30, 31,
97    ];
98    if frame.command != command
99        || frame.integers.keys().any(|n| !INTEGER_KEYS.contains(n))
100        || frame.strings.keys().any(|n| ![14, 24, 27].contains(n))
101        || frame.integers.get(&1) != Some(&1)
102        || frame.integers.get(&26) != Some(&4)
103        || [7, 8, 21, 25]
104            .iter()
105            .any(|n| frame.integers.get(n) != Some(&0))
106        || frame.strings.get(&24).map(Vec::as_slice) != Some(b"wechat")
107        || frame.strings.get(&27).is_none_or(Vec::is_empty)
108    {
109        return Ok(false);
110    }
111    let clear = zeroize::Zeroizing::new(super::app_frame::lz4_decode(&frame.payload, 64 * 1024)?);
112    let cloud = Fields::decode(&clear)?;
113    if cloud.0.iter().any(|f| ![1, 2].contains(&f.number)) {
114        return Ok(false);
115    }
116    let base = Fields::decode(cloud.required_bytes(1)?)?;
117    let uin = base.required_u64(1)?;
118    if base.0.iter().any(|f| ![1, 2, 3].contains(&f.number))
119        || uin == 0
120        || base.required_bytes(2)?.is_empty()
121        || i32::try_from(base.required_u64(3)?).is_err()
122        || frame.integers.get(&2) != Some(&uin)
123        || frame.integers.get(&22) != Some(&uin)
124    {
125        return Ok(false);
126    }
127    let exit = Fields::decode(cloud.required_bytes(2)?)?;
128    let last_field = if rejecting { 6 } else { 8 };
129    if exit.0.iter().any(|f| !(1..=last_field).contains(&f.number))
130        || exit.required_bytes(1)?.is_empty()
131        || exit.required_bytes(2)?.is_empty()
132        || exit.required_u64(3)? == 0
133        || exit.required_u64(4)? == 0
134        || [5, 6].iter().any(|n| {
135            exit.required_u64(*n)
136                .map_or(true, |v| u32::try_from(v).is_err())
137        })
138        || if rejecting {
139            exit.required_u64(6)? != 1
140        } else {
141            u32::try_from(exit.required_u64(7)?).is_err() || exit.required_u64(8)? > 1
142        }
143    {
144        return Ok(false);
145    }
146    let sdk = Fields::decode(exit.required_bytes(1)?)?;
147    if sdk.0.iter().any(|f| ![1, 2].contains(&f.number))
148        || u32::try_from(sdk.required_u64(1)?).is_err()
149        || sdk.required_bytes(2)?.is_empty()
150        || std::str::from_utf8(sdk.required_bytes(2)?).is_err()
151    {
152        return Ok(false);
153    }
154    let group = Fields::decode(exit.required_bytes(2)?)?;
155    if group.0.iter().any(|f| !(1..=7).contains(&f.number))
156        || group.required_bytes(1)? != b"wechat"
157        || group.required_bytes(3)? != b"voip-2p"
158        || group.required_u64(5)? != 1
159    {
160        return Ok(false);
161    }
162    if let Some(value) = crate::protobuf::optional(&group.0, 2)? {
163        if !matches!(value, crate::protobuf::Value::Varint(_)) {
164            return Ok(false);
165        }
166    }
167    for field in [4, 6] {
168        if let Some(value) = group.optional_bytes(field)? {
169            if value.len() > 1024 || std::str::from_utf8(value).is_err() {
170                return Ok(false);
171            }
172        }
173    }
174    group.optional_u32(7)?;
175    Ok(true)
176}
177
178impl From<super::personal::HostOperation> for CallHostRoute {
179    fn from(value: super::personal::HostOperation) -> Self {
180        match value {
181            super::personal::HostOperation::RingingAck => Self::RingingAck,
182            super::personal::HostOperation::Invite => Self::Invite,
183            super::personal::HostOperation::Cancel => Self::Cancel,
184            super::personal::HostOperation::LiveSync => Self::LiveSync,
185        }
186    }
187}
188
189/// Inspect the current root auth response, not retained `ax_auth` bytes. Presence
190/// of a historical section does not activate it when the current update mask
191/// excludes it. Callers must still fence account/logout generations and honor
192/// any separately configured host override before dispatching.
193pub fn selected_policy(snapshot: &AuthSnapshot, command: u16) -> Result<CallHostPolicy> {
194    ensure!(
195        matches!(command, 7994 | 8069 | 11421 | 10342 | 9332 | 9262),
196        "Unsupported call host command"
197    );
198    let root = Fields::decode(&snapshot.response)?;
199    let base = Fields::decode(root.required_bytes(1)?)?;
200    ensure!(
201        i32::try_from(base.required_u64(1)? as i64)? == 0,
202        "Rejected call auth snapshot"
203    );
204    let mask = u32::try_from(root.required_u64(2)?)?;
205    ensure!(
206        mask == snapshot.update_mask,
207        "Call auth snapshot mask mismatch"
208    );
209    if mask & 8 == 0 {
210        return Ok(CallHostPolicy::Ordinary);
211    }
212    let Some(section) = root.optional_bytes(6)? else {
213        return Ok(CallHostPolicy::Ordinary);
214    };
215    let list = Fields::decode(section)?;
216    let mut count = 0;
217    let mut selected = CallHostPolicy::Ordinary;
218    for field in &list.0 {
219        if field.number != 2 {
220            continue;
221        }
222        count += 1;
223        ensure!(count <= MAX_ENTRIES, "Too many call host auth entries");
224        let p::Value::Bytes(raw) = &field.value else {
225            anyhow::bail!("Invalid call host auth entry wire type");
226        };
227        let entry = Fields::decode(raw)?;
228        // Match native scalar defaults and validate known nested wire shapes.
229        entry.optional_u32(1)?;
230        let cgi = entry.optional_u32(4)?;
231        entry.optional_bytes(3)?;
232        for number in [2, 5] {
233            let nested = Fields::decode(entry.optional_bytes(number)?.unwrap_or_default())?;
234            // The selected producer uses buffer field2; field1 is not a length gate.
235            nested.optional_bytes(2)?;
236        }
237        if cgi == u32::from(command) {
238            selected = CallHostPolicy::AxSelected;
239        }
240    }
241    Ok(selected)
242}
243
244/// Successful protobuf decoding creates temporary copies of auth sections. Clear
245/// those copies when this wrapper drops, including downstream validation errors.
246/// Never include auth bytes in error text or diagnostics.
247struct Fields(Vec<p::Field>);
248impl Fields {
249    fn decode(raw: &[u8]) -> Result<Self> {
250        ensure!(raw.len() <= MAX_AUTH_BYTES, "Call auth snapshot too large");
251        let result = Self(p::decode(raw)?);
252        ensure!(result.0.len() <= MAX_ENTRIES, "Too many call auth fields");
253        Ok(result)
254    }
255    fn optional_bytes(&self, number: u32) -> Result<Option<&[u8]>> {
256        p::optional_data(&self.0, number)
257    }
258    fn required_bytes(&self, number: u32) -> Result<&[u8]> {
259        self.optional_bytes(number)?
260            .ok_or_else(|| anyhow::anyhow!("Missing call auth bytes field {number}"))
261    }
262    fn required_u64(&self, number: u32) -> Result<u64> {
263        match p::optional(&self.0, number)? {
264            Some(p::Value::Varint(value)) => Ok(*value),
265            _ => anyhow::bail!("Missing or invalid call auth integer {number}"),
266        }
267    }
268    fn optional_u32(&self, number: u32) -> Result<u32> {
269        match p::optional(&self.0, number)? {
270            None => Ok(0),
271            Some(p::Value::Varint(value)) => Ok(u32::try_from(*value)?),
272            _ => anyhow::bail!("Invalid call auth scalar {number}"),
273        }
274    }
275}
276impl Drop for Fields {
277    fn drop(&mut self) {
278        for field in &mut self.0 {
279            if let p::Value::Bytes(bytes) = &mut field.value {
280                bytes.zeroize();
281            }
282        }
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    fn snapshot(mask: u32, selected: u32) -> AuthSnapshot {
290        let entry = p::message(&[p::uint(4, u64::from(selected))]).unwrap();
291        let list = p::bytes(2, &entry);
292        let mut snapshot = AuthSnapshot::default();
293        snapshot.update_mask = mask;
294        snapshot.response = p::message(&[
295            p::bytes(1, &[8, 0]),
296            p::uint(2, u64::from(mask)),
297            p::bytes(6, &list),
298        ])
299        .unwrap();
300        snapshot
301    }
302    #[test]
303    fn active_exact_command_selects_ax_and_unrelated_command_does_not() {
304        let state = snapshot(8, 7994);
305        assert_eq!(
306            selected_policy(&state, 7994).unwrap(),
307            CallHostPolicy::AxSelected
308        );
309        assert_eq!(
310            selected_policy(&state, 10342).unwrap(),
311            CallHostPolicy::Ordinary
312        );
313        let mut retained = snapshot(0, 7994);
314        retained.ax_auth = vec![0xff; 32];
315        assert_eq!(
316            selected_policy(&retained, 7994).unwrap(),
317            CallHostPolicy::Ordinary
318        );
319        retained.update_mask = 8;
320        assert!(selected_policy(&retained, 7994).is_err());
321        assert!(selected_policy(&AuthSnapshot::default(), 7994).is_err());
322    }
323
324    #[test]
325    fn expired_scope_transfer_exception_requires_matching_exit_frames() {
326        fn request(delegate: u64, path: &[u8], task: u32) -> Vec<u8> {
327            request_override(delegate, path, task, None)
328        }
329        fn request_override(
330            delegate: u64,
331            path: &[u8],
332            task: u32,
333            extra: Option<(u32, u64)>,
334        ) -> Vec<u8> {
335            let sdk = crate::protobuf::message(&[
336                crate::protobuf::uint(1, 0x050000cb),
337                crate::protobuf::bytes(2, b"0x1000000"),
338            ])
339            .unwrap();
340            let group = crate::protobuf::message(&[
341                crate::protobuf::bytes(1, b"wechat"),
342                crate::protobuf::uint(2, 99),
343                crate::protobuf::bytes(3, b"voip-2p"),
344                crate::protobuf::bytes(4, b"group"),
345                crate::protobuf::uint(5, 1),
346                crate::protobuf::bytes(6, b""),
347                crate::protobuf::uint(7, 0),
348            ])
349            .unwrap();
350            let mut terminal = vec![
351                crate::protobuf::bytes(1, &sdk),
352                crate::protobuf::bytes(2, &group),
353                crate::protobuf::uint(3, 7),
354                crate::protobuf::uint(4, 9),
355                crate::protobuf::uint(5, 0),
356                crate::protobuf::uint(6, 1),
357            ];
358            if delegate != 3095 {
359                terminal.extend([crate::protobuf::uint(7, 0), crate::protobuf::uint(8, 1)]);
360            }
361            let exit = crate::protobuf::message(&terminal).unwrap();
362            let base = crate::protobuf::message(&[
363                crate::protobuf::uint(1, 17),
364                crate::protobuf::bytes(2, b"Ldevice"),
365                crate::protobuf::uint(3, 1),
366            ])
367            .unwrap();
368            let cloud = crate::protobuf::message(&[
369                crate::protobuf::bytes(1, &base),
370                crate::protobuf::bytes(2, &exit),
371            ])
372            .unwrap();
373            let mut integers = std::collections::BTreeMap::from([
374                (1, 1),
375                (2, 17),
376                (7, 0),
377                (8, 0),
378                (21, 0),
379                (22, 17),
380                (25, 0),
381                (26, 4),
382            ]);
383            if let Some((field, value)) = extra {
384                integers.insert(field, value);
385            }
386            let frame = super::super::app_frame::TaskFrame {
387                command: task,
388                integers,
389                strings: std::collections::BTreeMap::from([
390                    (24, b"wechat".to_vec()),
391                    (27, b"server".to_vec()),
392                ]),
393                payload: super::super::app_frame::lz4_literal(&cloud).unwrap(),
394            }
395            .encode()
396            .unwrap();
397            let body = crate::protobuf::message(&[
398                crate::protobuf::uint(1, 1901),
399                crate::protobuf::uint(2, delegate),
400                crate::protobuf::bytes(3, b"wechat"),
401                crate::protobuf::bytes(4, path),
402                crate::protobuf::bytes(5, &frame),
403            ])
404            .unwrap();
405            super::super::signaling::CommonRequest {
406                base_request: Some(vec![]),
407                body: Some(body),
408                unknown_fields: vec![],
409            }
410            .encode()
411            .unwrap()
412        }
413        let exit = super::super::signaling::CallOperation::Exit
414            .path()
415            .as_bytes();
416        let join = super::super::signaling::CallOperation::Join
417            .path()
418            .as_bytes();
419        assert!(is_teardown_request(CallHostRoute::Transfer, &request(3380, exit, 3380)).unwrap());
420        for raw in [
421            request(3361, join, 3361),
422            request(3380, join, 3380),
423            request(3380, exit, 3361),
424            request(3361, exit, 3380),
425        ] {
426            assert!(!is_teardown_request(CallHostRoute::Transfer, &raw).unwrap_or(false));
427        }
428        for extra in [(99, 0), (7, 11), (26, 0)] {
429            assert!(!is_teardown_request(
430                CallHostRoute::Transfer,
431                &request_override(3380, exit, 3380, Some(extra))
432            )
433            .unwrap_or(false));
434        }
435        let reject = super::super::signaling::CallOperation::Reject
436            .path()
437            .as_bytes();
438        assert!(is_reject_request(CallHostRoute::Transfer, &request(3095, reject, 3095)).unwrap());
439        assert!(
440            !is_teardown_request(CallHostRoute::Transfer, &request(3095, reject, 3095)).unwrap()
441        );
442        for raw in [
443            request(3380, exit, 3380),
444            request(3095, join, 3095),
445            request(3095, reject, 3361),
446        ] {
447            assert!(!is_reject_request(CallHostRoute::Transfer, &raw).unwrap_or(false));
448        }
449        for extra in [(99, 0), (7, 11), (26, 0)] {
450            assert!(!is_reject_request(
451                CallHostRoute::Transfer,
452                &request_override(3095, reject, 3095, Some(extra))
453            )
454            .unwrap_or(false));
455        }
456        assert!(!is_teardown_request(CallHostRoute::AppAuth, &[]).unwrap());
457        assert!(is_teardown_request(CallHostRoute::Transfer, &[0xff]).is_err());
458    }
459}