Skip to main content

wechat_protocol/
large_cdn_config.rs

1use super::{decode_cdn_config, public_ipv4, CdnConfig, CdnLocation};
2use crate::{protobuf as p, AccountState};
3use anyhow::{bail, ensure, Result};
4use std::{collections::BTreeSet, net::Ipv4Addr};
5
6const MAX_BOOTSTRAP_BYTES: usize = 512 * 1024;
7const MAX_RULE_BYTES: usize = 256 * 1024;
8const MAX_CONFIGS: usize = 64;
9const MAX_RULES: usize = 512;
10
11#[derive(Clone, Copy, Debug, Eq, PartialEq)]
12pub enum LargeCdnRoute {
13    Upload,
14    Download,
15    VideoDownload,
16}
17
18/// Decode the safe C2C routing table in an authenticated GetCDNDns response.
19/// The safe CDN UIN belongs to that routing table and may differ from the account UIN.
20pub fn decode_large_cdn_config(
21    input: &[u8],
22    account: &AccountState,
23    route: LargeCdnRoute,
24) -> Result<CdnConfig> {
25    ensure!(
26        input.len() <= MAX_BOOTSTRAP_BYTES,
27        "CDN bootstrap too large"
28    );
29    // The normal group binds this authenticated response to the active account.
30    // Its credentials are separate from the safe group's credentials.
31    let normal = decode_cdn_config(input, account)?;
32    let response = p::decode(input)?;
33    let safe = p::decode(p::data(&response, 7)?)?;
34    let version = number(&safe, 1)?;
35    let uin = number(&safe, 2)?;
36    ensure!(
37        uin != 0 && version == normal.version,
38        "Invalid safe CDN identity"
39    );
40    let safe_auth = p::unbuffer(p::data(&safe, 8)?)?;
41    ensure!(
42        !safe_auth.is_empty() && safe_auth.len() <= 65536,
43        "Invalid safe CDN auth key"
44    );
45
46    let raw = p::unbuffer(p::data(&response, 6)?)?;
47    ensure!(raw.len() <= MAX_RULE_BYTES, "CDN routing table too large");
48    let table = p::decode(&raw)?;
49    ensure!(
50        number(&table, 1)? == version && number(&table, 2)? == uin,
51        "Safe CDN routing identity mismatch"
52    );
53    let location = CdnLocation {
54        province: optional_number(&table, 3)?,
55        city: optional_number(&table, 4)?,
56        isp: optional_number(&table, 5)?,
57    };
58
59    // The official client sorts rules by signed sequence before taking the
60    // first match. A zero in a predicate is a wildcard, not a priority score.
61    let mut selected: Option<(i32, u32, bool)> = None;
62    for raw_rule in repeated(&table, 8, MAX_RULES)? {
63        let rule = p::decode(raw_rule)?;
64        ensure!(
65            rule.iter().all(|f| (1..=6).contains(&f.number)),
66            "Unsupported CDN routing predicate"
67        );
68        let sequence = number(&rule, 1)? as i32;
69        // Uploadbig promotes routing type to 7, independently of the actual
70        // request type. Video download retains actual type 4 and operation 2.
71        let predicates = [
72            (number(&rule, 2)?, 1),
73            (number(&rule, 3)?, 1),
74            (
75                number(&rule, 4)?,
76                if route == LargeCdnRoute::VideoDownload {
77                    4
78                } else {
79                    7
80                },
81            ),
82            (
83                number(&rule, 5)?,
84                match route {
85                    LargeCdnRoute::Upload => 1,
86                    LargeCdnRoute::Download | LargeCdnRoute::VideoDownload => 2,
87                },
88            ),
89        ];
90        let proid = number(&rule, 6)?;
91        ensure!(proid != 0, "Invalid CDN routing target");
92        if !predicates
93            .iter()
94            .all(|(predicate, actual)| *predicate == 0 || predicate == actual)
95        {
96            continue;
97        }
98        match selected {
99            Some((priority, previous, _)) if priority == sequence => {
100                selected = Some((priority, previous, true));
101            }
102            Some((priority, _, _)) if priority < sequence => {}
103            _ => selected = Some((sequence, proid, false)),
104        }
105    }
106    let (_, proid, ambiguous) =
107        selected.ok_or_else(|| anyhow::anyhow!("No large-file CDN route"))?;
108    ensure!(!ambiguous, "Ambiguous CDN routing priority");
109
110    let mut config_ids = BTreeSet::new();
111    let mut selected_config = None;
112    for raw_config in repeated(&table, 7, MAX_CONFIGS)? {
113        let config = p::decode(raw_config)?;
114        let id = number(&config, 1)?;
115        ensure!(id != 0 && config_ids.insert(id), "Ambiguous CDN config ID");
116        if id != proid {
117            continue;
118        }
119        let auth_key = p::data(&config, 8)?.to_vec();
120        let front_id = number(&config, 2)?;
121        ensure!(front_id != 0, "Missing CDN front ID");
122        let mut server_ips = Vec::new();
123        for field in config.iter().filter(|f| f.number == 3) {
124            ensure!(server_ips.len() < 32, "Too many CDN endpoints");
125            let p::Value::Varint(value) = field.value else {
126                bail!("Invalid CDN endpoint")
127            };
128            let ip = Ipv4Addr::from(wire_u32(value)?.to_le_bytes());
129            ensure!(public_ipv4(ip), "CDN endpoint is not public IPv4");
130            ensure!(!server_ips.contains(&ip), "Duplicate CDN endpoint");
131            server_ips.push(ip);
132        }
133        // This transport implements the observed cleartext HTTP carrier on 443.
134        let mut has_port_443 = false;
135        for (index, port) in config.iter().filter(|f| f.number == 7).enumerate() {
136            ensure!(index < 16, "Too many CDN ports");
137            let p::Value::Varint(value) = port.value else {
138                bail!("Invalid CDN port")
139            };
140            ensure!((1..=65535).contains(&value), "Invalid CDN port");
141            has_port_443 |= value == 443;
142        }
143        ensure!(has_port_443, "CDN route does not advertise port 443");
144        let result = CdnConfig {
145            version,
146            uin: u64::from(uin),
147            client_version: account.device.client_version as u32,
148            client_os_type: account.device.device_type.clone(),
149            auth_key,
150            server_ips,
151            front_id,
152            location: Some(location),
153        };
154        result.validate()?;
155        selected_config = Some(result);
156    }
157    selected_config.ok_or_else(|| anyhow::anyhow!("CDN routing target is missing"))
158}
159
160fn number(fields: &[p::Field], field: u32) -> Result<u32> {
161    wire_u32(p::scalar(fields, field)?)
162}
163
164fn optional_number(fields: &[p::Field], field: u32) -> Result<u32> {
165    match p::optional(fields, field)? {
166        Some(p::Value::Varint(value)) => wire_u32(*value),
167        None => Ok(0),
168        _ => bail!("Invalid CDN location number"),
169    }
170}
171
172fn wire_u32(value: u64) -> Result<u32> {
173    let low = value as u32;
174    ensure!(
175        value == u64::from(low) || value == (low as i32 as i64 as u64),
176        "Invalid 32-bit CDN number"
177    );
178    Ok(low)
179}
180
181fn repeated(fields: &[p::Field], number: u32, max: usize) -> Result<Vec<&[u8]>> {
182    let mut values = Vec::new();
183    for field in fields.iter().filter(|f| f.number == number) {
184        ensure!(values.len() < max, "Too many CDN routing records");
185        let p::Value::Bytes(value) = &field.value else {
186            bail!("Invalid CDN routing record")
187        };
188        values.push(value.as_slice());
189    }
190    ensure!(!values.is_empty(), "Missing CDN routing records");
191    Ok(values)
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::{AuthSnapshot, DeviceProfile};
198
199    const ACCOUNT_UIN: u32 = 123;
200    const SAFE_UIN: u32 = 0xf1234567;
201
202    fn state() -> AccountState {
203        AccountState {
204            schema: 1,
205            device: DeviceProfile {
206                device_id: vec![1; 16],
207                device_type: "UnifiedPCLinux synthetic x86_64".into(),
208                client_version: -227271398,
209            },
210            uin: ACCOUNT_UIN,
211            own_username: "synthetic-user".into(),
212            cookie: vec![5],
213            session_key: vec![6; 16],
214            request_key: vec![7; 24],
215            response_key: vec![8; 24],
216            auto_auth_key: vec![],
217            key_info: vec![],
218            database_password: vec![],
219            auth_snapshot: AuthSnapshot::default(),
220            long_host: "long.weixin.qq.com".into(),
221            short_host: "short.weixin.qq.com".into(),
222        }
223    }
224
225    fn rule(sequence: u32, biz: u32, app: u32, file: u32, op: u32, proid: u32) -> Vec<u8> {
226        [sequence, biz, app, file, op, proid]
227            .into_iter()
228            .enumerate()
229            .flat_map(|(i, value)| p::uint(i as u32 + 1, u64::from(value)))
230            .collect()
231    }
232
233    fn config(id: u32, ip: [u8; 4], auth: &[u8]) -> Vec<u8> {
234        [
235            p::uint(1, u64::from(id)),
236            p::uint(2, 1000 + u64::from(id)),
237            p::int32(3, u32::from_le_bytes(ip) as i32),
238            p::uint(7, 443),
239            p::bytes(8, auth),
240        ]
241        .concat()
242    }
243
244    fn bootstrap(rules: &[Vec<u8>], configs: &[Vec<u8>]) -> Vec<u8> {
245        let group = |uin| {
246            [
247                p::uint(1, 1),
248                p::uint(2, u64::from(uin)),
249                p::bytes(6, &p::bytes(1, b"8.8.8.8")),
250                p::bytes(8, &p::buffer(&[7; 73])),
251            ]
252            .concat()
253        };
254        let table = [
255            p::uint(1, 1),
256            p::int32(2, SAFE_UIN as i32),
257            p::uint(3, 1234),
258            p::uint(4, 56),
259            p::uint(5, 78),
260            configs.iter().flat_map(|v| p::bytes(7, v)).collect(),
261            rules.iter().flat_map(|v| p::bytes(8, v)).collect(),
262        ]
263        .concat();
264        [
265            p::bytes(1, &p::uint(1, 0)),
266            p::bytes(2, &group(ACCOUNT_UIN)),
267            p::bytes(7, &group(SAFE_UIN)),
268            p::bytes(6, &p::buffer(&table)),
269        ]
270        .concat()
271    }
272
273    #[test]
274    fn safe_identity_signed_ips_and_sequence_join_are_preserved() {
275        let input = bootstrap(
276            &[
277                rule(12, 1, 0, 0, 0, 4),
278                rule(2, 11, 0, 7, 0, 4),
279                rule(3, 1, 0, 7, 1, 1),
280            ],
281            &[
282                config(4, [9, 9, 9, 9], &[4; 73]),
283                config(1, [8, 8, 8, 200], &[1; 73]),
284            ],
285        );
286        let selected = decode_large_cdn_config(&input, &state(), LargeCdnRoute::Upload).unwrap();
287        assert_eq!(selected.uin, u64::from(SAFE_UIN));
288        assert_ne!(selected.uin, u64::from(ACCOUNT_UIN));
289        assert_eq!(selected.front_id, 1001);
290        assert_eq!(selected.auth_key, vec![1; 73]);
291        assert_eq!(selected.server_ips, vec![Ipv4Addr::new(8, 8, 8, 200)]);
292        let location = selected.location.unwrap();
293        assert_eq!(location.province, 1234);
294        assert_eq!(location.city, 56);
295        assert_eq!(location.isp, 78);
296        assert!(decode_cdn_config(&input, &state())
297            .unwrap()
298            .location
299            .is_none());
300        let fallback = decode_large_cdn_config(&input, &state(), LargeCdnRoute::Download).unwrap();
301        assert_eq!(fallback.front_id, 1004);
302    }
303
304    #[test]
305    fn route_must_match_all_predicates_and_join_one_config() {
306        let configs = [config(1, [8, 8, 8, 8], &[1; 73])];
307        for candidate in [
308            rule(1, 2, 0, 7, 0, 1),
309            rule(1, 1, 2, 7, 0, 1),
310            rule(1, 1, 0, 5, 0, 1),
311            rule(1, 1, 0, 7, 2, 1),
312            rule(1, 1, 0, 7, 0, 9),
313        ] {
314            assert!(decode_large_cdn_config(
315                &bootstrap(&[candidate], &configs),
316                &state(),
317                LargeCdnRoute::Upload
318            )
319            .is_err());
320        }
321        let rules = [rule(1, 1, 0, 7, 0, 1), rule(1, 1, 0, 0, 0, 1)];
322        assert!(decode_large_cdn_config(
323            &bootstrap(&rules, &configs),
324            &state(),
325            LargeCdnRoute::Upload
326        )
327        .is_err());
328        let duplicated = [configs[0].clone(), configs[0].clone()];
329        assert!(decode_large_cdn_config(
330            &bootstrap(&[rules[0].clone()], &duplicated),
331            &state(),
332            LargeCdnRoute::Upload
333        )
334        .is_err());
335    }
336
337    #[test]
338    fn upload_and_download_select_their_own_operation_with_routing_type_seven() {
339        let input = bootstrap(
340            &[
341                rule(0, 1, 1, 5, 0, 3),
342                rule(1, 1, 1, 7, 1, 1),
343                rule(2, 1, 1, 7, 2, 2),
344            ],
345            &[
346                config(3, [1, 1, 1, 1], &[3; 73]),
347                config(2, [9, 9, 9, 9], &[2; 73]),
348                config(1, [8, 8, 8, 8], &[1; 73]),
349            ],
350        );
351        let upload = decode_large_cdn_config(&input, &state(), LargeCdnRoute::Upload).unwrap();
352        let download = decode_large_cdn_config(&input, &state(), LargeCdnRoute::Download).unwrap();
353        assert_eq!(upload.front_id, 1001);
354        assert_eq!(download.front_id, 1002);
355        assert_eq!(upload.auth_key, vec![1; 73]);
356        assert_eq!(download.auth_key, vec![2; 73]);
357    }
358
359    #[test]
360    fn video_download_selects_type_four_without_file_seven_fallback() {
361        let configs = [
362            config(1, [8, 8, 8, 8], &[1; 73]),
363            config(2, [9, 9, 9, 9], &[2; 73]),
364        ];
365        let input = bootstrap(&[rule(1, 1, 1, 7, 2, 1), rule(2, 1, 1, 4, 2, 2)], &configs);
366        let video =
367            decode_large_cdn_config(&input, &state(), LargeCdnRoute::VideoDownload).unwrap();
368        let file = decode_large_cdn_config(&input, &state(), LargeCdnRoute::Download).unwrap();
369        assert_eq!(video.front_id, 1002);
370        assert_eq!(video.auth_key, vec![2; 73]);
371        assert_eq!(file.front_id, 1001);
372        for wrong in [rule(1, 1, 1, 7, 2, 1), rule(1, 1, 1, 4, 1, 1)] {
373            assert!(decode_large_cdn_config(
374                &bootstrap(&[wrong], &configs),
375                &state(),
376                LargeCdnRoute::VideoDownload
377            )
378            .is_err());
379        }
380    }
381
382    #[test]
383    fn sequence_precedes_specificity_and_only_winning_priority_must_be_unique() {
384        let configs = [
385            config(1, [8, 8, 8, 8], &[1; 73]),
386            config(4, [9, 9, 9, 9], &[4; 73]),
387        ];
388        let rules = [rule(3, 1, 0, 7, 0, 1), rule(1, 1, 0, 0, 0, 4)];
389        assert_eq!(
390            decode_large_cdn_config(
391                &bootstrap(&rules, &configs),
392                &state(),
393                LargeCdnRoute::Upload
394            )
395            .unwrap()
396            .front_id,
397            1004
398        );
399        let rules = [
400            rule(12, 1, 0, 0, 0, 4),
401            rule(12, 1, 0, 7, 0, 4),
402            rule(3, 1, 0, 7, 0, 1),
403        ];
404        assert_eq!(
405            decode_large_cdn_config(
406                &bootstrap(&rules, &configs),
407                &state(),
408                LargeCdnRoute::Upload
409            )
410            .unwrap()
411            .front_id,
412            1001
413        );
414    }
415
416    #[test]
417    fn routing_counts_authentication_and_future_predicates_are_limited() {
418        let configs = [config(1, [8, 8, 8, 8], &[1; 73])];
419        let valid = rule(3, 1, 0, 7, 0, 1);
420        assert!(decode_large_cdn_config(
421            &bootstrap(&vec![valid.clone(); MAX_RULES + 1], &configs),
422            &state(),
423            LargeCdnRoute::Upload
424        )
425        .is_err());
426        assert!(decode_large_cdn_config(
427            &bootstrap(
428                std::slice::from_ref(&valid),
429                &vec![configs[0].clone(); MAX_CONFIGS + 1]
430            ),
431            &state(),
432            LargeCdnRoute::Upload
433        )
434        .is_err());
435        let future = [valid.clone(), p::uint(7, 1)].concat();
436        assert!(decode_large_cdn_config(
437            &bootstrap(&[future], &configs),
438            &state(),
439            LargeCdnRoute::Upload
440        )
441        .is_err());
442        for auth in [vec![], vec![1; 65537]] {
443            assert!(decode_large_cdn_config(
444                &bootstrap(
445                    std::slice::from_ref(&valid),
446                    &[config(1, [8, 8, 8, 8], &auth)]
447                ),
448                &state(),
449                LargeCdnRoute::Upload
450            )
451            .is_err());
452        }
453        let excessive = vec![0; MAX_BOOTSTRAP_BYTES + 1];
454        assert!(decode_large_cdn_config(&excessive, &state(), LargeCdnRoute::Upload).is_err());
455    }
456
457    #[test]
458    fn routing_identity_and_raw_authentication_are_checked_independently() {
459        let rules = [rule(3, 1, 0, 7, 0, 1)];
460        let config = config(1, [8, 8, 8, 8], &[1; 73]);
461        let response = bootstrap(&rules, &[config]);
462        let fields = p::decode(&response).unwrap();
463        let raw = p::unbuffer(p::data(&fields, 6).unwrap()).unwrap();
464        let table = p::decode(&raw).unwrap();
465        let altered_table = [
466            p::uint(1, 1),
467            p::uint(2, u64::from(SAFE_UIN - 1)),
468            p::bytes(7, p::data(&table, 7).unwrap()),
469            p::bytes(8, p::data(&table, 8).unwrap()),
470        ]
471        .concat();
472        let altered = [
473            p::bytes(1, p::data(&fields, 1).unwrap()),
474            p::bytes(2, p::data(&fields, 2).unwrap()),
475            p::bytes(7, p::data(&fields, 7).unwrap()),
476            p::bytes(6, &p::buffer(&altered_table)),
477        ]
478        .concat();
479        let error = decode_large_cdn_config(&altered, &state(), LargeCdnRoute::Upload)
480            .err()
481            .unwrap()
482            .to_string();
483        assert!(error.contains("routing identity mismatch"));
484        // Route auth is raw bytes and may differ from the legacy safe group's
485        // auth. Unwrapping it as another SKBuiltinBuffer would corrupt it.
486        let selected = decode_large_cdn_config(&response, &state(), LargeCdnRoute::Upload).unwrap();
487        assert_eq!(selected.auth_key, vec![1; 73]);
488    }
489
490    #[test]
491    fn malformed_identity_wrappers_and_untrusted_endpoints_are_rejected() {
492        let rules = [rule(3, 1, 0, 7, 0, 1)];
493        let input = bootstrap(&rules, &[config(1, [8, 8, 8, 8], &[1; 73])]);
494        let mut other = state();
495        other.uin += 1;
496        assert!(decode_large_cdn_config(&input, &other, LargeCdnRoute::Upload).is_err());
497        for ip in [[127, 0, 0, 1], [10, 0, 0, 1], [192, 0, 2, 1]] {
498            assert!(decode_large_cdn_config(
499                &bootstrap(&rules, &[config(1, ip, &[1; 73])]),
500                &state(),
501                LargeCdnRoute::Upload
502            )
503            .is_err());
504        }
505        let fields = p::decode(&input).unwrap();
506        for replacement in [
507            p::bytes(1, &p::int32(1, -13)),
508            p::bytes(6, &[p::uint(1, 1), p::bytes(2, b"")].concat()),
509            p::bytes(7, &[p::uint(1, 1), p::uint(2, 99)].concat()),
510        ] {
511            let replacement_field = p::decode(&replacement).unwrap()[0].number;
512            let altered = fields
513                .iter()
514                .filter(|f| f.number != replacement_field)
515                .map(|f| {
516                    p::bytes(
517                        f.number,
518                        match &f.value {
519                            p::Value::Bytes(b) => b,
520                            _ => unreachable!(),
521                        },
522                    )
523                })
524                .chain(std::iter::once(replacement))
525                .flatten()
526                .collect::<Vec<_>>();
527            assert!(decode_large_cdn_config(&altered, &state(), LargeCdnRoute::Upload).is_err());
528        }
529        assert!(wire_u32(1u64 << 32).is_err());
530    }
531
532    #[test]
533    fn location_defaults_match_protobuf_and_malformed_values_are_rejected() {
534        let rules = [rule(3, 1, 0, 7, 0, 1)];
535        let input = bootstrap(&rules, &[config(1, [8, 8, 8, 8], &[1; 73])]);
536        let response = p::decode(&input).unwrap();
537        let raw = p::unbuffer(p::data(&response, 6).unwrap()).unwrap();
538        let table = p::decode(&raw).unwrap();
539        let without_location = [
540            p::uint(1, 1),
541            p::int32(2, SAFE_UIN as i32),
542            p::bytes(7, p::data(&table, 7).unwrap()),
543            p::bytes(8, p::data(&table, 8).unwrap()),
544        ]
545        .concat();
546        let replace_table = |location: &[u8]| {
547            [
548                p::bytes(1, p::data(&response, 1).unwrap()),
549                p::bytes(2, p::data(&response, 2).unwrap()),
550                p::bytes(7, p::data(&response, 7).unwrap()),
551                p::bytes(
552                    6,
553                    &p::buffer(&[without_location.clone(), location.to_vec()].concat()),
554                ),
555            ]
556            .concat()
557        };
558        let selected =
559            decode_large_cdn_config(&replace_table(&[]), &state(), LargeCdnRoute::Upload).unwrap();
560        let location = selected.location.unwrap();
561        assert_eq!((location.province, location.city, location.isp), (0, 0, 0));
562        for malformed in [
563            p::uint(3, 1u64 << 32),
564            p::bytes(4, b"invalid"),
565            [p::uint(5, 1), p::uint(5, 2)].concat(),
566        ] {
567            assert!(decode_large_cdn_config(
568                &replace_table(&malformed),
569                &state(),
570                LargeCdnRoute::Upload
571            )
572            .is_err());
573        }
574    }
575}