Skip to main content

futu_backend/
login.rs

1// 后端 TCP 登录
2//
3// 对应 C++ `FTChannelImpl::Logger::SendLoginReq`
4// (`FTLogin/Src/ftlogin/channel/impl/logger.cpp:150-220`)
5// 使用 FTConnLogin.proto 的 LoginReq/LoginRsp 完成登录
6// 登录命令(cmd_id=6001)不加密(由通道自身不走 session_key 路径),
7// 但 LoginReq 里的 encrypt_data 字段是用 client_key 做 AES-CBC-MD5 加密过的
8// ReqEncryptData protobuf。
9
10use futu_core::error::{FutuError, Result};
11use futu_core::log_redact::endpoint_log_fingerprint;
12
13use crate::auth::AuthResult;
14use crate::conn::BackendConn;
15
16/// Platform channel 登录命令 ID(C++ `kCmdLoginPlatform`)
17pub const CMD_LOGIN_PLATFORM: u16 = 6001;
18/// Broker channel 登录命令 ID(C++ `kCmdLoginBroker`)
19pub const CMD_LOGIN_BROKER: u16 = 1001;
20
21/// C++ `FTGTW_Client_Build`(`FTGTW_Version.h:6`)——编译版本号
22/// **注意**:ReqEncryptData.client_ex_ver 填的是这个 BuildVersion,
23/// 不是 `BackendConn::CLIENT_VER_FTGTW`。C++ `logger.cpp:165` 用 `AppConfig::GetBuildVersion()`。
24/// 对齐 v10.02 当前公开值 6208。
25const FTGTW_CLIENT_BUILD: u32 = 6208;
26
27/// C++ `ftlogin_def.h:230` `kNetTypeEthernet = 3`。
28/// FutuOpenD 的 `FTGTW_Inner_API.cpp:444` `GetNetType()` 硬编码返回 Ethernet,
29/// 我们也遵循。
30const NET_TYPE_ETHERNET: u32 = 3;
31
32/// 返回 C++ `AppConfig::GetOSName()` 对应的字符串(`app_config.cpp:47-62`)。
33/// 值域:`Mac` / `Windows` / `Ubuntu` / `CentOS` / `iOS` / `Android` / `Unknown`。
34/// FutuOpenD C++ 在 `FTGTW_Version.h` 里按编译目标硬定 `FTGTW_OSType`,
35/// 我们用 `cfg!(target_os = "...")` 按编译目标派生 —— FutuOpenD 只上架 Mac/
36/// Linux/Windows 三平台,Linux 发行版本无法区分 Ubuntu vs CentOS,统一用
37/// "Ubuntu"(与 C++ `FTGTW_OSType=2` 对齐,大部分 opend-linux 用户跑 Ubuntu)。
38fn os_name() -> &'static str {
39    if cfg!(target_os = "macos") {
40        "Mac"
41    } else if cfg!(target_os = "windows") {
42        "Windows"
43    } else if cfg!(target_os = "linux") {
44        "Ubuntu"
45    } else {
46        "Unknown"
47    }
48}
49
50/// 返回 C++ `GetNetTypeStr(kNetTypeEthernet)`(`logger.cpp:41-52`)的字符串值。
51/// Ethernet 对应字符串是 **"LAN"**,不是 "Ethernet"。
52fn net_type_str_ethernet() -> &'static str {
53    "LAN"
54}
55
56fn format_session_key_len_marker(session_key_len: usize) -> String {
57    format!("session_key_len={session_key_len}")
58}
59
60/// 登录结果
61#[derive(Debug, Clone)]
62pub struct LoginResult {
63    pub user_id: u64,
64    /// RspEncryptData.session_key 原始字节 —— 长度由服务端决定,对齐 C++
65    /// `Logger::session_key_` 是 `std::string`(`logger.h:152`)变长存储。
66    /// Platform 通常 16 字节,Broker 可能 32 字节,不能强制截断。
67    pub session_key: Vec<u8>,
68    pub keep_alive_interval: u32,
69    pub sec_data: u32,
70    pub server_time: u64,
71    /// RspEncryptData.client_ip(field 14), server 视角的客户端外网 IP。
72    ///
73    /// Ref: FTLogin `FTConnLogin.proto:105` + `logger.cpp:511-516`。
74    /// C++ 会把它保存到 working_tcp_client->client_ip,并在 broker CMD20147
75    /// `ConnIpReq.client_feature.client_ip` 中回填;broker 1007 会严格校验。
76    pub client_ip: String,
77}
78
79/// TCP login target fields written into `ReqEncryptData` fields 2/6/7/9.
80#[derive(Debug, Clone, Copy)]
81pub struct TcpLoginTarget<'a> {
82    pub is_new_login: bool,
83    pub redirect_ttl: u32,
84    pub host_ip: &'a str,
85    pub host_port: u32,
86}
87
88impl<'a> TcpLoginTarget<'a> {
89    pub fn new(is_new_login: bool, redirect_ttl: u32, host_ip: &'a str, host_port: u32) -> Self {
90        Self {
91            is_new_login,
92            redirect_ttl,
93            host_ip,
94            host_port,
95        }
96    }
97}
98
99/// Channel-specific login identity. Platform and broker login share the same
100/// body shape, but these fields intentionally differ.
101#[derive(Debug, Clone, Copy)]
102pub struct TcpLoginChannel<'a> {
103    pub cmd_id: u16,
104    pub conn_identity: u32,
105    pub effective_user_id: u64,
106    pub client_sig: &'a [u8],
107}
108
109impl<'a> TcpLoginChannel<'a> {
110    pub fn platform(auth: &'a AuthResult) -> Self {
111        Self {
112            cmd_id: CMD_LOGIN_PLATFORM,
113            conn_identity: auth.user_attribution.to_conn_identity(),
114            effective_user_id: auth.user_id,
115            client_sig: &auth.client_sig,
116        }
117    }
118
119    pub fn broker(conn_identity: u32, customer_id: u64, broker_client_sig: &'a [u8]) -> Self {
120        Self {
121            cmd_id: CMD_LOGIN_BROKER,
122            conn_identity,
123            effective_user_id: customer_id,
124            client_sig: broker_client_sig,
125        }
126    }
127}
128
129/// Platform 通道登录的外层封装:填入 platform 专用参数后调 `tcp_login_raw`。
130/// 保持 v1.4.7 语义不变——默认 cmd=6001,conn_identity 从 attribution 派生,user_id=auth.user_id。
131pub async fn tcp_login(
132    conn: &BackendConn,
133    auth: &AuthResult,
134    client_key: &[u8],
135    target: TcpLoginTarget<'_>,
136) -> Result<LoginResult> {
137    tcp_login_raw(conn, client_key, target, TcpLoginChannel::platform(auth)).await
138}
139
140/// 执行 TCP 登录(通用版 —— 同时用于 Platform cmd=6001 和 Broker cmd=1001)
141///
142/// 构造 `ReqEncryptData` → AES-CBC-MD5 加密(key=完整 client_key,
143/// 32 字节时是 AES-256) → 放进 `LoginReq.encrypt_data` → 发指定 cmd。
144///
145/// 对齐 `logger.cpp:150-220` —— 该函数是 C++ `SendNormalLoginProtocol` 的等价
146/// 实现,cmd=6001/1001 共用同一套 ReqEncryptData 字段布局,只是以下三个值
147/// 随通道变化:
148///
149/// - `cmd_id`:6001=`kCmdLoginPlatform`,1001=`kCmdLoginBroker`
150/// - `conn_identity`:Platform 是 1-6(按 UserAttribution),Broker 是 1001/1007/...
151/// - `effective_user_id`:Platform 是 uid,Broker 是 **customer_id (cid)** —— 对齐
152///   C++ `channel_->outer_uid_`(`logger.cpp:107,120,161,194`)
153///
154/// `TcpLoginChannel::client_sig`:Platform 用 `auth.client_sig`,Broker 用 `broker_client_sig`。
155pub async fn tcp_login_raw(
156    conn: &BackendConn,
157    client_key: &[u8],
158    target: TcpLoginTarget<'_>,
159    channel: TcpLoginChannel<'_>,
160) -> Result<LoginResult> {
161    let TcpLoginTarget {
162        is_new_login,
163        redirect_ttl,
164        host_ip,
165        host_port,
166    } = target;
167    let TcpLoginChannel {
168        cmd_id,
169        conn_identity,
170        effective_user_id,
171        client_sig,
172    } = channel;
173
174    // ===== 构建 ReqEncryptData(对齐 C++ logger.cpp:160-180)=====
175    let mut req_encrypt = Vec::with_capacity(128);
176    // field 1: user_id (uint64) —— Broker 场景是 customer_id 而非 auth.user_id
177    prost::encoding::uint64::encode(1, &effective_user_id, &mut req_encrypt);
178    // field 2: mac_addr (string) —— FutuOpenD 抓包上报 "00:00:00:00:00:00" 也能登
179    let mac_addr = "00:00:00:00:00:00".to_string();
180    prost::encoding::string::encode(2, &mac_addr, &mut req_encrypt);
181    // field 3: os_type (uint32) —— C++ 用 GetClientTypeValue(),FutuOpenD FTNN=40
182    prost::encoding::uint32::encode(3, &40u32, &mut req_encrypt);
183    // field 4: client_ex_ver (uint32) —— C++ 用 GetBuildVersion() = FTGTW_Client_Build (6208)
184    // 注意这是 BuildVersion,不是 BackendConn::CLIENT_VER_FTGTW。
185    prost::encoding::uint32::encode(4, &FTGTW_CLIENT_BUILD, &mut req_encrypt);
186    // field 5: net_type (uint32) —— 3=ETHERNET
187    prost::encoding::uint32::encode(5, &NET_TYPE_ETHERNET, &mut req_encrypt);
188    // field 6: redirect_ttl (uint32)
189    prost::encoding::uint32::encode(6, &redirect_ttl, &mut req_encrypt);
190    // field 7: host_ip (string) —— 当前 TCP 连接的目标 IP,服务端用来识别接入点
191    let host_ip_str = host_ip.to_string();
192    prost::encoding::string::encode(7, &host_ip_str, &mut req_encrypt);
193    // field 8: conn_identity (uint32) —— 服务端用来识别"登错情况"的关键字段
194    // Platform: 1-6 (CN/US/SG/AU/JP/HK);Broker: 1001/1007/1008/... 对齐 FTConnCmn.proto
195    prost::encoding::uint32::encode(8, &conn_identity, &mut req_encrypt);
196    // field 9: host_port (uint32)
197    prost::encoding::uint32::encode(9, &host_port, &mut req_encrypt);
198    // field 10: client_feature 嵌套 message(新版客户端必填)——
199    // 字段 1 device_model、2 net_type、3 carrier
200    let client_feature = build_client_feature();
201    prost::encoding::bytes::encode(10, &client_feature, &mut req_encrypt);
202    // field 12: os_name (string) —— 对齐 C++ `AppConfig::GetOSName()` 的返回值
203    //("Mac" / "Windows" / "Ubuntu" / "CentOS",注意不是 "macOS"),
204    // 按运行时 target_os 派生;v1.4.7 前硬编码 "macOS" 是错误字符串
205    let os = os_name().to_string();
206    prost::encoding::string::encode(12, &os, &mut req_encrypt);
207
208    // ===== AES-CBC-MD5 加密 ReqEncryptData =====
209    // C++ `OMCrypt_FTAES_MD5_Encrypt(client_key.c_str(), client_key.size(), ...)`
210    // 用**完整 client_key** —— 32 字节时是 AES-256,16 字节时是 AES-128
211    // 我们 v1.4.6 之前错用 client_key[..16] 截断到 AES-128,是 bug
212    let encrypted_data = if client_key.is_empty() {
213        req_encrypt.clone()
214    } else {
215        futu_net::encrypt::aes_cbc_md5_encrypt_var(client_key, &req_encrypt)?
216    };
217
218    // ===== 构建 LoginReq 外层协议 =====
219    // field 1: user_id  field 2: new_login  field 3: client_sig  field 4: encrypt_data
220    let mut login_req = Vec::with_capacity(256);
221    prost::encoding::uint64::encode(1, &effective_user_id, &mut login_req);
222    prost::encoding::bool::encode(2, &is_new_login, &mut login_req);
223    prost::encoding::bytes::encode(3, &client_sig.to_vec(), &mut login_req);
224    prost::encoding::bytes::encode(4, &encrypted_data, &mut login_req);
225
226    let chan_desc = match cmd_id {
227        CMD_LOGIN_PLATFORM => "platform",
228        CMD_LOGIN_BROKER => "broker",
229        _ => "other",
230    };
231    tracing::info!(
232        user_id = effective_user_id,
233        cmd_id = cmd_id,
234        channel = chan_desc,
235        conn_identity = conn_identity,
236        host_fp = %endpoint_log_fingerprint(&format!("{host_ip}:{host_port}")),
237        "sending TCP login request"
238    );
239    tracing::debug!(
240        login_req_len = login_req.len(),
241        req_encrypt_plain_len = req_encrypt.len(),
242        client_key_len = client_key.len(),
243        client_sig_len = client_sig.len(),
244        encrypted_data_len = encrypted_data.len(),
245        "TCP login request details"
246    );
247
248    let resp_frame = conn.request(cmd_id, login_req).await?;
249
250    // ===== 解析 LoginRsp =====
251    let resp_body = &resp_frame.body;
252    // v1.4.102 F-003 fix (P2, leaf v1.4.100 报告): redact LoginRsp body hex.
253    // 历史: DEBUG log 把 encrypted LoginRsp body 全 hex dump (96 字节). 不是
254    // 明文密码泄漏, 但 auth response body 应 default redact (defense-in-depth).
255    // 攻击者可能结合其他 log 离线分析 cipher / session_key 派生路径.
256    // 改打 length + first-4-bytes hex (cmd_id sniff 已足够 debug, 不暴露 payload).
257    tracing::debug!(
258        resp_body_len = resp_body.len(),
259        resp_body_first4 = hex::encode(&resp_body[..resp_body.len().min(4)]),
260        "TCP login response (body redacted, F-003 v1.4.102 fix)"
261    );
262
263    let result_code = extract_int32_field(resp_body, 1).unwrap_or(-1);
264
265    if result_code != 0 {
266        let desc = extract_bytes_field(resp_body, 3).unwrap_or_default();
267        let desc_str = String::from_utf8_lossy(&desc).to_string();
268
269        if result_code == 1 {
270            // 重定向
271            let redirect = parse_login_redirect(resp_body)?;
272            let redirect_endpoint = format!("{}:{}", redirect.addr, redirect.port);
273            tracing::warn!(
274                addr_fp = %endpoint_log_fingerprint(&redirect_endpoint),
275                ttl = redirect.ttl,
276                "login redirect"
277            );
278            return Err(FutuError::ServerError {
279                ret_type: result_code,
280                msg: format!("redirect to {}:{}", redirect.addr, redirect.port),
281            });
282        }
283
284        return Err(FutuError::ServerError {
285            ret_type: result_code,
286            msg: desc_str,
287        });
288    }
289
290    // 解密 RspEncryptData —— 同样用**完整 client_key**
291    let enc_data = extract_bytes_field(resp_body, 2)
292        .ok_or(FutuError::Codec("missing encrypt_data in LoginRsp".into()))?;
293
294    let dec_data = if client_key.is_empty() {
295        enc_data
296    } else {
297        futu_net::encrypt::aes_cbc_md5_decrypt_var(client_key, &enc_data)?
298    };
299
300    let result = parse_rsp_encrypt_login_result(&dec_data, effective_user_id)?;
301    let session_key_len = result.session_key.len();
302
303    let session_key_len_marker = format_session_key_len_marker(session_key_len);
304    tracing::info!(
305        user_id = result.user_id,
306        keep_alive = result.keep_alive_interval,
307        session_key_len,
308        session_key_len_marker = %session_key_len_marker,
309        client_ip_present = !result.client_ip.is_empty(),
310        "TCP login succeeded, got session key; {session_key_len_marker}"
311    );
312
313    Ok(result)
314}
315
316#[derive(Debug, Clone, PartialEq, Eq)]
317struct LoginRedirect {
318    addr: String,
319    port: u32,
320    ttl: u32,
321}
322
323fn parse_login_redirect(resp_body: &[u8]) -> Result<LoginRedirect> {
324    // C++ `FTChannelImpl::Logger::OnRecvNormalLoginProtocolRedirect`
325    // (`FTLogin/Src/ftlogin/channel/impl/logger.cpp:655-684`) requires all
326    // three redirect fields before reconnecting.
327    let addr = extract_string_field(resp_body, 5)
328        .ok_or_else(|| FutuError::Codec("missing redir_svr_addr in redirect LoginRsp".into()))?;
329    let port = extract_uint32_field(resp_body, 6)
330        .ok_or_else(|| FutuError::Codec("missing redir_svr_port in redirect LoginRsp".into()))?;
331    let ttl = extract_uint32_field(resp_body, 8)
332        .ok_or_else(|| FutuError::Codec("missing redirect_ttl in redirect LoginRsp".into()))?;
333
334    Ok(LoginRedirect { addr, port, ttl })
335}
336
337fn parse_rsp_encrypt_login_result(dec_data: &[u8], effective_user_id: u64) -> Result<LoginResult> {
338    // 解析 RspEncryptData 字段(fallback 用 effective_user_id,
339    // 以便 broker 登录成功时也能正确回落到 customer_id)
340    let user_id = extract_uint64_field(dec_data, 1).unwrap_or(effective_user_id);
341    let session_key_bytes = extract_bytes_field(dec_data, 4)
342        .ok_or_else(|| FutuError::Codec("missing session_key in RspEncryptData".into()))?;
343    let session_key_len = session_key_bytes.len();
344    let keep_alive = extract_uint32_field(dec_data, 8).unwrap_or(10);
345    let sec_data = extract_uint32_field(dec_data, 9).unwrap_or(1);
346    let server_time = extract_uint64_field(dec_data, 10).unwrap_or(0);
347    let client_ip = extract_string_field(dec_data, 14).unwrap_or_default();
348
349    // 检查 session_key 长度合法(AES-128/192/256 要求 16/24/32)
350    if !matches!(session_key_len, 16 | 24 | 32) {
351        return Err(FutuError::Encryption(format!(
352            "session key has unexpected length: {} bytes (expected 16/24/32)",
353            session_key_len
354        )));
355    }
356
357    Ok(LoginResult {
358        user_id,
359        session_key: session_key_bytes,
360        keep_alive_interval: keep_alive,
361        sec_data,
362        server_time,
363        client_ip,
364    })
365}
366
367/// 对齐 C++ `logger.cpp:177-179`:
368/// `client_feature.device_model = AppConfig::GetDeviceModel()` 等 3 字段。
369/// ClientFeature proto:field 1 device_model, 2 net_type, 3 carrier.
370fn build_client_feature() -> Vec<u8> {
371    let mut out = Vec::with_capacity(32);
372    // field 1: device_model (string) —— C++ 拿 `AppConfig::GetDeviceModel()`(如
373    // "MacBookPro15,4")。当前未做机型探测,用 OS 名字兜底("Mac" / "Ubuntu" / ...)
374    let device_model = os_name().to_string();
375    prost::encoding::string::encode(1, &device_model, &mut out);
376    // field 2: net_type (string) —— C++ `GetNetTypeStr(kNetTypeEthernet)` 返回 "LAN"
377    // (`logger.cpp:47`)不是 "Ethernet"。v1.4.7 前写错了
378    let net_type = net_type_str_ethernet().to_string();
379    prost::encoding::string::encode(2, &net_type, &mut out);
380    // field 3: carrier (string) —— 桌面无 carrier,省略
381    out
382}
383
384// ===== 简单的 protobuf 字段提取(避免依赖内部 proto 编译) =====
385
386fn extract_int32_field(data: &[u8], field_num: u32) -> Option<i32> {
387    extract_varint_field(data, field_num).map(|v| v as i32)
388}
389
390fn extract_uint32_field(data: &[u8], field_num: u32) -> Option<u32> {
391    extract_varint_field(data, field_num).map(|v| v as u32)
392}
393
394fn extract_uint64_field(data: &[u8], field_num: u32) -> Option<u64> {
395    extract_varint_field(data, field_num)
396}
397
398fn extract_string_field(data: &[u8], field_num: u32) -> Option<String> {
399    extract_bytes_field(data, field_num).map(|b| String::from_utf8_lossy(&b).to_string())
400}
401
402fn extract_bytes_field(data: &[u8], field_num: u32) -> Option<Vec<u8>> {
403    let mut pos = 0;
404    while pos < data.len() {
405        let (tag, new_pos) = decode_varint(data, pos)?;
406        pos = new_pos;
407
408        let wire_type = (tag & 0x07) as u8;
409        let num = (tag >> 3) as u32;
410
411        match wire_type {
412            0 => {
413                // varint
414                let (_val, new_pos) = decode_varint(data, pos)?;
415                if num == field_num {
416                    return Some(vec![]); // varint 不是 bytes
417                }
418                pos = new_pos;
419            }
420            2 => {
421                // length-delimited
422                let (len, new_pos) = decode_varint(data, pos)?;
423                pos = new_pos;
424                let len = len as usize;
425                if pos + len > data.len() {
426                    return None;
427                }
428                if num == field_num {
429                    return Some(data[pos..pos + len].to_vec());
430                }
431                pos += len;
432            }
433            1 => {
434                pos += 8;
435            } // 64-bit
436            5 => {
437                pos += 4;
438            } // 32-bit
439            _ => return None,
440        }
441    }
442    None
443}
444
445fn extract_varint_field(data: &[u8], field_num: u32) -> Option<u64> {
446    let mut pos = 0;
447    while pos < data.len() {
448        let (tag, new_pos) = decode_varint(data, pos)?;
449        pos = new_pos;
450
451        let wire_type = (tag & 0x07) as u8;
452        let num = (tag >> 3) as u32;
453
454        match wire_type {
455            0 => {
456                let (val, new_pos) = decode_varint(data, pos)?;
457                if num == field_num {
458                    return Some(val);
459                }
460                pos = new_pos;
461            }
462            2 => {
463                let (len, new_pos) = decode_varint(data, pos)?;
464                pos = new_pos + len as usize;
465            }
466            1 => {
467                pos += 8;
468            }
469            5 => {
470                pos += 4;
471            }
472            _ => return None,
473        }
474    }
475    None
476}
477
478fn decode_varint(data: &[u8], start: usize) -> Option<(u64, usize)> {
479    let mut result: u64 = 0;
480    let mut shift = 0;
481    let mut pos = start;
482    loop {
483        if pos >= data.len() {
484            return None;
485        }
486        let byte = data[pos];
487        result |= ((byte & 0x7F) as u64) << shift;
488        pos += 1;
489        if byte & 0x80 == 0 {
490            return Some((result, pos));
491        }
492        shift += 7;
493        if shift >= 64 {
494            return None;
495        }
496    }
497}
498
499#[cfg(test)]
500mod tests;