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