Skip to main content

futu_backend/auth/
broker.rs

1//! Broker 通道鉴权
2//!
3//! 平台登录成功后,给每个已授权 broker(Futu HK/US/SG/AU/JP/MY/CA)发一个
4//! `POST https://{broker_auth_domain}/broker_auth/client_auth` 请求,换取
5//! `broker_client_sig` + `broker_client_key` + `customer_id`。这些 broker
6//! 级凭据是 broker TCP 通道 CMD 1001 登录的输入。
7//!
8//! 对齐 C++:
9//! - `FTLogin/Src/ftlogin/config/impl/broker_config.cpp:9-18`(broker_id 映射表)
10//! - `FTLogin/Src/ftlogin/config/impl/env_config.cpp:41-46`(7 个 broker 的 auth_domain)
11//! - `FTLogin/Src/ftlogin/config/impl/env_config.cpp:163-167`(HK auth_domain 本地替换)
12//! - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:2415-2422`(InitRequest 先走 GetReplacedDomain)
13//! - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:2439-2565`(HTTP/WebTCP 失败后走 retry domain / retry IP)
14//! - `FTLogin/Src/ftlogin/auth/impl/auth_ip_list.cpp:75-190,427-516`(broker auth retry IP 池)
15//! - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:640-674` `RefreshBrokerClientSig`
16//! - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:3378-3480` `ParseBrokerAuthResponse`
17
18use std::{
19    net::{IpAddr, SocketAddr},
20    sync::{Arc, Mutex},
21};
22
23use futu_core::error::{FutuError, Result};
24
25use super::commconfig::AuthGuaranteedDomainMap;
26
27/// 单个 broker 通道的配置 —— broker_id → (名字 / conn_identity / broker auth HTTP 域名)
28///
29/// 对齐 C++:
30/// - `FTLogin/Src/ftlogin/config/impl/broker_config.cpp:9-18`(broker_id → chn_type + auth_domain 键)
31/// - `FTLogin/Src/ftlogin/config/impl/env_config.cpp:41-43`(auth_domain 字符串)
32/// - `FTLogin/Src/ftlogin/channel/impl/proto/FTConnCmn.proto:27-40`(conn_identity 值)
33#[derive(Debug, Clone, Copy)]
34pub struct BrokerConfig {
35    pub broker_id: u32,
36    pub name: &'static str,
37    /// 登录协议 CMD 1001 LoginReq.encrypt_data.conn_identity
38    pub conn_identity: u32,
39    /// broker_auth HTTP POST 的域名前缀(不含 scheme)
40    pub auth_domain: &'static str,
41}
42
43/// broker_id → `BrokerConfig` 映射,对齐 C++ `broker_config.cpp:9-18` 完整表。
44/// 未知 broker_id 返回 `None`(路由时 skip)。
45///
46/// 域名来源:C++ `env_config.cpp:41-46`(kDomainBroker{Hk/Us/Sg/Au/Jp/My/Ca}Auth)。
47/// `conn_identity` 来源:C++ `FTConnCmn.proto:27-35` `CONN_BROKER_FUTU_*`。
48///
49/// Futu AirStar (1022) 在 C++ `broker_config.cpp:17` 的 auth_domain 字段为空字符串,
50/// 跳过。
51pub fn broker_config(broker_id: u32) -> Option<BrokerConfig> {
52    Some(match broker_id {
53        1001 => BrokerConfig {
54            broker_id: 1001,
55            name: "Futu HK",
56            conn_identity: 1001,
57            auth_domain: "authority.futuhk.com",
58        },
59        1007 => BrokerConfig {
60            broker_id: 1007,
61            name: "Futu US",
62            conn_identity: 1007,
63            auth_domain: "authority.us.moomoo.com",
64        },
65        1008 => BrokerConfig {
66            broker_id: 1008,
67            name: "Futu SG",
68            conn_identity: 1008,
69            auth_domain: "authority.sg.moomoo.com",
70        },
71        1009 => BrokerConfig {
72            broker_id: 1009,
73            name: "Futu AU",
74            conn_identity: 1009,
75            auth_domain: "authority.au.moomoo.com",
76        },
77        1012 => BrokerConfig {
78            broker_id: 1012,
79            name: "Futu JP",
80            conn_identity: 1012,
81            auth_domain: "authority.jp.moomoo.com",
82        },
83        1017 => BrokerConfig {
84            broker_id: 1017,
85            name: "Futu MY",
86            conn_identity: 1017,
87            auth_domain: "authority.my.moomoo.com",
88        },
89        1019 => BrokerConfig {
90            broker_id: 1019,
91            name: "Futu CA",
92            conn_identity: 1019,
93            auth_domain: "authority.ca.moomoo.com",
94        },
95        _ => return None,
96    })
97}
98
99/// broker_auth HTTP 响应里提取出的 broker-specific 凭据
100#[derive(Debug, Clone)]
101pub struct BrokerAuth {
102    pub broker_id: u32,
103    pub customer_id: u64,
104    pub broker_client_sig: Vec<u8>,
105    pub broker_client_key: Vec<u8>,
106}
107
108/// C++ `FTAuthImpl::AuthReqStage` 的 broker-auth transport 阶段。
109///
110/// Ref:
111/// - `FTLogin/Src/ftlogin/auth/impl/auth_impl.h:15-21`
112/// - `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:2512-2641`
113#[derive(Debug, Clone, Copy, PartialEq, Eq)]
114pub enum BrokerAuthStage {
115    WebTcp,
116    Http,
117    RetryDomain,
118    RetryIp,
119}
120
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub(crate) enum BrokerAuthWebTcpSkipReason {
123    StageNotWebTcp,
124    NoAddrs,
125}
126
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub(crate) struct BrokerAuthTransportPlan {
129    pub(crate) start_stage: BrokerAuthStage,
130    pub(crate) webtcp_attempted: bool,
131    pub(crate) webtcp_skip_reason: Option<BrokerAuthWebTcpSkipReason>,
132}
133
134pub(crate) fn broker_auth_transport_plan(
135    start_stage: BrokerAuthStage,
136    web_tcp_addr_count: usize,
137) -> BrokerAuthTransportPlan {
138    let webtcp_skip_reason = match (start_stage, web_tcp_addr_count) {
139        (BrokerAuthStage::WebTcp, 1..) => None,
140        (BrokerAuthStage::WebTcp, 0) => Some(BrokerAuthWebTcpSkipReason::NoAddrs),
141        _ => Some(BrokerAuthWebTcpSkipReason::StageNotWebTcp),
142    };
143    BrokerAuthTransportPlan {
144        start_stage,
145        webtcp_attempted: webtcp_skip_reason.is_none(),
146        webtcp_skip_reason,
147    }
148}
149
150#[derive(Debug, Default)]
151struct BrokerAuthRouteCacheInner {
152    last_request_original_domain: String,
153    last_success_stage: Option<BrokerAuthStage>,
154    last_success_retry_ip: Option<String>,
155}
156
157/// C++ `FTAuthImpl` 的 broker-auth 成功阶段缓存。
158///
159/// 这是一个单槽缓存,不是 per-domain map:C++ 只保存
160/// `last_request_original_domain_` / `last_success_stage_` /
161/// `last_success_retry_ip_` 三个字段。相同 original domain 的后续请求会直接
162/// 从上次成功阶段开始;不同 domain 仍按 init 路径走。
163#[derive(Debug, Clone, Default)]
164pub struct BrokerAuthRouteCache {
165    inner: Arc<Mutex<BrokerAuthRouteCacheInner>>,
166}
167
168impl BrokerAuthRouteCache {
169    pub(crate) fn preferred_stage(&self, original_domain: &str) -> Option<BrokerAuthStage> {
170        let guard = self
171            .inner
172            .lock()
173            .unwrap_or_else(|poisoned| poisoned.into_inner());
174        (guard.last_request_original_domain == original_domain)
175            .then_some(guard.last_success_stage)
176            .flatten()
177    }
178
179    pub(crate) fn cached_retry_ip(&self, original_domain: &str) -> Option<String> {
180        let guard = self
181            .inner
182            .lock()
183            .unwrap_or_else(|poisoned| poisoned.into_inner());
184        (guard.last_request_original_domain == original_domain)
185            .then(|| guard.last_success_retry_ip.clone())
186            .flatten()
187    }
188
189    pub(crate) fn record_success(
190        &self,
191        original_domain: &str,
192        stage: BrokerAuthStage,
193        retry_ip: Option<String>,
194    ) {
195        let mut guard = self
196            .inner
197            .lock()
198            .unwrap_or_else(|poisoned| poisoned.into_inner());
199        guard.last_request_original_domain = original_domain.to_string();
200        guard.last_success_stage = Some(stage);
201        guard.last_success_retry_ip = retry_ip;
202    }
203}
204
205/// C++ `auth_cryptor.cpp:9-10` 的两把默认 key —— broker_auth 响应里的
206/// `broker_client_key` 是用这把 key 而**不是** rand_key 做 AES 加密的。
207/// 先试 AES-256(新版),失败兜底 AES-128(旧版)。
208/// 对齐 C++ `FTAuthCryptor::DecryptByRandKey(data, nullptr)` 分支(line 324-332)。
209const AUTH_DEFAULT_KEY_32: &[u8] = b"5_B8tYqx^@aVJ6Vra2fi858@(5BGVYcJ";
210const AUTH_DEFAULT_KEY_16: &[u8] = b"@bsOj)h$ZHJx*TDI";
211
212/// C++ FTLogin `EnvConfig::GetReplacedDomain` 的本地 broker-auth 规则。
213///
214/// 证据:
215/// - `env_config.cpp:163-167`: `authority.futuhk.com` 本地替换到
216///   `authfthk.futuhn.com`
217/// - `auth_impl.cpp:2415-2422`: 构造 broker auth URL 前先调用
218///   `GetReplacedDomain(domain)`
219///
220/// 这里不是业务 hardcode,而是 FTLogin wire 层内置域名替换。漏掉它会让 Rust
221/// 直接打 `authority.futuhk.com`,deger 真机反馈该域名在外部环境 TLS reset。
222fn broker_auth_replaced_domain(domain: &str) -> String {
223    match domain {
224        "authority.futuhk.com" => "authfthk.futuhn.com".to_string(),
225        _ => domain.to_string(),
226    }
227}
228
229/// broker auth 域名候选。首选 FTLogin 的 replaced domain;失败后才带上 C++
230/// `GetRetryDomain` 的 guaranteed domain。retry IP 阶段由调用方单独处理。
231///
232/// 证据:
233/// - `auth_impl.cpp:2491-2527`: WebTcp/HTTP 失败后依次重试 retry domain / retry IP
234/// - `auth_impl.cpp:2464-2487`: HK 本地兜底域名按 AppType 选 futunn / moomoo
235/// - `env_config.cpp:50-51`: `authfthk.futunn.com` / `authfthk.moomoo.com`
236pub(crate) fn broker_auth_domain_candidates(
237    cfg: BrokerConfig,
238    client_type: u8,
239    auth_guaranteed_domains: &AuthGuaranteedDomainMap,
240    auth_guaranteed_domains_configured: bool,
241) -> Vec<String> {
242    let mut domains = Vec::with_capacity(4);
243
244    let replaced = broker_auth_replaced_domain(cfg.auth_domain);
245    domains.push(replaced.clone());
246
247    if let Some(retry_domain) = auth_guaranteed_domains
248        .get(cfg.auth_domain)
249        .filter(|domain| !domain.is_empty())
250    {
251        domains.push(broker_auth_replaced_domain(retry_domain));
252    } else if !auth_guaranteed_domains_configured && cfg.auth_domain == "authority.futuhk.com" {
253        domains.push(if client_type == 60 {
254            "authfthk.moomoo.com".to_string()
255        } else {
256            "authfthk.futunn.com".to_string()
257        });
258    }
259
260    domains.dedup();
261    domains
262}
263
264/// Broker auth 的 C++ hardcoded retry IP 池。它不是业务主配置,而是
265/// `FTLogin` 在动态 auth IP 列表不可用时的故障兜底。
266///
267/// 证据:
268/// - `FTLogin/Src/ftlogin/auth/impl/auth_ip_list.cpp:75-190`
269///   `InitializeData()` 定义各 broker auth IP
270/// - `auth_ip_list.cpp:427-463` `LoadHardCodeData()` 写入 `broker_ip_list_`
271/// - `auth_ip_list.cpp:502-516` `GetNextRetryIpBroker()`
272///
273/// Rust 目前还没有搬完整 `auth_guaranteed_ip_list` 动态刷新链路,所以这里先
274/// 对齐 C++ 的 hardcoded floor。请求仍使用原 broker auth domain 做 URL/SNI,
275/// 仅通过 reqwest `resolve()` 把 TCP 目标固定到这些 IP,不能绕过 TLS 校验。
276pub(crate) fn broker_auth_retry_ips(broker_id: u32) -> &'static [&'static str] {
277    match broker_id {
278        1001 => &["43.159.5.43", "43.159.1.53", "43.159.1.96"],
279        1007 => &["49.51.78.182", "170.106.200.151", "49.51.77.65"],
280        1008 => &["101.32.110.83", "43.134.159.6", "43.134.158.10"],
281        1009 => &["54.79.143.157", "13.55.231.187"],
282        1012 => &["43.128.254.149", "150.109.201.146"],
283        1017 => &["47.254.237.244", "47.250.12.241"],
284        1019 => &["3.98.18.229", "35.182.97.191"],
285        _ => &[],
286    }
287}
288
289enum BrokerAuthAttemptError {
290    Transport(String),
291    Json(String),
292}
293
294async fn post_broker_auth_json(
295    http: &reqwest::Client,
296    url: &str,
297    body: &serde_json::Value,
298) -> std::result::Result<serde_json::Value, BrokerAuthAttemptError> {
299    let response = http
300        .post(url)
301        .json(body)
302        .send()
303        .await
304        .map_err(|e| BrokerAuthAttemptError::Transport(e.to_string()))?;
305    response
306        .json::<serde_json::Value>()
307        .await
308        .map_err(|e| BrokerAuthAttemptError::Json(e.to_string()))
309}
310
311pub(crate) async fn broker_auth_init_stage_from_site_config(
312    web_tcp_identity: u32,
313    url: &str,
314    site_config: Option<&super::site_config::SharedSiteConfig>,
315) -> BrokerAuthStage {
316    let Some(site_config) = site_config else {
317        return BrokerAuthStage::WebTcp;
318    };
319    let parsed = match reqwest::Url::parse(url) {
320        Ok(parsed) => parsed,
321        Err(e) => {
322            tracing::warn!(url, error = %e, "broker_auth site_config URL parse failed; selecting HTTP");
323            return BrokerAuthStage::Http;
324        }
325    };
326    let Some(host) = parsed.host_str() else {
327        tracing::warn!(
328            url,
329            "broker_auth site_config URL has no host; selecting HTTP"
330        );
331        return BrokerAuthStage::Http;
332    };
333
334    let Some(config) = super::site_config::wait_latest(site_config).await else {
335        tracing::warn!(
336            web_identity = web_tcp_identity,
337            url,
338            "broker_auth site_config not loaded before C++ wait deadline; selecting HTTP"
339        );
340        return BrokerAuthStage::Http;
341    };
342
343    match config.query(web_tcp_identity, host, parsed.path()) {
344        super::site_config::WebChannelConfigType::Http => BrokerAuthStage::Http,
345        super::site_config::WebChannelConfigType::WebTcpShort
346        | super::site_config::WebChannelConfigType::WebTcpLong => BrokerAuthStage::WebTcp,
347    }
348}
349
350/// 向 broker auth 域名发 `/broker_auth/client_auth` POST 请求,换取
351/// `broker_client_sig` + `broker_client_key`。
352///
353/// 对齐 C++ `auth_impl.cpp:640-674`(`RefreshBrokerClientSig`)+
354/// `auth_impl.cpp:3378-3480`(`ParseBrokerAuthResponse`):
355/// - URL:`POST https://{broker_auth_domain}/broker_auth/client_auth`
356/// - Body:`{"uid", "auth_code", "device_id", "broker_id"}`
357/// - 响应 result 里的 `broker_client_key` 是 base64 编码 + AES-CBC-MD5 加密过的
358///
359/// ⚠️ 解密不是用 `rand_key`!对齐 `auth_impl.cpp:3434` —— 该处调用
360/// `DecryptByRandKey(&broker_client_key, nullptr)`,nullptr 触发
361/// `auth_cryptor.cpp:324-332` 分支:**用固定默认 key 解密**(先试 AES-256
362/// `AUTH_DEFAULT_KEY_32`,失败兜底 AES-128 `AUTH_DEFAULT_KEY`),**不是**
363/// Platform client_key 用的 rand_key。
364//
365// v1.4.109: broker_auth 参数都是协议必填字段 (http / client_type / uid /
366// broker_id / auth_code / device_id / web_tcp_identity / web_tcp_addrs /
367// site_config / auth_guaranteed_domains / + 其他), 拆 struct 会让调用方
368// 反而更难读. clippy `too_many_arguments` allow.
369#[allow(clippy::too_many_arguments)]
370pub async fn broker_auth(
371    http: &reqwest::Client,
372    client_type: u8,
373    uid: u64,
374    broker_id: u32,
375    auth_code: &str,
376    device_id: &str,
377    web_tcp_identity: u32,
378    web_tcp_addrs: &[(String, u16)],
379    site_config: Option<&super::site_config::SharedSiteConfig>,
380    auth_guaranteed_domains: &AuthGuaranteedDomainMap,
381    auth_guaranteed_domains_configured: bool,
382    route_cache: Option<&BrokerAuthRouteCache>,
383) -> Result<BrokerAuth> {
384    let cfg = broker_config(broker_id).ok_or_else(|| {
385        FutuError::Codec(format!(
386            "broker_auth: unknown broker_id {broker_id} (not in broker_config map)"
387        ))
388    })?;
389
390    let body = serde_json::json!({
391        "uid": uid,
392        "auth_code": auth_code,
393        "device_id": device_id,
394        "broker_id": broker_id,
395    });
396
397    let domains = broker_auth_domain_candidates(
398        cfg,
399        client_type,
400        auth_guaranteed_domains,
401        auth_guaranteed_domains_configured,
402    );
403    let primary_domain = broker_auth_replaced_domain(cfg.auth_domain);
404    let primary_url = format!("https://{primary_domain}/broker_auth/client_auth");
405    let (start_stage, start_stage_source) = match route_cache
406        .and_then(|cache| cache.preferred_stage(cfg.auth_domain))
407    {
408        Some(stage) => (stage, "route_cache"),
409        None => (
410            broker_auth_init_stage_from_site_config(web_tcp_identity, &primary_url, site_config)
411                .await,
412            "site_config",
413        ),
414    };
415    let transport_plan = broker_auth_transport_plan(start_stage, web_tcp_addrs.len());
416    if start_stage != BrokerAuthStage::WebTcp {
417        tracing::debug!(
418            broker_id,
419            original_domain = cfg.auth_domain,
420            stage = ?start_stage,
421            source = start_stage_source,
422            "broker_auth starting from selected FTLogin stage"
423        );
424    } else if transport_plan.webtcp_skip_reason == Some(BrokerAuthWebTcpSkipReason::NoAddrs) {
425        tracing::warn!(
426            broker_id,
427            original_domain = cfg.auth_domain,
428            web_identity = web_tcp_identity,
429            source = start_stage_source,
430            "broker_auth WebTCP-short selected but no WebTCP addresses are loaded; falling back to HTTP domain"
431        );
432    }
433    let mut last_network_err: Option<String> = None;
434    let mut resp: Option<(serde_json::Value, BrokerAuthStage, Option<String>)> = None;
435
436    // C++ order: WebTCP-short -> HTTP domain -> retry IP, unless the route
437    // cache or site-config moves the init stage forward. The WebTCP stage uses
438    // server-provided IP pools and avoids fragile system DNS, while retry IP is
439    // only a final floor.
440    if transport_plan.webtcp_attempted {
441        tracing::debug!(
442            broker_id,
443            uid,
444            web_identity = web_tcp_identity,
445            addrs = web_tcp_addrs.len(),
446            url = %primary_url,
447            "POST /broker_auth/client_auth via WebTCP-short"
448        );
449        match super::webtcp::post_json_via_webtcp(
450            client_type,
451            web_tcp_identity,
452            web_tcp_addrs,
453            &primary_url,
454            &body,
455        )
456        .await
457        {
458            Ok(value) => {
459                resp = Some((value, BrokerAuthStage::WebTcp, None));
460            }
461            Err(e) => {
462                last_network_err = Some(format!("webtcp identity {web_tcp_identity}: {e}"));
463                tracing::warn!(
464                    broker_id,
465                    web_identity = web_tcp_identity,
466                    addrs = web_tcp_addrs.len(),
467                    error = %e,
468                    "broker_auth WebTCP-short failed; falling back to HTTP domain"
469                );
470            }
471        }
472    }
473
474    let domain_start = match start_stage {
475        BrokerAuthStage::WebTcp | BrokerAuthStage::Http => 0,
476        BrokerAuthStage::RetryDomain => 1,
477        BrokerAuthStage::RetryIp => domains.len(),
478    };
479    for (idx, domain) in domains.iter().enumerate().skip(domain_start) {
480        if resp.is_some() {
481            break;
482        }
483        let stage = if idx == 0 {
484            BrokerAuthStage::Http
485        } else {
486            BrokerAuthStage::RetryDomain
487        };
488        let url = format!("https://{domain}/broker_auth/client_auth");
489        tracing::debug!(
490            broker_id,
491            uid,
492            url = %url,
493            original_domain = cfg.auth_domain,
494            stage = ?stage,
495            "POST /broker_auth/client_auth"
496        );
497
498        match post_broker_auth_json(http, &url, &body).await {
499            Ok(value) => {
500                resp = Some((value, stage, None));
501            }
502            Err(BrokerAuthAttemptError::Transport(e)) => {
503                last_network_err = Some(format!("{domain}: {e}"));
504                tracing::warn!(
505                    broker_id,
506                    domain,
507                    error = %e,
508                    "broker_auth transport failed; trying next domain if available"
509                );
510                continue;
511            }
512            Err(BrokerAuthAttemptError::Json(e)) => {
513                return Err(FutuError::Codec(format!(
514                    "broker_auth json from {domain}: {e}"
515                )));
516            }
517        }
518    }
519
520    let retry_domain = broker_auth_replaced_domain(cfg.auth_domain);
521    let retry_ips = broker_auth_retry_ips(broker_id);
522    let retry_ip_candidates: Vec<String> = if start_stage == BrokerAuthStage::RetryIp {
523        route_cache
524            .and_then(|cache| cache.cached_retry_ip(cfg.auth_domain))
525            .map(|ip| vec![ip])
526            .unwrap_or_else(|| retry_ips.iter().map(|ip| (*ip).to_string()).collect())
527    } else {
528        retry_ips.iter().map(|ip| (*ip).to_string()).collect()
529    };
530    for ip in &retry_ip_candidates {
531        if resp.is_some() {
532            break;
533        }
534        let ip_addr = ip.parse::<IpAddr>().map_err(|e| {
535            FutuError::Codec(format!(
536                "invalid hardcoded broker_auth retry ip broker_id={broker_id} ip={ip}: {e}"
537            ))
538        })?;
539        let addr = SocketAddr::new(ip_addr, 443);
540        let ip_http = super::build_http_client_with_resolve(
541            client_type,
542            Some((retry_domain.as_str(), addr)),
543        )?;
544        let url = format!("https://{retry_domain}/broker_auth/client_auth");
545        tracing::debug!(
546            broker_id,
547            uid,
548            tls_domain = retry_domain,
549            target_ip = %ip,
550            "POST /broker_auth/client_auth via C++ retry IP"
551        );
552        match post_broker_auth_json(&ip_http, &url, &body).await {
553            Ok(value) => {
554                resp = Some((value, BrokerAuthStage::RetryIp, Some(ip.clone())));
555                break;
556            }
557            Err(BrokerAuthAttemptError::Transport(e)) => {
558                last_network_err = Some(format!("{retry_domain}@{ip}:443: {e}"));
559                tracing::warn!(
560                    broker_id,
561                    tls_domain = retry_domain,
562                    target_ip = %ip,
563                    error = %e,
564                    "broker_auth transport failed over retry IP; trying next IP/domain if available"
565                );
566            }
567            Err(BrokerAuthAttemptError::Json(e)) => {
568                return Err(FutuError::Codec(format!(
569                    "broker_auth json from {retry_domain}@{ip}:443: {e}"
570                )));
571            }
572        }
573    }
574
575    let resp = resp.ok_or_else(|| {
576        FutuError::Network(std::io::Error::other(format!(
577            "broker_auth transport failed for broker_id={broker_id}; attempted_webtcp_addrs={web_tcp_addrs:?}; attempted domains={domains:?}; attempted retry_ips={retry_ip_candidates:?}; last_error={}",
578            last_network_err.unwrap_or_else(|| "none".to_string())
579        )))
580    })?;
581    let (resp, success_stage, success_retry_ip) = resp;
582    if let Some(cache) = route_cache {
583        cache.record_success(cfg.auth_domain, success_stage, success_retry_ip.clone());
584    }
585
586    // 错误分支
587    if let Some(err) = resp.get("error").and_then(|e| e.as_object()) {
588        let code = err.get("error_code").and_then(|v| v.as_i64()).unwrap_or(-1);
589        let msg = err
590            .get("error_msg")
591            .and_then(|v| v.as_str())
592            .unwrap_or("unknown");
593        if code != 0 {
594            return Err(FutuError::ServerError {
595                ret_type: code as i32,
596                msg: format!("broker_auth broker_id={broker_id}: {msg}"),
597            });
598        }
599    }
600
601    let result = resp
602        .get("result")
603        .and_then(|r| r.as_object())
604        .ok_or_else(|| FutuError::Codec("broker_auth: missing result".into()))?;
605
606    let sig_b64 = result
607        .get("broker_client_sig")
608        .and_then(|v| v.as_str())
609        .ok_or_else(|| FutuError::Codec("broker_auth: missing broker_client_sig".into()))?;
610    let key_b64 = result
611        .get("broker_client_key")
612        .and_then(|v| v.as_str())
613        .ok_or_else(|| FutuError::Codec("broker_auth: missing broker_client_key".into()))?;
614    let customer_id = result.get("cid").and_then(|v| v.as_u64()).unwrap_or(0);
615    if customer_id == 0 {
616        return Err(FutuError::Codec(
617            "broker_auth: cid missing or zero in response".into(),
618        ));
619    }
620
621    use base64::Engine;
622    let broker_client_sig = base64::engine::general_purpose::STANDARD
623        .decode(sig_b64)
624        .map_err(|e| FutuError::Codec(format!("broker_client_sig decode: {e}")))?;
625    let ck_enc = base64::engine::general_purpose::STANDARD
626        .decode(key_b64)
627        .map_err(|e| FutuError::Codec(format!("broker_client_key decode: {e}")))?;
628
629    // 对齐 C++ `DecryptByRandKey(data, nullptr)` 分支:先试 AES-256 默认 key,
630    // 解密失败兜底 AES-128 —— broker 后端可能还在用旧版 16 字节默认 key
631    let broker_client_key =
632        match futu_net::encrypt::aes_cbc_md5_decrypt_var(AUTH_DEFAULT_KEY_32, &ck_enc) {
633            Ok(k) => {
634                tracing::debug!(
635                    broker_id,
636                    "broker_client_key decrypted with AUTH_DEFAULT_KEY_32 (AES-256)"
637                );
638                k
639            }
640            Err(e_256) => {
641                tracing::debug!(
642                    broker_id,
643                    error = %e_256,
644                    "AES-256 default key failed, fallback to AES-128"
645                );
646                futu_net::encrypt::aes_cbc_md5_decrypt_var(AUTH_DEFAULT_KEY_16, &ck_enc).map_err(
647                    |e_128| {
648                        FutuError::Codec(format!(
649                            "broker_client_key decrypt failed with both default keys: \
650                         AES-256={e_256}, AES-128={e_128}"
651                        ))
652                    },
653                )?
654            }
655        };
656
657    tracing::info!(
658        broker_id,
659        broker = cfg.name,
660        customer_id,
661        success_stage = ?success_stage,
662        success_retry_ip = success_retry_ip.as_deref().unwrap_or("none"),
663        start_stage = ?transport_plan.start_stage,
664        start_stage_source,
665        webtcp_attempted = transport_plan.webtcp_attempted,
666        webtcp_skip_reason = ?transport_plan.webtcp_skip_reason,
667        webtcp_addrs = web_tcp_addrs.len(),
668        web_identity = web_tcp_identity,
669        client_sig_len = broker_client_sig.len(),
670        client_key_len = broker_client_key.len(),
671        "broker_auth success"
672    );
673    Ok(BrokerAuth {
674        broker_id,
675        customer_id,
676        broker_client_sig,
677        broker_client_key,
678    })
679}