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_command_spec::CommandSpecId;
11use futu_core::error::{FutuError, Result};
12use futu_core::log_redact::endpoint_log_fingerprint;
13use futu_core::server_time::ServerTimeAnchorUpdate;
14use futu_domain_auth::{
15    TcpLoginChannelKind, TcpLoginCredentialFacts, TcpLoginIgnoredResponseFieldPresence,
16    TcpLoginOuterRequestFacts, TcpLoginTargetReason, plan_tcp_login_outer_request_like_cpp,
17    plan_tcp_login_request_encrypt_data_like_cpp, tcp_login_is_new_login_like_cpp,
18    tcp_login_redirect_ttl_like_cpp, validate_tcp_login_credentials_like_cpp,
19};
20use std::time::{Instant, SystemTime, UNIX_EPOCH};
21
22use crate::auth::{AuthResult, redact::uid_log_fingerprint};
23use crate::conn::BackendConn;
24use crate::connection_lifecycle_runtime::execute_connection_lifecycle;
25
26mod request_encode;
27mod request_env;
28mod response_parse;
29mod wire_decode;
30
31/// Compatibility aliases for callers that still name the C++ login commands.
32pub use futu_domain_auth::{
33    TCP_LOGIN_BROKER_CMD as CMD_LOGIN_BROKER, TCP_LOGIN_PLATFORM_CMD as CMD_LOGIN_PLATFORM,
34    TcpLoginSessionPhase,
35};
36
37pub fn format_session_key_len_marker(session_key_len: usize) -> String {
38    format!("session_key_len={session_key_len}")
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42enum TcpLoginStage {
43    LoginRspParsed,
44    SessionKeyAccepted,
45}
46
47impl TcpLoginStage {
48    const fn as_str(self) -> &'static str {
49        match self {
50            Self::LoginRspParsed => "login_rsp_parsed",
51            Self::SessionKeyAccepted => "session_key_accepted",
52        }
53    }
54}
55
56fn format_tcp_login_stage_marker(
57    cmd_id: u16,
58    stage: TcpLoginStage,
59    session_key_len: Option<usize>,
60) -> String {
61    match session_key_len {
62        Some(session_key_len) => format!(
63            "cmd_id={cmd_id} stage={} session_key_len={session_key_len}",
64            stage.as_str()
65        ),
66        None => format!("cmd_id={cmd_id} stage={}", stage.as_str()),
67    }
68}
69
70fn trace_tcp_login_stage(cmd_id: u16, stage: TcpLoginStage, session_key_len: Option<usize>) {
71    let marker = format_tcp_login_stage_marker(cmd_id, stage, session_key_len);
72    tracing::debug!(
73        cmd_id,
74        stage = stage.as_str(),
75        session_key_len,
76        marker = %marker,
77        "TCP login response stage"
78    );
79}
80
81/// Rust transport safety bound shared by Platform/Broker login loops.
82///
83/// C++ re-establishes immediately and relies on the server-managed TTL rather
84/// than exposing a client-side numeric cap (`logger.cpp:718-748`). Rust keeps
85/// the existing three-redirect bound to avoid an unbounded malicious/broken
86/// redirect chain. Replace this only when a server/config cap is available or
87/// channel-runtime owns a stronger cycle detector.
88pub const TCP_LOGIN_REDIRECT_SAFETY_LIMIT: usize = 3;
89
90/// 登录结果
91#[derive(Debug, Clone)]
92pub struct LoginResult {
93    pub user_id: u64,
94    /// RspEncryptData.session_key 原始字节 —— 长度由服务端决定,对齐 C++
95    /// `Logger::session_key_` 是 `std::string`(`logger.h:152`)变长存储。
96    /// Platform 通常 16 字节,Broker 可能 32 字节,不能强制截断。
97    pub session_key: Vec<u8>,
98    /// Effective C++ `StartSessionKeyTimer` interval. Missing or zero field 6
99    /// is normalized to 180 seconds by the auth domain.
100    pub session_key_update_interval: u32,
101    /// Optional channel metadata fields consumed by C++ login success.
102    pub web_url_head: Option<String>,
103    pub keep_alive_interval: u32,
104    pub sec_data: Option<u32>,
105    /// Complete Platform login server-time sample captured at network receive.
106    /// Broker callers deliberately ignore this update; only Platform owns the
107    /// process-wide server clock, matching FTLogin `logger.cpp:558-568`.
108    pub server_time_update: Option<ServerTimeAnchorUpdate>,
109    /// RspEncryptData.client_ip(field 14), server 视角的客户端外网 IP。
110    ///
111    /// Ref: FTLogin `FTConnLogin.proto:105` + `logger.cpp:511-516`。
112    /// C++ 会把它保存到 working_tcp_client->client_ip,并在 broker CMD20147
113    /// `ConnIpReq.client_feature.client_ip` 中回填;broker 1007 会严格校验。
114    pub client_ip: String,
115    pub update_flag: Option<u32>,
116    pub web_session_id: Option<u64>,
117    /// Redaction-safe ownership markers for fields 2/3/5, whose values C++
118    /// normal-login success does not consume.
119    pub ignored_field_presence: TcpLoginIgnoredResponseFieldPresence,
120}
121
122/// TCP login target fields written into `ReqEncryptData` fields 2/6/7/9.
123#[derive(Debug, Clone, Copy)]
124pub struct TcpLoginTarget<'a> {
125    pub is_new_login: bool,
126    pub redirect_ttl: u32,
127    pub host_ip: &'a str,
128    pub host_port: u32,
129}
130
131impl<'a> TcpLoginTarget<'a> {
132    pub fn fresh(phase: TcpLoginSessionPhase, host_ip: &'a str, host_port: u32) -> Self {
133        Self::new(
134            tcp_login_is_new_login_like_cpp(phase),
135            tcp_login_redirect_ttl_like_cpp(TcpLoginTargetReason::FreshConnection),
136            host_ip,
137            host_port,
138        )
139    }
140
141    pub fn redirected(
142        phase: TcpLoginSessionPhase,
143        server_ttl: u32,
144        host_ip: &'a str,
145        host_port: u32,
146    ) -> Self {
147        Self::new(
148            tcp_login_is_new_login_like_cpp(phase),
149            tcp_login_redirect_ttl_like_cpp(TcpLoginTargetReason::ServerRedirect { server_ttl }),
150            host_ip,
151            host_port,
152        )
153    }
154
155    /// Compatibility constructor for callers that already own an explicit
156    /// wire TTL. New runtime paths should prefer [`Self::fresh`] or
157    /// [`Self::redirected`] so a local retry counter cannot leak onto the wire.
158    pub fn new(is_new_login: bool, redirect_ttl: u32, host_ip: &'a str, host_port: u32) -> Self {
159        Self {
160            is_new_login,
161            redirect_ttl,
162            host_ip,
163            host_port,
164        }
165    }
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct TcpLoginRedirect {
170    pub result_code: i32,
171    pub addr: String,
172    pub port: u32,
173    pub ttl: u32,
174    /// C++ attaches this optional field (default 0) to the redirect address
175    /// for connection reporting. Rust has no equivalent report channel,
176    /// so callers preserve and log it instead of silently dropping field 10.
177    pub condition_flag: i32,
178}
179
180impl TcpLoginRedirect {
181    pub fn endpoint(&self) -> String {
182        format!("{}:{}", self.addr, self.port)
183    }
184}
185
186#[derive(Debug, Clone)]
187pub enum TcpLoginAttemptOutcome {
188    Success(LoginResult),
189    Redirect(TcpLoginRedirect),
190}
191
192/// Channel-specific login identity. Platform and broker login share the same
193/// body shape, but these fields intentionally differ.
194#[derive(Debug, Clone, Copy)]
195pub struct TcpLoginChannel<'a> {
196    pub cmd_id: u16,
197    pub conn_identity: u32,
198    pub effective_user_id: u64,
199    pub client_sig: &'a [u8],
200}
201
202impl<'a> TcpLoginChannel<'a> {
203    pub fn platform(auth: &'a AuthResult) -> Self {
204        Self {
205            cmd_id: CMD_LOGIN_PLATFORM,
206            conn_identity: auth.user_attribution.to_conn_identity(),
207            effective_user_id: auth.user_id,
208            client_sig: &auth.client_sig,
209        }
210    }
211
212    pub fn broker(conn_identity: u32, customer_id: u64, broker_client_sig: &'a [u8]) -> Self {
213        Self {
214            cmd_id: CMD_LOGIN_BROKER,
215            conn_identity,
216            effective_user_id: customer_id,
217            client_sig: broker_client_sig,
218        }
219    }
220}
221
222pub async fn tcp_login_attempt(
223    conn: &BackendConn,
224    auth: &AuthResult,
225    client_key: &[u8],
226    target: TcpLoginTarget<'_>,
227) -> Result<TcpLoginAttemptOutcome> {
228    tcp_login_raw_attempt(conn, client_key, target, TcpLoginChannel::platform(auth)).await
229}
230
231/// 执行 TCP 登录(通用版 —— 同时用于 Platform cmd=6001 和 Broker cmd=1001)
232///
233/// 构造 `ReqEncryptData` → AES-CBC-MD5 加密(key=完整 client_key,
234/// 32 字节时是 AES-256) → 放进 `LoginReq.encrypt_data` → 发指定 cmd。
235///
236/// 对齐 `logger.cpp:150-220` —— 该函数是 C++ `SendNormalLoginProtocol` 的等价
237/// 实现,cmd=6001/1001 共用同一套 ReqEncryptData 字段布局,只是以下三个值
238/// 随通道变化:
239///
240/// - `cmd_id`:6001=`kCmdLoginPlatform`,1001=`kCmdLoginBroker`
241/// - `conn_identity`:Platform 是 1-6(按 UserAttribution),Broker 是 1001/1007/...
242/// - `effective_user_id`:Platform 是 uid,Broker 是 **customer_id (cid)** —— 对齐
243///   C++ `channel_->outer_uid_`(`logger.cpp:107,120,161,194`)
244///
245/// `TcpLoginChannel::client_sig`:Platform 用 `auth.client_sig`,Broker 用 `broker_client_sig`。
246pub async fn tcp_login_raw_attempt(
247    conn: &BackendConn,
248    client_key: &[u8],
249    target: TcpLoginTarget<'_>,
250    channel: TcpLoginChannel<'_>,
251) -> Result<TcpLoginAttemptOutcome> {
252    let TcpLoginTarget {
253        is_new_login,
254        redirect_ttl,
255        host_ip,
256        host_port,
257    } = target;
258    let TcpLoginChannel {
259        cmd_id,
260        conn_identity,
261        effective_user_id,
262        client_sig,
263    } = channel;
264    let channel_kind = TcpLoginChannelKind::from_command_id(cmd_id)
265        .ok_or_else(|| FutuError::Codec(format!("unsupported TCP login command: {cmd_id}")))?;
266    validate_tcp_login_credentials_like_cpp(TcpLoginCredentialFacts {
267        client_key_len: client_key.len(),
268        client_sig_len: client_sig.len(),
269    })
270    .map_err(|error| FutuError::Codec(error.to_string()))?;
271
272    let req_encrypt_facts = request_env::tcp_login_request_encrypt_data_facts(
273        effective_user_id,
274        redirect_ttl,
275        host_ip,
276        host_port,
277        conn_identity,
278    );
279    let req_encrypt_plan = plan_tcp_login_request_encrypt_data_like_cpp(req_encrypt_facts);
280    let req_encrypt = request_encode::build_req_encrypt_data_from_plan(&req_encrypt_plan);
281
282    // ===== AES-CBC-MD5 加密 ReqEncryptData =====
283    // C++ `OMCrypt_FTAES_MD5_Encrypt(client_key.c_str(), client_key.size(), ...)`
284    // 用**完整 client_key** —— 32 字节时是 AES-256,16 字节时是 AES-128
285    // 我们 v1.4.6 之前错用 client_key[..16] 截断到 AES-128,是 bug
286    let encrypted_data = futu_net::encrypt::aes_cbc_md5_encrypt_var(client_key, &req_encrypt)?;
287
288    let outer_plan = plan_tcp_login_outer_request_like_cpp(TcpLoginOuterRequestFacts {
289        channel_kind,
290        user_id: effective_user_id,
291        new_login: is_new_login,
292        client_sig: client_sig.to_vec(),
293        encrypted_data,
294    });
295    let login_req = request_encode::build_login_req_from_plan(&outer_plan);
296
297    tracing::info!(
298        user_id_fp = %uid_log_fingerprint(effective_user_id),
299        cmd_id = outer_plan.cmd_id,
300        channel = outer_plan.channel_name,
301        conn_identity = conn_identity,
302        host_fp = %endpoint_log_fingerprint(&format!("{host_ip}:{host_port}")),
303        "sending TCP login request"
304    );
305    tracing::debug!(
306        login_req_len = login_req.len(),
307        req_encrypt_plain_len = req_encrypt.len(),
308        client_key_len = client_key.len(),
309        client_sig_len = client_sig.len(),
310        encrypted_data_len = outer_plan.encrypted_data.len(),
311        "TCP login request details"
312    );
313
314    let request_started_at = Instant::now();
315    let resp_frame = execute_connection_lifecycle(
316        conn,
317        CommandSpecId::LoginPlaintext(outer_plan.cmd_id),
318        login_req,
319    )
320    .await?;
321    let response_captured_at = Instant::now();
322    let response_timing = response_parse::LoginResponseTiming {
323        round_trip_micros: duration_micros_u64(
324            response_captured_at.saturating_duration_since(request_started_at),
325        ),
326        local_receive_unix_micros: local_unix_micros(),
327        captured_at: response_captured_at,
328    };
329
330    // ===== 解析 LoginRsp =====
331    let resp_body = &resp_frame.body;
332    // v1.4.102 F-003 fix (P2, leaf v1.4.100 报告): redact LoginRsp body hex.
333    // 历史: DEBUG log 把 encrypted LoginRsp body 全 hex dump (96 字节). 不是
334    // 明文密码泄漏, 但 auth response body 应 default redact (defense-in-depth).
335    // 攻击者可能结合其他 log 离线分析 cipher / session_key 派生路径.
336    // 只记录长度;不输出 body prefix、hex 或 base64。
337    tracing::debug!(
338        resp_body_len = resp_body.len(),
339        "TCP login response (body redacted, F-003 v1.4.102 fix)"
340    );
341
342    let response_action = response_parse::parse_login_response_action(resp_body)?;
343    trace_tcp_login_stage(cmd_id, TcpLoginStage::LoginRspParsed, None);
344    match response_action {
345        response_parse::LoginResponseAction::Success => {}
346        response_parse::LoginResponseAction::Redirect {
347            result_code,
348            redirect,
349        } => {
350            // 重定向
351            let redirect_endpoint = format!("{}:{}", redirect.addr, redirect.port);
352            tracing::warn!(
353                addr_fp = %endpoint_log_fingerprint(&redirect_endpoint),
354                ttl = redirect.ttl,
355                "login redirect"
356            );
357            return Ok(TcpLoginAttemptOutcome::Redirect(TcpLoginRedirect {
358                result_code,
359                addr: redirect.addr,
360                port: redirect.port,
361                ttl: redirect.ttl,
362                condition_flag: redirect.condition_flag,
363            }));
364        }
365        response_parse::LoginResponseAction::Failure {
366            result_code,
367            message,
368        } => {
369            return Err(FutuError::ServerError {
370                ret_type: result_code,
371                msg: message,
372            });
373        }
374    }
375
376    // 解密 RspEncryptData —— 同样用**完整 client_key**
377    let enc_data = response_parse::login_rsp_encrypt_data(resp_body)?;
378
379    let dec_data = futu_net::encrypt::aes_cbc_md5_decrypt_var(client_key, &enc_data)?;
380
381    let result = response_parse::parse_rsp_encrypt_login_result(
382        &dec_data,
383        effective_user_id,
384        response_timing,
385    )?;
386    let session_key_len = result.session_key.len();
387    trace_tcp_login_stage(
388        cmd_id,
389        TcpLoginStage::SessionKeyAccepted,
390        Some(session_key_len),
391    );
392
393    let session_key_len_marker = format_session_key_len_marker(session_key_len);
394    tracing::info!(
395        user_id_fp = %uid_log_fingerprint(result.user_id),
396        keep_alive = result.keep_alive_interval,
397        session_key_update_interval = result.session_key_update_interval,
398        session_key_len,
399        session_key_len_marker = %session_key_len_marker,
400        client_ip_present = !result.client_ip.is_empty(),
401        web_url_head_present = result.web_url_head.is_some(),
402        update_flag_present = result.update_flag.is_some(),
403        web_session_id_present = result.web_session_id.is_some(),
404        ignored_user_auth_flag_present = result.ignored_field_presence.user_auth_flag,
405        ignored_user_service_flag_present = result.ignored_field_presence.user_service_flag,
406        ignored_web_session_key_present = result.ignored_field_presence.web_session_key,
407        "TCP login succeeded, got session key; {session_key_len_marker}"
408    );
409
410    Ok(TcpLoginAttemptOutcome::Success(result))
411}
412
413fn duration_micros_u64(duration: std::time::Duration) -> u64 {
414    duration.as_micros().min(u128::from(u64::MAX)) as u64
415}
416
417fn local_unix_micros() -> Option<u64> {
418    match SystemTime::now().duration_since(UNIX_EPOCH) {
419        Ok(elapsed) => u64::try_from(elapsed.as_micros()).ok(),
420        Err(error) => {
421            tracing::warn!(%error, "system clock is before UNIX_EPOCH; TCP login clock not updated");
422            None
423        }
424    }
425}
426
427#[cfg(test)]
428mod tests;