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