Skip to main content

futu_backend/auth/
types.rs

1//! v1.4.110+ Tier 1 split (from `auth/mod.rs`): 顶层类型 + 2 const.
2//!
3//! - `UserAttribution` compatibility re-export (owned by `futu-domain-auth`)
4//! - `AuthConfig` (输入: account / pwd / device_id / client_type)
5//! - `AuthResult` (输出: client_sig / client_key / auth_code_list / svr_time_offset / web_sig / ...)
6//! - `BrokerAuthCode` (auth_code_list 单元)
7//! - `AUTH_SERVER_PROD` / `TGTGT_VALIDITY_SECS` 顶层 const
8//!
9//! 不含业务逻辑 — 业务 fn 仍在 mod.rs.
10
11use crate::conn::BackendProtocolIdentity;
12use async_trait::async_trait;
13use futu_core::error::Result;
14pub use futu_domain_auth::UserAttribution;
15use futu_domain_auth::{
16    AuthChallengeCommand, AuthChallengeKind, AuthChallengeSnapshot, InputCompletion,
17};
18
19/// 默认认证服务器——CN / HK 归属地账号用这个。
20/// 海外账号(US/SG/AU/JP)实际请求时会按 `UserAttribution::auth_domain()` 切换。
21pub const AUTH_SERVER_PROD: &str = "https://auth.futunn.com";
22
23/// v1.4.71: TGTGT 票据有效期(30 天),对齐 C++ `auth_cryptor.cpp:135`
24/// `CreateNewTgtgt` 里 `InvalidTime = RefreshTime + 30 * 24 * 3600`。
25///
26/// **绝对不 hardcode**:之前 `30 * 24 * 3600` 魔法数散落在 2 处(`handle_device_verify`
27/// + `tgtgt_payload_structure_ftnn_aligns_cpp_spec` test),改动需同步。提为 const 保一致。
28pub const TGTGT_VALIDITY_SECS: u32 = 30 * 24 * 3600;
29
30pub struct AuthChallengeWork {
31    generation: u64,
32    command: Option<AuthChallengeCommand>,
33    response: tokio::sync::oneshot::Sender<Result<AuthChallengeSnapshot>>,
34}
35
36impl AuthChallengeWork {
37    pub fn new(
38        generation: u64,
39        command: AuthChallengeCommand,
40        response: tokio::sync::oneshot::Sender<Result<AuthChallengeSnapshot>>,
41    ) -> Self {
42        Self {
43            generation,
44            command: Some(command),
45            response,
46        }
47    }
48
49    pub fn generation(&self) -> u64 {
50        self.generation
51    }
52
53    pub fn take_command(&mut self) -> Option<AuthChallengeCommand> {
54        self.command.take()
55    }
56
57    pub fn respond(self, result: Result<AuthChallengeSnapshot>) {
58        let _ = self.response.send(result);
59    }
60}
61
62#[async_trait]
63pub trait AuthChallengePort: Send + Sync {
64    async fn publish_initial_requested(
65        &self,
66        kind: AuthChallengeKind,
67        masked_target: Option<String>,
68    ) -> Result<AuthChallengeSnapshot>;
69
70    async fn wait_for_work(&self) -> Result<AuthChallengeWork>;
71
72    fn complete_request(&self, generation: u64) -> Result<AuthChallengeSnapshot>;
73
74    /// Validate request-generation ownership and publish its external artifact
75    /// in one linearization boundary. Production runtimes override this to
76    /// hold their generation lock while `publish` runs.
77    fn complete_request_with(
78        &self,
79        generation: u64,
80        publish: &mut dyn FnMut() -> Result<()>,
81    ) -> Result<AuthChallengeSnapshot> {
82        self.snapshot(generation)?;
83        publish()?;
84        self.complete_request(generation)
85    }
86
87    fn fail_request(&self, generation: u64) -> Result<AuthChallengeSnapshot>;
88
89    fn complete_input(
90        &self,
91        generation: u64,
92        completion: InputCompletion,
93    ) -> Result<AuthChallengeSnapshot>;
94
95    fn snapshot(&self, generation: u64) -> Result<AuthChallengeSnapshot>;
96}
97
98/// Cached credentials ticket age summary for daemon diagnostics.
99///
100/// This exposes only timestamps / derived age, never `tgtgt`, rand key,
101/// device sig, web sig, account, or uid. `None` values mean legacy credentials
102/// did not carry `tgtgt_saved_at` yet.
103#[derive(Debug, Clone, serde::Serialize)]
104pub struct CredentialTicketStatus {
105    pub saved_at: Option<u64>,
106    pub age_days: Option<u64>,
107    pub expires_in_days: Option<i64>,
108    pub expiry_warning: Option<String>,
109}
110
111/// v1.4.19:`ZeroizeOnDrop` 让 `AuthConfig` drop 时自动把 `password` 字段的
112/// 堆内存清零,减少进程 core dump / `/proc/<pid>/mem` 读到明文的窗口。
113/// 其他字段(auth_server / account / device_id)不是秘密,`#[zeroize(skip)]`
114/// 跳过。注意 `Clone` 每次复制都会产生新堆分配,drop 时各自 zeroize。
115#[derive(Clone, zeroize::ZeroizeOnDrop)]
116pub struct AuthConfig {
117    #[zeroize(skip)]
118    pub auth_server: String,
119    #[zeroize(skip)]
120    pub account: String,
121    /// 密码:`password_is_md5 = false` 时是明文(内部做 MD5);
122    /// `password_is_md5 = true` 时是 32 位小写 hex 的 MD5(直接使用)。
123    /// drop 时自动 zeroize。
124    pub password: String,
125    /// 是否为预哈希 MD5;`false` 时按明文处理
126    #[zeroize(skip)]
127    pub password_is_md5: bool,
128    #[zeroize(skip)]
129    pub device_id: String,
130    /// C++ `AppConfig` protocol header identity.
131    ///
132    /// 对齐 C++ `NNBase_Define_Enum.h:1113-1114`:
133    /// - `40` = `NN_ClientType_FutuOpenD`(牛牛 FTNN)
134    /// - `60` = `NN_ClientType_FutuOpenDMooMoo`(moomoo FTMM)
135    ///
136    /// v1.4.15:moomoo 的 `auth.moomoo.com` 对 client-type=40 直接拒绝
137    /// (返回 `error_code=2`),必须用 60。由 `--platform` flag 决定。
138    #[zeroize(skip)]
139    pub protocol_identity: BackendProtocolIdentity,
140}
141
142impl std::fmt::Debug for AuthConfig {
143    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144        let account_fp = crate::auth::redact::account_log_fingerprint(&self.account);
145        let device_id_fp = crate::auth::redact::device_id_log_fingerprint(&self.device_id);
146        let redacted_password = format!("<REDACTED len={}>", self.password.len());
147
148        f.debug_struct("AuthConfig")
149            .field("auth_server", &self.auth_server)
150            .field("account_fp", &account_fp)
151            .field("password", &redacted_password)
152            .field("password_is_md5", &self.password_is_md5)
153            .field("device_id_fp", &device_id_fp)
154            .field("client_type", &self.protocol_identity.client_type())
155            .field("lang_id", &self.protocol_identity.lang_id())
156            .finish()
157    }
158}
159
160/// `auth_code_list` 响应条目——每个 broker 一项。
161/// 对齐 C++ `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:3504`(`ParseAuthCodeList`)
162#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
163pub struct BrokerAuthCode {
164    pub broker_id: u32,
165    pub auth_code: String,
166    pub invalid_time: u64,
167}
168
169#[derive(Debug, Clone)]
170pub struct AuthResult {
171    pub user_id: u64,
172    pub client_sig: Vec<u8>,
173    pub client_key: Vec<u8>,
174    /// 从 salt 响应拿到的归属地,TCP 登录时会用来派生 conn_identity
175    pub user_attribution: UserAttribution,
176    /// HTTP auth 响应的 `auth_code_list`——每个已授权 broker 的票据,
177    /// 用来后续向 `/broker_auth/client_auth` 换取 broker_client_sig / broker_client_key
178    pub auth_code_list: Vec<BrokerAuthCode>,
179    /// 原始 rand_key(解密用)——broker_auth 响应里的 `broker_client_key` 也是
180    /// 用这个 rand_key 加密过的,需要它做 `aes_cbc_md5_decrypt_var` 解开
181    pub rand_key: Vec<u8>,
182    /// v1.4.22:**服务端时间 - 本机时间**(秒)—— salt 响应时捕获。
183    ///
184    /// 用于让后续 TOTP / time-based token 用"服务端时间"而不是本机时间。
185    /// 机器时钟飘了 > 30s 的场景下 TOTP 会被服务端拒,offset 校正后可免。
186    /// 对齐 C++ `INNBiz_SvrTime::GetSvrTimeStamp()` 机制。
187    ///
188    /// 使用方式:`let server_now = local_now + svr_time_offset;`
189    /// 首次 salt 时 offset < 0 说明本机时钟比服务端快,反之则慢。0 = 没取到。
190    pub svr_time_offset: i64,
191    /// v1.4.93 G3 (CLAUDE.md C4 audit): `web_sig` from `/authority/` 响应里的
192    /// `web_sig_new` 字段(对齐 C++ `auth_impl.cpp:3193,3260`
193    /// `account.web_sig_`)。
194    ///
195    /// **用途**:G2 [`crate::auth::repull::repull_auth_code`] 把它作 POST
196    /// `/authority/repull_auth_code` body 字段(C++ `auth_impl.cpp:738-748`),
197    /// broker auth_code 过期时拉新 auth_code,让 broker channel self-heal
198    /// 不必重启 daemon。
199    ///
200    /// 缺失 → 空字符串(旧 v1.4.92 及之前的凭据 / device-verify shell 路径
201    /// 没此字段)。空时调用方应跳过 repull, fallback 走 platform refresh。
202    pub web_sig: String,
203    /// v1.4.93 G1 (CLAUDE.md C4 audit P1): client_sig 失效的本地时戳 (秒, UTC epoch).
204    /// 对齐 C++ `auth_impl.cpp:3245-3247` 解 `cltsig_invalidtime`
205    /// (服务端绝对过期 epoch),再按
206    /// `(cltsig_invalidtime - svr_time) + local_now` 得本地时间, 写到
207    /// `account.client_sig_invalid_local_time_s_`.
208    ///
209    /// **用途**: 记录服务端下发的 client_sig 失效时间。当前 auth 模块只负责
210    /// 解析/持久化该字段,不启动 proactive timer;长跑 daemon 的 client_sig
211    /// 更新走 reconnect 失败后的 reactive remember-login refresh 路径。
212    ///
213    /// **缺失 (老 backend / 旧 credentials shell)**: 0 (不触发 proactive refresh).
214    pub client_sig_invalid_local_time_s: u64,
215    /// v1.4.94 G6 (P2 protocol gap): `moomoo_client_sig` from `/authority/`
216    /// 响应里的 `moomoo_client_sig` 字段, **base64 已解码**.
217    ///
218    /// 对齐 C++ `auth_impl.cpp:3195` `ParseJsonString(jval_result, "moomoo_client_sig", mm_sig);`
219    /// 映射到 `account.us_client_sig_`. 用于 moomoo / US 路径独立于
220    /// `client_sig` 的 broker channel 鉴权 — 当账号 attribution = US/SG/AU/JP/CA
221    /// 时, broker_auth_code 换 client_sig 走的是 `moomoo_client_sig` 而不是
222    /// 主 `client_sig`.
223    ///
224    /// 缺失 (futunn HK 账号 / 老 backend) → 空 Vec (handler 检查 `is_empty()`
225    /// 决定 fallback 主 `client_sig`).
226    ///
227    /// ## ⚠️ v1.4.96 BUG #010 doctrine fix (external reviewer double-tester 2026-04-26):
228    ///
229    /// 真机 verify 发现 backend 对 **futunn 账号也下发 moomoo_client_sig**
230    /// (mm_sig_len=128). 字段名 `moomoo_*` **不**意味着账号是 moomoo 系.
231    ///
232    /// **不要**基于 `moomoo_client_sig.is_empty()` 判账号 broker path. 真正
233    /// 的 broker path 判断用 `broker_id` (1001/1007=Futu HK/US, 6xxx=moomoo).
234    pub moomoo_client_sig: Vec<u8>,
235    /// v1.4.94 G6 (P2 protocol gap): `moomoo_client_key` 解密后的 client key
236    /// (对应 moomoo path), 与 `client_key` 平级. 缺失 → 空 Vec.
237    ///
238    /// 对齐 C++ `auth_impl.cpp:3196,3260` `account.us_client_key_` (经
239    /// `UpdateRandKey` 解密).
240    pub moomoo_client_key: Vec<u8>,
241    /// v1.4.94 G6 (P2 protocol gap): `moomoo_web_sig_new` from `/authority/`
242    /// 响应. 对齐 C++ `auth_impl.cpp:3197,3260` `account.us_web_sig_`. 用于
243    /// moomoo path repull_auth_code (类似 `web_sig` 之于主 path). 缺失 →
244    /// 空字符串.
245    pub moomoo_web_sig: String,
246}
247
248/// Initial authentication result plus the pre-login site-config runtime state
249/// that must be handed to the authenticated Gateway lifecycle exactly once.
250///
251/// Keeping this wrapper separate prevents reactive credential refreshes from
252/// replacing the already-running authenticated site-config actor/store.
253#[derive(Debug, Clone)]
254pub struct AuthSession {
255    pub auth_result: AuthResult,
256    pub bootstrap_site_config: crate::auth::site_config::SharedSiteConfig,
257}
258
259#[cfg(test)]
260mod tests;