Skip to main content

futu_backend/auth/
http_client.rs

1//! v1.4.110+ Tier 1 split (from `auth/mod.rs`): reqwest HTTP client builder.
2//!
3//! - `build_http_client(client_type)` — 主入口
4//! - `build_http_client_with_resolve(client_type, resolve)` — 测试 / IP override 入口
5//! - `auth_http_default_headers` — AuthIPList / commconfig 专用 `X-Futu-*` headers
6//! - `auth_business_headers` — FTAuthImpl 业务鉴权请求 headers
7//!
8//! TLS: rustls-tls-webpki-roots (CLAUDE.md 坑 #50, 防 user keychain MITM).
9
10use futu_core::error::{FutuError, Result};
11
12pub(crate) const AUTH_HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
13pub(crate) const AUTH_HTTP_MAX_REDIRECTS: usize = 3;
14
15pub fn build_http_client(client_type: u8) -> Result<reqwest::Client> {
16    build_http_client_with_resolve(client_type, None)
17}
18
19pub(crate) fn build_http_client_with_resolve(
20    client_type: u8,
21    resolve: Option<(&str, std::net::SocketAddr)>,
22) -> Result<reqwest::Client> {
23    // v1.4.84 SEC-002 主修复 (external reviewer security report):
24    //
25    // **删除** 之前的 `.danger_accept_invalid_certs(true)` — 这相当于对所有
26    // HTTPS endpoint 完全**禁用** cert 验证, 任何 MITM 都能过. external reviewer 实证:
27    // mitmproxy CA 装入 user keychain 后 Rust daemon 10 个 HTTPS endpoint
28    // 全部 TLS 握手成功, 同条件 C++ OpenD 被 `tlsv1 alert unknown ca` 拒.
29    //
30    // 配合 workspace Cargo.toml `reqwest` dep 改为 `rustls-tls-webpki-roots`:
31    // - 排除 native-tls (OS keychain trust, 会被 user keychain 恶意 CA MITM)
32    // - 使用 Mozilla webpki-roots (curated CA list, 不读 user keychain)
33    //
34    // **防攻击面**:
35    // - Agent skill 装 user keychain MITM CA → 不再被信任, 握手失败
36    // - 企业 MDM 推 CA → 仍会被信任 (MDM 是 system-trusted), 若需阻挡
37    //   此类场景需 v1.4.85+ 加 cert-pinning (SPKI hash) for 敏感 endpoint
38    //
39    // **注**: Futu backend 用公开 CA 签 cert, webpki-roots 内置全球公共 CA,
40    // 正常握手不受影响.
41    let _ = client_type;
42    // Ref: `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:2564,2744`.
43    // C++ FTAuthImpl SendRequestImpl uses a 10s HTTP budget and caps redirects
44    // at 3; keep the common Rust auth client on the same transport envelope.
45    let mut builder = reqwest::Client::builder()
46        .timeout(AUTH_HTTP_TIMEOUT)
47        .redirect(reqwest::redirect::Policy::limited(AUTH_HTTP_MAX_REDIRECTS));
48    if let Some((domain, addr)) = resolve {
49        builder = builder.resolve(domain, addr);
50    }
51    builder
52        .build()
53        .map_err(|e| FutuError::Encryption(format!("http client: {e}")))
54}
55
56/// Headers for `AuthIPList::UpdateAuthIPList` and related config fetches.
57///
58/// C++ auth business requests do **not** use this set. They go through
59/// `FTAuthImpl::InitRequest` / `SetHttpHeaders` / `SendRequestImpl`, which only
60/// carries Content-Type, Cookie and OpenD's User-Agent at the request layer.
61/// Ref:
62/// - `FTlogin/Src/ftlogin/auth/impl/auth_ip_list.cpp:263-281`
63/// - `FTlogin/Src/ftlogin/auth/impl/auth_impl.cpp:3416-3423,3606-3612,3772-3775`
64pub(crate) fn auth_http_default_headers(client_type: u8) -> Result<reqwest::header::HeaderMap> {
65    let mut default_headers = reqwest::header::HeaderMap::new();
66    default_headers.insert(
67        "X-Futu-Client-Type",
68        http_header_value("X-Futu-Client-Type", client_type)?,
69    );
70    default_headers.insert(
71        "X-Futu-Client-Version",
72        http_header_value(
73            "X-Futu-Client-Version",
74            crate::conn::BackendConn::CLIENT_VER_FTGTW,
75        )?,
76    );
77    default_headers.insert(
78        "X-Futu-Client-Lang",
79        reqwest::header::HeaderValue::from_static("sc"),
80    );
81    default_headers.insert(
82        "Content-Type",
83        reqwest::header::HeaderValue::from_static("application/json"),
84    );
85    Ok(default_headers)
86}
87
88pub(crate) fn auth_business_headers(
89    client_type: u8,
90    device_id: &str,
91) -> Result<reqwest::header::HeaderMap> {
92    let mut headers = reqwest::header::HeaderMap::new();
93    headers.insert(
94        reqwest::header::CONTENT_TYPE,
95        reqwest::header::HeaderValue::from_static("application/json"),
96    );
97    headers.insert(
98        reqwest::header::COOKIE,
99        http_header_value("Cookie", format!("device_id={device_id}"))?,
100    );
101    headers.insert(
102        reqwest::header::USER_AGENT,
103        http_header_value("User-Agent", opend_auth_user_agent(client_type))?,
104    );
105    AuthTraceHeaders::new().insert_http_headers(&mut headers)?;
106    Ok(headers)
107}
108
109#[derive(Debug, Clone)]
110pub(crate) struct AuthTraceHeaders {
111    trace_id: String,
112    parent_span_id: String,
113    span_id: String,
114}
115
116impl AuthTraceHeaders {
117    pub(crate) fn new() -> Self {
118        // Ref: `FTlogin/Src/ftlogin/auth/impl/auth_impl.cpp:3596-3600`
119        // and `FTNet/Src/ftnet_unittest/ftnet_unittest.cpp:20-24`.
120        // These IDs are observability-only B3 headers, not idempotency keys.
121        Self {
122            trace_id: hex::encode(rand::random::<[u8; 16]>()),
123            parent_span_id: hex::encode(rand::random::<[u8; 8]>()),
124            span_id: hex::encode(rand::random::<[u8; 8]>()),
125        }
126    }
127
128    pub(crate) fn entries(&self) -> [(&'static str, &str); 3] {
129        [
130            ("x-b3-traceid", self.trace_id.as_str()),
131            ("x-b3-parentspanid", self.parent_span_id.as_str()),
132            ("x-b3-spanid", self.span_id.as_str()),
133        ]
134    }
135
136    fn insert_http_headers(&self, headers: &mut reqwest::header::HeaderMap) -> Result<()> {
137        for (name, value) in self.entries() {
138            headers.insert(name, http_header_value(name, value)?);
139        }
140        Ok(())
141    }
142}
143
144pub(crate) fn opend_auth_user_agent(client_type: u8) -> String {
145    // Ref: FutuOpenD/Src/NNProtoCenter/Login/NNDataUrl.cpp:252-266.
146    format!(
147        "ClientType/{client_type} ClientVersion/{} CliLang/zh-cn ClientHourClock/24 OsType/{} RequestSource/Http",
148        crate::conn::BackendConn::CLIENT_VER_FTGTW,
149        opend_user_agent_os_type(),
150    )
151}
152
153fn opend_user_agent_os_type() -> &'static str {
154    if cfg!(target_os = "macos") {
155        "11"
156    } else if cfg!(target_os = "linux") {
157        "14"
158    } else {
159        "10"
160    }
161}
162
163fn http_header_value(
164    name: &'static str,
165    value: impl std::fmt::Display,
166) -> Result<reqwest::header::HeaderValue> {
167    reqwest::header::HeaderValue::from_str(&value.to_string())
168        .map_err(|e| FutuError::Codec(format!("{name}: invalid header value: {e}")))
169}