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