Skip to main content

futu_backend/auth/
mod.rs

1// HTTP 认证模块 — 移植自成功项目 futuopend-rs
2//
3// 流程: salt → tgtgt → POST auth → (可能设备验证) → client_sig + client_key
4
5use base64::Engine;
6use futu_core::error::{FutuError, Result};
7
8mod auth_ip_list;
9mod broker;
10pub mod redact;
11/// v1.4.92 P1-D Tier A1: in-process relogin trigger (AuthRefresher trait + DefaultAuthRefresher).
12pub mod refresh;
13/// v1.4.93 G2 (CLAUDE.md C4 audit): RepullAuthCode broker auth_code self-heal.
14/// Triggered when broker auth_code expires (typical 30-day window) or
15/// `kAuthNoValidCid` (20029); avoids requiring a daemon restart.
16pub mod repull;
17#[doc(hidden)]
18pub mod site_config;
19mod webtcp;
20pub use broker::{
21    BrokerAuth, BrokerAuthRequest, BrokerAuthRouteCache, BrokerConfig, broker_auth, broker_config,
22    is_cpp_known_broker_id,
23};
24pub use refresh::{AuthRefresher, DefaultAuthRefresher, REFRESH_TIMEOUT};
25pub use repull::{ERROR_CODE_NO_VALID_CID, repull_auth_code, repull_auth_code_with_client_type};
26pub use webtcp::install_default_rustls_crypto_provider;
27pub mod commconfig;
28mod parse;
29mod util;
30
31/// Platform 通道后端连接点,按 `UserAttribution` 分池。
32///
33/// 完整对齐 C++ `FTLogin/Src/ftlogin/channel/impl/address.cpp:495-557`
34/// `LoadHardcodeAddress()` 里 `CONN_PLATFORM_*` 的条目。每个 IP 后面是 C++
35/// 源里的 `Region::kRegion*` 字段(gz/sh/hk/us/sg/au/jp),仅作注释。
36///
37/// v1.4.11 前只有 CN 12 个 IP,海外账号(HK/US/SG/AU/JP)首选 IP 命中 CN 池
38/// → 非大陆网络连不通 → 进 offline mode。v1.4.10 的 fallback 逻辑即使有也只
39/// 会 fallback 到其他 CN IP,依然死路。修复方式是按 `user_attribution` 选池。
40///
41/// 端口 9595 是 C++ 硬编码的标准端口。
42pub mod conn_points;
43// v1.4.110+ Tier 1 split: 顶层类型 + 2 const 抽到 types.rs (无业务逻辑).
44mod types;
45pub use types::{
46    AUTH_SERVER_PROD, AuthConfig, AuthResult, BrokerAuthCode, CredentialTicketStatus,
47    TGTGT_VALIDITY_SECS, UserAttribution,
48};
49// v1.4.110+ Tier 1 split: reqwest HTTP client builder 抽到 http_client.rs.
50mod http_client;
51pub use http_client::build_http_client;
52pub(crate) use http_client::build_http_client_with_resolve;
53
54mod phone;
55use phone::normalize_phone_account;
56
57mod device;
58use device::{
59    DEVICE_CODE_SIG_TTL_SECS, DEVICE_VERIFY_SIG_TTL_SECS, fresh_cached_device_code_sig,
60    fresh_cached_device_verify_sig, load_credentials, save_credentials,
61};
62pub use device::{read_or_generate_device_id, reset_device_state, tighten_secret_files_at_startup};
63
64// v1.4.106 codex 0558 F2+F3: PII fingerprint helpers — log 不再放 raw account/uid.
65use redact::{account_log_fingerprint, uid_log_fingerprint};
66
67/// v1.4.102 BUG-009 root-cause fix (codex 24 F6 抽 helper, 让 test 覆盖生产
68/// path): pre-flight 决策 — cached `dvs+dcs` 都 fresh 且 user 提供
69/// `--verify-code` 时, 跳过 `remember_login` + authority POST + req_device_code,
70/// 直接 verify_device_code with cached values.
71///
72/// 输入: 3 个 bool — `dvs_fresh` / `dcs_fresh` / `has_verify_cb`.
73/// 输出: true = pre-flight skip; false = fall through 现有 remember_login 流.
74///
75/// 详见 pitfall #59 + `authenticate_with_callback` 的 wire site.
76pub(super) fn should_skip_remember_login_for_cached_sms(
77    dvs_fresh: bool,
78    dcs_fresh: bool,
79    has_verify_cb: bool,
80) -> bool {
81    dvs_fresh && dcs_fresh && has_verify_cb
82}
83
84pub(super) fn should_fallback_to_password_auth_after_remember_error(err: &FutuError) -> bool {
85    !matches!(
86        err,
87        FutuError::ServerError { ret_type: 20, msg }
88            if msg.starts_with("remember-login device verification did not complete:")
89    )
90}
91
92/// Return non-sensitive metadata about the cached auth ticket for diagnostics.
93pub fn credential_ticket_status(account: &str) -> Option<CredentialTicketStatus> {
94    device::credential_ticket_status(account)
95}
96
97fn decode_saved_rand_key_b64(rand_key_b64: &str) -> Result<Vec<u8>> {
98    let rand_key = base64::engine::general_purpose::STANDARD
99        .decode(rand_key_b64)
100        .map_err(|e| FutuError::Codec(format!("rand_key base64 decode: {e}")))?;
101    parse::validate_account_rand_key_len("cached credentials rand_key_b64", &rand_key)?;
102    Ok(rand_key)
103}
104
105/// v1.4.34: daemon-reload 升级(A' 方案)的产出。
106///
107/// 走一次 `remember_login` 用缓存凭据刷新 tgtgt,**只写回磁盘 credentials 文件**
108/// 不动 bridge 内存状态。下次 Platform / broker TCP 断线重连时自动读新 tgtgt。
109#[derive(Debug, Clone)]
110pub struct RefreshCredentialsReport {
111    /// 是否把新凭据成功写回了 credentials 文件
112    pub credentials_refreshed: bool,
113    /// 服务端返回的新 uid(大部分场景等于旧 uid,拿来 sanity check)
114    pub uid: u64,
115}
116
117/// v1.4.34: 给 `daemon-reload` 升级用——用磁盘缓存的 `(uid, tgtgt, device_sig,
118/// rand_key)` 走一次 `remember_login`,成功则把新 tgtgt 写回 credentials 文件。
119///
120/// **安全边界**:
121/// - 不保留 plaintext 密码(输入参数里没有 password)
122/// - 不动 bridge 内存 auth_result(不穿透 Bridge 字段可变性)
123/// - 只作用于磁盘文件
124///
125/// **失败场景**:
126/// - credentials 文件不存在 → `Err`(调用方应回退 "shutdown + restart")
127/// - tgtgt 过期(服务端拒)→ `Err`(同上)
128/// - 服务端返 code=20(重新 SMS 验证)→ `Err`(daemon 运行中不可能交互 SMS)
129pub async fn refresh_credentials_on_disk(
130    http: &reqwest::Client,
131    account: &str,
132    device_id: &str,
133    client_type: u8,
134    region_code: Option<&str>,
135    attribution: UserAttribution,
136) -> std::result::Result<RefreshCredentialsReport, FutuError> {
137    let cred = load_credentials(account).ok_or_else(|| {
138        FutuError::Codec(format!(
139            "no cached credentials for account '{}' to refresh; you likely \
140             need to shutdown + restart opend to re-auth from password",
141            account
142        ))
143    })?;
144    // 构造最小 AuthConfig——remember_login 内部用 account + client_type。
145    // client_type 必须沿真实平台传递:C++ TGTGT / User-Agent / AuthIPList
146    // 均使用 `AppConfig::GetClientTypeValue()` 的 40/60。
147    let minimal_config = AuthConfig {
148        auth_server: String::new(),
149        account: account.to_string(),
150        password: String::new(),
151        password_is_md5: false,
152        device_id: device_id.to_string(),
153        client_type,
154    };
155    // SavedCredentials 里存的是 base64 的 rand_key_b64,remember_login 需要
156    // 裸字节 —— 对齐 authenticate_with_callback 里的做法解码。
157    let rand_key = decode_saved_rand_key_b64(&cred.rand_key_b64)?;
158
159    // remember_login 对成功会返回 (AuthResult, Some(SavedCredentials))——
160    // 我们拿 SavedCredentials 写回文件,AuthResult 丢弃。
161    let (_auth_result, new_cred_opt) = remember_login(
162        http,
163        RememberLoginInput {
164            config: &minimal_config,
165            region_code,
166            attribution,
167            uid: cred.uid,
168            device_id,
169            device_sig: &cred.device_sig,
170            tgtgt: &cred.tgtgt,
171            rand_key: &rand_key,
172            web_sig: &cred.web_sig,
173            moomoo_web_sig: &cred.moomoo_web_sig,
174            verify_cb: None,
175            primary_webtcp: None,
176        },
177    )
178    .await?;
179    let credentials_refreshed = if let Some(new_cred) = new_cred_opt {
180        let uid = new_cred.uid;
181        // v1.4.106 codex 0558 F1: write IO 错 propagate, 不 silent drop
182        save_credentials(account, &new_cred).map_err(|e| {
183            FutuError::Codec(format!(
184                "admin reload: save_credentials failed — {e} (cred not on disk; \
185                 next startup will re-auth via password)"
186            ))
187        })?;
188        // v1.4.106 codex 0558 F2+F3: log fingerprint 替代 raw account/uid
189        tracing::info!(
190            account_fp = %account_log_fingerprint(account),
191            uid_fp = %uid_log_fingerprint(uid),
192            "admin reload: credentials refreshed on disk"
193        );
194        true
195    } else {
196        // 服务端成功但没返新凭据——老的仍然有效,算不刷
197        tracing::debug!(
198            account_fp = %account_log_fingerprint(account),
199            "admin reload: remember_login ok but no fresh credentials (still valid)"
200        );
201        false
202    };
203    Ok(RefreshCredentialsReport {
204        credentials_refreshed,
205        uid: cred.uid,
206    })
207}
208
209/// v1.4.94 G4: 用持久化 credentials 重做 remember-login, 拿到 fresh `AuthResult`
210/// (含新 `client_sig` / `client_key`).
211///
212/// ## 用途
213///
214/// G4 reactive client_sig refresh 路径: 当 reconnect tcp_login 持续失败暗示
215/// `client_sig` 失效时, 调用方:
216/// 1. 先调 `AuthRefresher::refresh_qot_login()` (refresh disk creds via
217///    `refresh_credentials_on_disk`)
218/// 2. **再调本 fn** 用更新后的 disk creds 重做 remember-login → fresh AuthResult
219/// 3. 用 fresh AuthResult 替换 reconnect monitor 的本地 `auth_result` 变量
220/// 4. 下一轮 tcp_login 用 fresh `client_sig`
221///
222/// ## 与 `refresh_credentials_on_disk` 的区别
223///
224/// `refresh_credentials_on_disk` 只更新 disk creds + LoginCache, 不返
225/// `AuthResult`. 本 fn 复用其后端 logic 但额外**返 fresh AuthResult** 给调用方
226/// 用. 不重复 refresh disk (调用方已先调 refresh_qot_login).
227///
228/// ## Failure modes
229///
230/// - `load_credentials` 失败 (disk file 缺) → `Err`
231/// - `rand_key_b64` decode 失败 (磁盘 file 损坏) → `Err`
232/// - `remember_login` 失败 (服务端拒新 tgtgt / 反刷限流 / network) → `Err`
233///
234/// 任何失败 caller fallback 到旧行为 (continue with stale `client_sig`,
235/// 等下次 reconnect / G1 timer trigger / user 手动 admin reload).
236pub async fn reauth_via_remember_login(
237    http: &reqwest::Client,
238    account: &str,
239    device_id: &str,
240    client_type: u8,
241    attribution: UserAttribution,
242) -> std::result::Result<AuthResult, FutuError> {
243    let cred = device::load_credentials(account).ok_or_else(|| {
244        FutuError::Codec(format!(
245            "v1.4.94 G4 reauth: no cached credentials for account '{account}' (cannot \
246             reload AuthResult; daemon needs admin reload / restart)"
247        ))
248    })?;
249    // 派生 region_code (对齐 v1.4.13 phone account 拆分逻辑) — 与首登时
250    // `authenticate_with_callback` 入口同源, 保证 remember_login 收到的
251    // region_code 跟首登一致.
252    let (normalized_account, region_code_opt) = normalize_phone_account(account);
253    let minimal_config = AuthConfig {
254        auth_server: String::new(),
255        account: normalized_account,
256        password: String::new(),
257        password_is_md5: false,
258        device_id: device_id.to_string(),
259        client_type,
260    };
261    let rand_key = decode_saved_rand_key_b64(&cred.rand_key_b64)?;
262    let (auth_result, _new_cred_opt) = remember_login(
263        http,
264        RememberLoginInput {
265            config: &minimal_config,
266            region_code: region_code_opt.as_deref(),
267            attribution,
268            uid: cred.uid,
269            device_id,
270            device_sig: &cred.device_sig,
271            tgtgt: &cred.tgtgt,
272            rand_key: &rand_key,
273            web_sig: &cred.web_sig,
274            moomoo_web_sig: &cred.moomoo_web_sig,
275            verify_cb: None,
276            primary_webtcp: None,
277        },
278    )
279    .await?;
280    // v1.4.106 codex 0558 F2+F3: log fingerprint 替代 raw account/uid
281    tracing::info!(
282        account_fp = %account_log_fingerprint(account),
283        uid_fp = %uid_log_fingerprint(auth_result.user_id),
284        client_sig_len = auth_result.client_sig.len(),
285        "v1.4.94 G4: reauth_via_remember_login produced fresh AuthResult"
286    );
287    Ok(auth_result)
288}
289
290/// 验证码获取回调类型
291///
292/// 当需要短信验证码时调用此回调。返回 Some(code) 表示用户输入了验证码,
293/// 返回 None 表示用户取消。
294pub type VerifyCodeCallback = Box<dyn Fn() -> Option<String> + Send + Sync>;
295
296/// 完整密码鉴权(优先用保存的凭据跳过验证码)
297///
298/// CLI 模式使用 `authenticate()` 从 stdin 读取验证码;
299/// GUI 模式使用 `authenticate_with_callback()` 通过回调获取。
300pub async fn authenticate(config: &AuthConfig) -> Result<AuthResult> {
301    authenticate_with_callback(config, None).await
302}
303
304/// 完整密码鉴权(带自定义验证码回调)
305pub async fn authenticate_with_callback(
306    config: &AuthConfig,
307    verify_cb: Option<VerifyCodeCallback>,
308) -> Result<AuthResult> {
309    // v1.4.84 SEC-001: 首次 auth 启动时 stderr warn debug log 安全风险.
310    // OnceLock dedup 避免 retry 或 daemon-reload 重复打.
311    redact::emit_debug_log_security_warn_once();
312
313    let http = build_http_client(config.client_type)?;
314    let primary_webtcp = endpoints::primary_auth_webtcp_context_for_auth_server(
315        config.client_type,
316        &config.auth_server,
317    );
318    let _primary_webtcp_prefetch = primary_webtcp.as_ref().map(|context| {
319        endpoints::spawn_primary_auth_site_config_prefetch(context, 0, &config.device_id)
320    });
321
322    // v1.4.13:把 `+86-13900000000` 这种带区号的输入拆成 account 本体 + region_code。
323    // 不拆的话 moomoo 服务端按 `13900000000` 查存的 pwd_md5 对不上我们 tgtgt 里
324    // 发的整串 `+86-13900000000`,报 `error_code=2 账号密码不匹配`。对齐 C++
325    // `BasicAccountAuthInfo` 的字段约定(`auth_impl.cpp:267`)。
326    let (normalized_account, region_code) = normalize_phone_account(&config.account);
327    if region_code.is_some() {
328        // v1.4.106 codex 0558 F2: log fingerprint, 不写 raw account / phone
329        tracing::info!(
330            original_fp = %account_log_fingerprint(&config.account),
331            account_fp = %account_log_fingerprint(&normalized_account),
332            region_no = %region_code.as_deref().unwrap_or(""),
333            "parsed phone account with region code"
334        );
335    }
336    // 构造本地 effective config —— `account` 字段已归一化,后续所有流程都用它
337    let mut effective_config = config.clone();
338    effective_config.account = normalized_account;
339
340    // 尝试 remember login(用保存的凭据)
341    if let Some(cred) = load_credentials(&effective_config.account) {
342        tracing::info!("found saved credentials, trying remember-login");
343
344        // ★ rand_key 在 salt32 非空路径是 32 字节(AES-256),不能截断到 16。
345        // 截到 16 字节后用 AES-128 解密 AES-256 加密的 client_key / rand_key_new
346        // 必定失败(表现:`cbc_md5_var: last_block_size 127 > 15`)。
347        let rand_key = match decode_saved_rand_key_b64(&cred.rand_key_b64) {
348            Ok(rand_key) => rand_key,
349            Err(e) => {
350                tracing::warn!(
351                    error = %e,
352                    account_fp = %account_log_fingerprint(&effective_config.account),
353                    "cached credentials rand_key_b64 invalid; skipping remember-login and falling back to password auth"
354                );
355                return password_auth(
356                    &effective_config,
357                    region_code.as_deref(),
358                    &http,
359                    verify_cb.as_deref(),
360                    primary_webtcp.as_ref(),
361                )
362                .await;
363            }
364        };
365
366        // v1.4.102 BUG-009 真修 (root-cause fix, 用户 2026-04-28 反馈):
367        //
368        // **历史 saga**: BUG-009 SMS race 经过 v1.4.72/74/75/81 共 6 版迭代.
369        // v1.4.81 Option B 设计是 "若 cached dvs+dcs 都 fresh, 跳
370        // req_device_code 用 cached values + 用户传入 --verify-code 直接
371        // verify". 但 Option B 检查放在 `remember_login` **之后** —— 每次启动
372        // 都先跑 remember_login → POST /authority/ → code=20 → 拿新 dvs →
373        // 进 handle_device_verify with NEW dvs (而不是 cached) → req_device_code
374        // → 新 SMS 覆盖老码 → 用户输的老 SMS 失败 → code=21 累计触发账号锁.
375        //
376        // **真根因**: cached dvs+dcs 在 5min 窗口内是有效凭证, 任何 POST
377        // /authority/ 都可能 invalidate. 必须在 cache fresh + user 提供
378        // --verify-code 时 **跳过** remember_login, 直接 verify_device_code.
379        //
380        // **本次修法**: pre-flight 检查 — 若 cached dvs+dcs 都 fresh **AND**
381        // verify_cb 存在(--verify-code 提供) → 跳过 remember_login + authority
382        // POST + req_device_code, 直接 handle_device_verify(cached_dvs,
383        // cached_dcs, user verify_code).
384        //
385        // **不破坏现有 flow**: cache 不全 fresh / 无 --verify-code → fall
386        // through 到正常 remember_login (v1.4.74 及以前行为).
387        //
388        // **C++ 对齐**: auth_impl.cpp 没显式 "skip authority on cached SMS"
389        // 路径 (C++ 客户端假设交互式输入码), 但本 fix 是 daemon 长跑场景特有 —
390        // GUI app 不需要因为 SMS 是即时输入. C++ 如果有同等场景应该也这样做.
391        // v1.4.102 codex 24 F6 (P2) fix: 用 should_skip_remember_login_for_cached_sms
392        // 共享 decision fn (pub(super) in tests.rs). 之前生产 logic 与 test
393        // helper 重复 (test 测 helper 但 helper 不 wire 到生产) — 改为共享.
394        let dvs_fresh = fresh_cached_device_verify_sig(&cred);
395        let dcs_fresh = fresh_cached_device_code_sig(&cred);
396        let has_verify_cb = verify_cb.is_some();
397        if should_skip_remember_login_for_cached_sms(
398            dvs_fresh.is_some(),
399            dcs_fresh.is_some(),
400            has_verify_cb,
401        ) && let (Some(cached_dvs), Some(cached_dcs)) = (dvs_fresh, dcs_fresh)
402        {
403            tracing::info!(
404                dvs_len = cached_dvs.len(),
405                dcs_len = cached_dcs.len(),
406                ttl_secs = DEVICE_CODE_SIG_TTL_SECS,
407                attribution = ?cred.user_attribution,
408                "v1.4.102 BUG-009 root-cause fix: cached dvs+dcs both fresh AND \
409                 user supplied --verify-code → SKIP remember_login + authority POST + \
410                 req_device_code (would invalidate cached SMS); going DIRECTLY to \
411                 verify_device_code with cached values"
412            );
413            let domain = cred.user_attribution.auth_domain();
414            return handle_device_verify(
415                &http,
416                DeviceVerifyInput {
417                    config: &effective_config,
418                    attribution: cred.user_attribution,
419                    domain,
420                    uid: cred.uid,
421                    dvs: cached_dvs,
422                    rand_key: &rand_key,
423                    verify_cb: verify_cb.as_deref(),
424                    cached_device_code_sig: Some(cached_dcs),
425                },
426            )
427            .await;
428        }
429        // Note: 任一缺失 (dvs / dcs / verify_cb) → fall through to remember_login.
430        // 现有 post-fail Option B/A path 仍存在 (cred 部分新鲜场景, e.g. dvs
431        // 新鲜 dcs 缺失 → Option A fallback).
432
433        match remember_login(
434            &http,
435            RememberLoginInput {
436                config: &effective_config,
437                region_code: region_code.as_deref(),
438                attribution: cred.user_attribution,
439                uid: cred.uid,
440                device_id: &cred.device_id,
441                device_sig: &cred.device_sig,
442                tgtgt: &cred.tgtgt,
443                rand_key: &rand_key,
444                web_sig: &cred.web_sig,
445                moomoo_web_sig: &cred.moomoo_web_sig,
446                verify_cb: verify_cb.as_deref(),
447                primary_webtcp: primary_webtcp.as_ref(),
448            },
449        )
450        .await
451        {
452            Ok((auth, new_cred)) => {
453                // 更新凭据 — v1.4.106 codex 0558 F1: write IO 错 propagate
454                if let Some(nc) = new_cred {
455                    save_credentials(&effective_config.account, &nc).map_err(|e| {
456                        FutuError::Codec(format!(
457                            "remember_login: save_credentials failed — {e} (cred not on disk; \
458                             daemon proceeds with in-memory creds, next restart will re-auth)"
459                        ))
460                    })?;
461                }
462                return Ok(auth);
463            }
464            Err(e) => {
465                if !should_fallback_to_password_auth_after_remember_error(&e) {
466                    tracing::warn!(
467                        error = %e,
468                        "remember-login reached device verification and did not complete; \
469                         not falling back to password/SMS auth because that can request a \
470                         second SMS and mix DVS/DCS/rand_key state. Restart with \
471                         --verify-code <SMS> using the same HOME."
472                    );
473                    return Err(e);
474                }
475                tracing::warn!(
476                    error = %e,
477                    "remember-login failed; cached credentials may be stale/incomplete, \
478                     falling back to password/SMS auth"
479                );
480            }
481        }
482
483        // v1.4.75 BUG-009 Fix 9a 真修(Option A "探路版",CLAUDE.md 坑 #34 模式):
484        // 如果缓存的 device_verify_sig 仍新鲜(<5 min)→ **跳过 password_auth
485        // → POST /authority/ → backend 返新 dvs 覆盖老 SMS** 的破坏流程,直接
486        // 调 handle_device_verify 带 cached dvs 做 SMS 验证。
487        //
488        // **v1.4.72 Fix 9a(WARN-only 非真修)**:只 log 提示,仍走 password_auth。
489        // 用户输的老 SMS 码绑定 old dvs,但 daemon 流程已经拿到新 dvs → 服务端
490        // 对比失败 → code=21 累计失败锁账号(外部 v1.4.71 AI tester 报告 §2.5)。
491        //
492        // **v1.4.75 Option A 探路**:cached dvs fresh → 直接 handle_device_verify(cached_dvs)
493        //
494        // **agent 代码级审查确认 3/4 风险安全**(essentials/2026-04-23-1810-v1.4.75-plan.md):
495        // - Risk 1 🟢 backend 接受 cached dvs(C++ auth_impl.cpp:244/757/879 无"一次性"语义)
496        // - Risk 3 🟢 rand_key AES-256 不截断(aes_cbc_md5_decrypt_var 可变长 + unit test 已 PASS)
497        // - Risk 4 🟢 moomoo empty device_sig 无关(handle_device_verify 不用 device_sig)
498        //
499        // **v1.4.75 真机结论**:Risk 2 假设被推翻。GET
500        // `/authority/req_device_code?dvs=cached_X` 仍可能触发新 SMS,
501        // 所以 Option A 只能避开 authority re-POST 的反刷问题,不足以保住
502        // 用户手上的旧 SMS code。
503        //
504        // 当前 Option A fallback 仍是纯 "add new branch",**不破坏现有 auth 流程**:
505        // cached dvs 过期 / 不存在 → fall through 到 password_auth(v1.4.74 及以前行为)
506        // v1.4.81 BUG-009 Fix 9a Option B (优先) / Option A (fallback):
507        //
508        // - Option B (首选): cached device_code_sig + dvs 都 fresh → 跳过
509        //   req_device_code 整步,直接 verify_device_code with cached dcs +
510        //   用户传入 --verify-code。**这是 BUG-009 的真修**。
511        // - Option A (fallback): 只有 cached dvs fresh(dcs 缺失或过期)→ 跳
512        //   authority POST 避反刷,但 req_device_code 仍会触发新 SMS(v1.4.75
513        //   真机 verify 推翻 Risk 2 假设后,Option A 只能避 "authority POST 反
514        //   刷 15",不能避 "新 SMS 覆盖老码")。
515        //
516        // 典型 flow:
517        // - Step 1(首次启动 non-tty): password_auth → POST /authority/ code=20
518        //   → persist shell (含 dvs+ts) → req_device_code (发 SMS, 拿 dcs) →
519        //   persist dcs → stdin atty fail 退出
520        // - Step 2(5min 内重启带 --verify-code X): load credentials (dvs+dcs 都 fresh)
521        //   → Option B 触发 → handle_device_verify(cached_dvs, cached_dcs=Some)
522        //   → 跳 req_device_code → verify_device_code with X → 成功
523        // v1.4.102 codex 32 F3 (P2) fix: 同时 check dvs + dcs fresh.
524        // 之前只 check dcs fresh + cred.device_verify_sig.unwrap_or("") 直接
525        // 用 → DCS fresh 但 DVS 过期/缺失时仍走此路径用 stale/empty DVS verify.
526        // 修法: 与 pre-flight check (line 538-) 一致, 必须 both fresh.
527        if let (Some(cached_dcs), Some(cached_dvs)) = (
528            fresh_cached_device_code_sig(&cred),
529            fresh_cached_device_verify_sig(&cred),
530        ) {
531            tracing::info!(
532                dcs_len = cached_dcs.len(),
533                dvs_len = cached_dvs.len(),
534                ttl_secs = DEVICE_CODE_SIG_TTL_SECS,
535                attribution = ?cred.user_attribution,
536                "v1.4.81 BUG-009 Fix 9a Option B (v1.4.102 audit 32 F3 refine: \
537                 both dvs+dcs fresh): using cached device_code_sig + \
538                 device_verify_sig, skipping req_device_code entirely"
539            );
540            let domain = cred.user_attribution.auth_domain();
541            return handle_device_verify(
542                &http,
543                DeviceVerifyInput {
544                    config: &effective_config,
545                    attribution: cred.user_attribution,
546                    domain,
547                    uid: cred.uid,
548                    dvs: cached_dvs,
549                    rand_key: &rand_key,
550                    verify_cb: verify_cb.as_deref(),
551                    cached_device_code_sig: Some(cached_dcs),
552                },
553            )
554            .await;
555        }
556        if let Some(cached_dvs) = fresh_cached_device_verify_sig(&cred) {
557            tracing::warn!(
558                dvs_len = cached_dvs.len(),
559                ttl_secs = DEVICE_VERIFY_SIG_TTL_SECS,
560                attribution = ?cred.user_attribution,
561                "v1.4.75 BUG-009 Fix 9a Option A fallback: cached dvs only (no \
562                 fresh device_code_sig) — skipping authority re-POST but \
563                 req_device_code will still fire new SMS (known half-fix; 5min \
564                 window after first-SMS lost). Will work for authority rate-limit \
565                 avoidance but not SMS-code preservation."
566            );
567            let domain = cred.user_attribution.auth_domain();
568            return handle_device_verify(
569                &http,
570                DeviceVerifyInput {
571                    config: &effective_config,
572                    attribution: cred.user_attribution,
573                    domain,
574                    uid: cred.uid,
575                    dvs: cached_dvs,
576                    rand_key: &rand_key,
577                    verify_cb: verify_cb.as_deref(),
578                    cached_device_code_sig: None,
579                },
580            )
581            .await;
582        }
583    }
584
585    // 全新密码认证
586    //
587    // v1.4.17:SMS 验证码错(`error_code=21`)时自动轮换 device_id 重试,
588    // 最多 MAX_SMS_RETRIES 次。
589    //
590    // **v1.4.57 修正(外部报告 #5 第 3 层根因)**:自动轮换 device_id 反而会触发
591    // 服务端限流("5 次不同设备 30 秒内" 硬 threshold)。同事实锤:连续 2 次
592    // SMS 输错自动轮换后,正确码也被 code=1 "系统繁忙" 拒。v1.4.57 起**不再
593    // 自动轮换**(MAX_SMS_RETRIES=0),让用户手动决定:
594    //   - 真是验证码输错 → 重新运行 `futu-opend --setup-only` 重试一次
595    //   - 需要强制换 device_id → 显式 `--reset-device --setup-only`
596    //
597    // **tty 检测**:prompt_input 已在非 tty 时 fail fast(见 auth/util.rs:38-51),
598    // 避免空验证码毒化 device_id。v1.4.57 外部 #5 A/C 两层根因至此闭环。
599    // v1.4.57 外部 #5:直接调一次 password_auth,不再自动轮换 device_id。
600    // 如 SMS 输错 (ret_type=21),把错误返给 caller,用户再手动 retry。
601    //
602    // v1.4.17-56 的 `MAX_SMS_RETRIES=2` 自动轮换反而触发服务端限流(同一 uid
603    // "5 次不同设备 30 秒内"硬阈值),导致正确码也被 code=1 系统繁忙拒(实锤:
604    // 2026-04-22 外部用户 Telegram SMS 中继场景)。
605    //
606    // 未来若想恢复重试(e.g., CLI flag gated),restore 原 loop + 用
607    // `reset_device_state` + `read_or_generate_device_id` 轮换 device_id。
608    password_auth(
609        &effective_config,
610        region_code.as_deref(),
611        &http,
612        verify_cb.as_deref(),
613        primary_webtcp.as_ref(),
614    )
615    .await
616}
617
618// v1.4.110+ Tier 2/3 split: 5 业务 fn + 4 input struct 拆 4 子 mod.
619// Orchestrator (authenticate_with_callback / refresh_credentials_on_disk) 通过
620// 下方 use 仍可见 fn 和 input struct.
621mod device_verify;
622mod endpoints;
623mod password_auth;
624mod remember;
625
626use device_verify::{DeviceVerifyInput, handle_device_verify};
627use password_auth::password_auth;
628use remember::{RememberLoginInput, remember_login};
629
630#[cfg(test)]
631mod tests;