Skip to main content

futu_backend/auth/
device.rs

1//! device_id 持久化 + credentials 文件管理
2//!
3//! 统一存储根目录 `~/.futu-opend-rs/`(对齐 C++ `~/.com.futunn.FutuOpenD/`)。
4//! v1.4.17 起把 credentials 从 cwd 下的 `.futu_credentials_{account}` 搬过来,
5//! 并把 device_id 单独持久化到 `device-<hash>.dat`。
6//!
7//! 生命周期(见 CLAUDE.md "device_id 生命周期"):
8//! - 首次启动 → 随机生成 16-hex → 写文件
9//! - 后续启动 → 读文件
10//! - `--device-id <hex>` → 覆盖文件 + 用这个值
11//! - `--reset-device` → 删 device + credentials 文件
12//! - SMS `error_code=21` → `authenticate_with_callback` 自动 reset + 重试
13
14use super::{
15    CredentialTicketStatus, TGTGT_VALIDITY_SECS, UserAttribution, normalize_phone_account,
16};
17
18mod credentials_store;
19mod storage;
20
21#[cfg(test)]
22use credentials_store::save_credentials_to_path;
23pub(in crate::auth) use credentials_store::{CredentialsStoreError, save_credentials};
24use storage::{
25    DirEnforceError, cleanup_remove_file, try_credentials_path, try_device_id_path,
26    write_secret_file_best_effort,
27};
28pub(super) use storage::{try_futu_opend_dir, write_secret_file};
29
30#[cfg(test)]
31pub(super) use storage::{
32    account_key, credentials_path, device_id_path, ensure_dir_0700, futu_opend_dir,
33};
34
35#[cfg(all(test, unix))]
36pub(super) use storage::cleanup_set_permissions_0600;
37
38pub(super) const CURRENT_CREDENTIALS_SCHEMA_VERSION: u32 = 1;
39
40fn legacy_credentials_schema_version() -> u32 {
41    0
42}
43
44/// 保存的凭据结构 —— `~/.futu-opend-rs/credentials-<hash>.json` 的 schema。
45///
46/// v1.4.5+ 新增 `user_attribution` 字段(必填,无 `serde(default)`):旧格式
47/// 凭据反序列化失败 → `load_credentials` 返回 None → 自动回落到密码登录。这是
48/// 故意的:旧凭据基于 CN cipher,不能直接套 moomoo 域名切换逻辑。
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub(super) struct SavedCredentials {
51    /// Credentials schema version. Missing means legacy v0.
52    ///
53    /// v1.4.111 codex legacy deep-dive follow-up: earlier schema evolution relied
54    /// solely on `#[serde(default)]`, which made it hard to distinguish legacy
55    /// credentials from current ones during future migrations. New writes always
56    /// persist [`CURRENT_CREDENTIALS_SCHEMA_VERSION`]; load upgrades v0 files
57    /// in-place after account safety checks pass.
58    #[serde(default = "legacy_credentials_schema_version")]
59    pub(super) schema_version: u32,
60    /// v1.4.67 Bug #1 (external reviewer P0): 持久化 login account 字符串到文件内容,load 时
61    /// 校验文件的 account 字段必须与 expected account 一致,防止 cross-account
62    /// corruption(external reviewer 报告 `credentials-<hashA>.json` 里存了 uid_B 的情况导致
63    /// unlock 用错密码验证别人身份 → 风险跨账户交易)
64    ///
65    /// Backward compat (v1.4.70 hotfix): 旧 v1.4.66 及之前的文件没这字段 →
66    /// serde default 空字符串 → load 时**静默升级** populate + 写回,不再强制
67    /// 删文件重 SMS(v1.4.68 Bug #1 副作用:强制 SMS 累积触发 Futu 后端限流)
68    #[serde(default)]
69    pub(super) account: String,
70    pub(super) device_id: String,
71    pub(super) device_sig: String,
72    pub(super) tgtgt: String,
73    /// Unix seconds when the current cached `tgtgt` was written.
74    ///
75    /// The service-side ticket validity is 30 days from creation
76    /// ([`TGTGT_VALIDITY_SECS`]). Older credential files did not persist this
77    /// timestamp; `0` means unknown and status surfaces should ask users to
78    /// refresh credentials once rather than pretending the ticket is fresh.
79    #[serde(default)]
80    pub(super) tgtgt_saved_at: u64,
81    pub(super) rand_key_b64: String,
82    pub(super) uid: u64,
83    pub(super) user_attribution: UserAttribution,
84    /// v1.4.72 BUG-009 Fix 9a (external reviewer v1.4.69 P1): 持久化最近一次的
85    /// `device_verify_sig`(由 `/authority/` 响应 error.device_verify_sig
86    /// 提供,短 TTL ~5 分钟),避免 daemon 启动重 POST `/authority/` 触发新
87    /// SMS + 失效旧码 → 用户输旧码 → code=21 → 累计失败触发 cause #4 账户锁。
88    ///
89    /// **用法**:`authenticate_with_callback` 入口检测 SavedCredentials 的
90    /// dvs 是否 < 5min,若是 → log WARN 警告用户"刚收到过 SMS 不要重复触发",
91    /// 并 hint 使用 `--verify-code <已收到的 SMS>` 避免新 SMS。
92    ///
93    /// **存储时刻**:post_auth / remember_login 两路径从 error 抽出 dvs 后。
94    /// Backward compat: Optional 字段,v1.4.71 及之前的文件没此字段 → serde
95    /// default None → 不影响现有用户。
96    #[serde(default)]
97    pub(super) device_verify_sig: Option<String>,
98    #[serde(default)]
99    pub(super) device_verify_sig_ts: Option<u64>,
100    /// v1.4.81 BUG-009 Fix 9a Option B (replaces Option A 方向错):
101    /// 持久化 `req_device_code` 响应里的 `device_code_sig`(SMS 一次有效、对应
102    /// 一条 SMS 码)。Option A 只 cache dvs 跳 authority POST,但 req_device_code
103    /// 仍发新 SMS 覆盖老码(v1.4.75 真机 verify 推翻 Risk 2 假设)。
104    /// Option B **同时 cache device_code_sig** → Fix 9a 路径**跳过 req_device_code
105    /// 整步**直接 verify_device_code with cached dcs + --verify-code X。
106    ///
107    /// **存储时刻**:`handle_device_verify` 内部 req_device_code 响应返回后立即
108    /// persist(即在 prompt_input 之前)—— 这样 daemon 在 stdin atty fail
109    /// 非交互退出之前,dcs 已落盘。
110    ///
111    /// **TTL**:同 dvs 5min(后端窗口未文档化,保守取 dvs 相同 TTL;真机 verify
112    /// 后可调)。
113    #[serde(default)]
114    pub(super) device_code_sig: Option<String>,
115    #[serde(default)]
116    pub(super) device_code_sig_ts: Option<u64>,
117    /// v1.4.93 G3 (CLAUDE.md C4 audit): 持久化 `web_sig`,对齐 C++
118    /// `FTLogin/Src/ftlogin/auth/impl/auth_impl.cpp:3193,3260`
119    /// (`web_sig_new` 解到 `account.web_sig_`)。
120    ///
121    /// **用途**:G2 `RepullAuthCode` 需要 `web_sig` 作 POST body 字段
122    /// (对齐 C++ `auth_impl.cpp:738-748`),broker auth_code 过期或
123    /// `kAuthNoValidCid` 时用来拉新 auth_code,避免必须重启 daemon。
124    ///
125    /// **存储时刻**:`save_credentials_from_response` 解 result.web_sig_new
126    /// 后落盘。
127    ///
128    /// Backward compat: `#[serde(default)]` 兼容 v1.4.92 及之前的文件
129    /// (没此字段 → 空字符串 → repull 路径调用前 check empty 跳过, fallback
130    /// 走 platform refresh)。
131    #[serde(default)]
132    pub(super) web_sig: String,
133    /// v1.4.94 G6 (P2 protocol gap): `moomoo_client_sig` (base64-encoded)
134    /// 持久化, 对齐 C++ `auth_impl.cpp:3195,3260` `account.us_client_sig_`.
135    ///
136    /// **用途**: moomoo / US 路径 broker channel 鉴权 — attribution =
137    /// US/SG/AU/JP/CA 时 broker_auth_code 换 client_sig 走的是
138    /// `moomoo_client_sig` 而不是主 `client_sig`. 持久化让 daemon restart 后
139    /// 不必重新 password auth 也能用 moomoo path.
140    ///
141    /// Backward compat: `#[serde(default)]` 兼容 v1.4.93 及之前的文件
142    /// (空 → fallback 主 client_sig).
143    #[serde(default)]
144    pub(super) moomoo_client_sig: String,
145    /// v1.4.94 G6: `moomoo_web_sig_new` 持久化, 对齐 C++ `auth_impl.cpp:3197,3260`
146    /// `account.us_web_sig_`. 用于 moomoo path repull_auth_code.
147    /// 缺失 → 空字符串 fallback 主 `web_sig`.
148    #[serde(default)]
149    pub(super) moomoo_web_sig: String,
150}
151
152/// v1.4.72 BUG-009 Fix 9a: device_verify_sig TTL (秒)
153///
154/// 对齐 Futu 后端 SMS 窗口 (~5 分钟内输入有效)。超此时间 backend 会主动
155/// invalidate dvs,此时即使 cached 也需要重新 POST /authority 触发新 SMS。
156pub(super) const DEVICE_VERIFY_SIG_TTL_SECS: u64 = 5 * 60;
157
158/// v1.4.81 BUG-009 Fix 9a Option B: device_code_sig TTL (秒)
159///
160/// 对齐 Futu 后端 SMS 窗口 (~5 分钟,和 dvs 一致作保守假设,v1.4.82+ 按真机
161/// verify 调整)。超此时间 backend 会主动 invalidate → verify_device_code
162/// 返 code=21,此时必须重跑 req_device_code 拿新 dcs + 新 SMS。
163pub(super) const DEVICE_CODE_SIG_TTL_SECS: u64 = 5 * 60;
164
165#[derive(Debug)]
166pub enum DeviceStoreError {
167    Dir(String),
168}
169
170impl std::fmt::Display for DeviceStoreError {
171    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
172        match self {
173            DeviceStoreError::Dir(message) => write!(f, "{message}"),
174        }
175    }
176}
177
178impl std::error::Error for DeviceStoreError {}
179
180fn device_store_dir_error(err: DirEnforceError) -> DeviceStoreError {
181    DeviceStoreError::Dir(err.to_string())
182}
183
184fn dir_enforce_to_io_error(err: DirEnforceError) -> std::io::Error {
185    std::io::Error::other(err.to_string())
186}
187
188/// 启动时扫 `~/.futu-opend-rs/` 把已存在的 secret 文件 (credentials-*.json
189/// / device-*.dat) 收紧到 0600. v1.4.101 及以前版本可能创建了 0644 文件,
190/// 此 fn 是迁移路径.
191///
192/// 由 `init_auth_state` (或类似入口) 在 daemon 启动早期调用. 失败 best-effort
193/// (warn but don't fail), 因为 chmod 失败 != 凭据本身失效.
194pub fn tighten_secret_files_at_startup() {
195    #[cfg(unix)]
196    {
197        use std::os::unix::fs::PermissionsExt;
198        let dir = match try_futu_opend_dir() {
199            Ok(dir) => dir,
200            Err(err) => {
201                tracing::warn!(
202                    error = %err,
203                    "auth secret-file permission tightening skipped because store dir is unavailable"
204                );
205                return;
206            }
207        };
208        let Ok(entries) = std::fs::read_dir(&dir) else {
209            return;
210        };
211        for entry in entries.flatten() {
212            let path = entry.path();
213            let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
214                continue;
215            };
216            // 仅收紧 secret 文件: credentials-<hash>.json / device-<hash>.dat.
217            // 不收紧 keys.json (用户可能想 ACL 给 systemd group), 不收紧 logs/ etc.
218            //
219            // v1.4.104 external reviewer P2-004 (P2) fix: 扩展到 sidecar 文件 (`.backup` / `.bak` /
220            // `.tmp` / `.swp`). 编辑器 vim / git rebase / external backup tool 等
221            // 可能创建 `credentials-<hash>.json.backup` 0644 文件, 同样含 secret
222            // 不能放任. 任何 name 以 `credentials-` 或 `device-` 开头都收紧.
223            let is_secret = name.starts_with("credentials-") || name.starts_with("device-");
224            if !is_secret {
225                continue;
226            }
227            let Ok(meta) = entry.metadata() else { continue };
228            let mode = meta.permissions().mode() & 0o777;
229            if mode != 0o600 {
230                tracing::warn!(
231                    path = %path.display(),
232                    actual_mode = format!("0{:o}", mode),
233                    "v1.4.102 BUG-012 fix: secret file has loose permissions; tightening to 0600"
234                );
235                if let Err(e) =
236                    std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
237                {
238                    tracing::warn!(
239                        path = %path.display(),
240                        error = %e,
241                        "failed to tighten secret file permissions; please chmod 0600 manually"
242                    );
243                }
244            }
245        }
246    }
247    #[cfg(not(unix))]
248    {
249        // Windows: ACL 模型不同, 此 fn 不展开.
250    }
251}
252
253/// 读取 device_id;文件不存在则生成新的 16-hex 随机值并持久化。
254///
255/// 如果 `override_value` 是 `Some(hex)`(来自 `--device-id` CLI 参数),
256/// 直接用并更新文件。
257pub fn read_or_generate_device_id(
258    account: &str,
259    override_value: Option<&str>,
260) -> Result<String, DeviceStoreError> {
261    let path = try_device_id_path(account).map_err(device_store_dir_error)?;
262
263    if let Some(explicit) = override_value {
264        // 用户显式指定 —— 更新持久化文件 (v1.4.102 BUG-012: 0600 secret-file)
265        if write_secret_file(&path, explicit.as_bytes()).is_err() {
266            tracing::warn!(path = %path.display(), "failed to persist --device-id override");
267        } else {
268            // v1.4.106 codex 0558 F2: log fingerprint, 不写 raw device_id
269            tracing::info!(
270                path = %path.display(),
271                device_id_fp = %super::redact::device_id_log_fingerprint(explicit),
272                "device_id overridden by --device-id and persisted"
273            );
274        }
275        return Ok(explicit.to_string());
276    }
277
278    // 文件已存在 → 读取
279    if let Ok(content) = std::fs::read_to_string(&path) {
280        let trimmed = content.trim().to_string();
281        if trimmed.len() == 16 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) {
282            tracing::debug!(path = %path.display(), "loaded existing device_id");
283            return Ok(trimmed);
284        }
285        tracing::warn!(
286            path = %path.display(),
287            "device_id file contents invalid, regenerating"
288        );
289    }
290
291    // 首次运行 / 文件损坏 → 生成随机 16-hex
292    let device_id = {
293        let bytes: [u8; 8] = rand::random();
294        format!("{:x}", u64::from_ne_bytes(bytes))
295            .chars()
296            .chain(std::iter::repeat('0'))
297            .take(16)
298            .collect::<String>()
299    };
300    // v1.4.102 BUG-012: device_id 也是 secret (用于设备识别), 0600
301    if write_secret_file(&path, device_id.as_bytes()).is_err() {
302        tracing::warn!(path = %path.display(), "failed to persist device_id");
303    } else {
304        // v1.4.106 codex 0558 F2: log fingerprint, 不写 raw device_id
305        tracing::info!(
306            path = %path.display(),
307            device_id_fp = %super::redact::device_id_log_fingerprint(&device_id),
308            "generated and persisted new device_id"
309        );
310    }
311    Ok(device_id)
312}
313
314/// 删除 device_id 文件 + credentials 文件(`--reset-device` 使用)。
315///
316/// device_id 被服务端锁定后必须换新的 —— 单纯改密码无法恢复。
317pub fn reset_device_state(account: &str) -> std::io::Result<()> {
318    let dev_path = try_device_id_path(account).map_err(dir_enforce_to_io_error)?;
319    let cred_path = try_credentials_path(account).map_err(dir_enforce_to_io_error)?;
320    let mut removed = Vec::new();
321    if dev_path.exists() {
322        std::fs::remove_file(&dev_path)?;
323        removed.push(dev_path.display().to_string());
324    }
325    if cred_path.exists() {
326        std::fs::remove_file(&cred_path)?;
327        removed.push(cred_path.display().to_string());
328    }
329    if removed.is_empty() {
330        tracing::info!("reset_device: no existing device/credentials files to remove");
331    } else {
332        tracing::info!(files = ?removed, "reset_device: removed device and credentials files");
333    }
334    Ok(())
335}
336
337pub(super) fn load_credentials(account: &str) -> Option<SavedCredentials> {
338    let path = match try_credentials_path(account) {
339        Ok(path) => path,
340        Err(err) => {
341            tracing::warn!(
342                error = %err,
343                "credentials file path unavailable; caller will see no cached credentials"
344            );
345            return None;
346        }
347    };
348    // v1.4.17 迁移:如果新路径不存在但 cwd 下有老文件,自动迁移
349    if !path.exists() {
350        let legacy = std::path::PathBuf::from(format!(".futu_credentials_{account}"));
351        if legacy.exists() {
352            if let Err(e) = std::fs::rename(&legacy, &path) {
353                tracing::warn!(error = %e, "failed to migrate legacy credentials");
354            } else {
355                tracing::info!(
356                    from = %legacy.display(),
357                    to = %path.display(),
358                    "migrated legacy credentials file"
359                );
360            }
361        }
362    }
363    // v1.4.104 external reviewer S-004 (P1): 之前 `.ok()?` silently swallow IO / deserialize
364    // 错误, 让 caller 看到 "no cached credentials" 但实际是 "file exists 读不
365    // 出来" / "JSON 损坏". longrun bug 难定位. 现在 loud warn 让 daemon log
366    // 留有真因.
367    //
368    // v1.4.104 codex round 2 F1 (P1) fix: 不能写 raw account / first_64_bytes
369    // 因 credentials file 起始即 device_sig / tgtgt / rand_key_b64 / web_sig
370    // / moomoo_client_sig 等敏感字段, partial-write 时这些可能在 first 64
371    // bytes 里. 改用 hash + len + serde 位置错误描述.
372    let path_basename = path
373        .file_name()
374        .and_then(|s| s.to_str())
375        .unwrap_or("<unnamed>");
376    let account_digest = {
377        use sha2::{Digest, Sha256};
378        let mut h = Sha256::new();
379        h.update(account.as_bytes());
380        let d = h.finalize();
381        // codex round 3 polish: 用 02x zero-pad 保证固定 8 hex 字符 (之前
382        // {:x} 会丢前导零, 让 ops 看 log 时长度不一致). 4 bytes ≈ 1/2^32 碰
383        // 撞概率, 对单机 daemon 足够区分不同 account.
384        format!("{:02x}{:02x}{:02x}{:02x}", d[0], d[1], d[2], d[3])
385    };
386    let data = match std::fs::read_to_string(&path) {
387        Ok(s) => s,
388        Err(e) => {
389            tracing::warn!(
390                error = %e,
391                error_kind = ?e.kind(),
392                path_basename,
393                account_digest,
394                "credentials file read failed (e.g. NotFound, PermissionDenied, IsADirectory). \
395                 caller will see 'no cached credentials'. account hashed to avoid \
396                 PII; full path elided."
397            );
398            return None;
399        }
400    };
401    let mut cred: SavedCredentials = match serde_json::from_str(&data) {
402        Ok(c) => c,
403        Err(e) => {
404            // codex round 2 F1: 计算 SHA256 of credential bytes (前 8 字节 hex)
405            // 用作 fingerprint 区分不同 corruption 实例, 不暴露原文.
406            let blob_digest = {
407                use sha2::{Digest, Sha256};
408                let mut h = Sha256::new();
409                h.update(data.as_bytes());
410                let d = h.finalize();
411                // codex round 3 polish: 02x zero-pad fixed 8 hex
412                format!("{:02x}{:02x}{:02x}{:02x}", d[0], d[1], d[2], d[3])
413            };
414            tracing::error!(
415                error = %e,
416                line = e.line(),
417                column = e.column(),
418                path_basename,
419                account_digest,
420                data_len = data.len(),
421                blob_digest,
422                "credentials JSON deserialize failed — file likely corrupted (partial write \
423                 / disk error / version skew). user may need to re-auth via \
424                 password + SMS. raw bytes elided (含 device_sig / tgtgt / \
425                 rand_key_b64 等敏感字段); 用 blob_digest 区分不同 corruption."
426            );
427            return None;
428        }
429    };
430
431    // v1.4.70 hotfix — 修 v1.4.68 Bug #1 副作用(强制 re-SMS 触发 Futu 限流)
432    //
433    // v1.4.68 Bug #1 引入 `cred.account != account` 强校验(防 cross-account
434    // corruption,安全 P0),但两种 **合法场景**被误杀,导致升级用户强制 SMS:
435    //
436    //   (1) Legacy v1.4.66 及之前文件:`cred.account` 空(serde default)
437    //       → 本 hotfix:**静默升级** populate + 写回,不删文件
438    //   (2) Phone 格式变体:`+86-13900000000` vs `13900000000` 归一化后哈希
439    //       相同(同一文件),但 `cred.account` 字符串不同
440    //       → 本 hotfix:**比较 normalize 后的值**,接受同账号不同写法
441    //
442    // 安全目标(Bug #1)保留:**normalize 之后真不等** = 真 cross-account 污染
443    // → 仍然删文件走 SMS(见下方 else 分支)
444    let (normalized_expected, _) = normalize_phone_account(account);
445
446    if cred.account.is_empty() {
447        // Legacy <=v1.4.66 文件:静默升级 account 字段 + 写回,让下次 load 直接命中
448        // v1.4.106 codex 0558 F2: log fingerprint, 不写 raw account
449        tracing::info!(
450            file = %path.display(),
451            account_fp = %super::redact::account_log_fingerprint(&normalized_expected),
452            "legacy credentials file — silently upgrading account field (v1.4.70 hotfix)"
453        );
454        cred.account = normalized_expected.clone();
455        cred.schema_version = CURRENT_CREDENTIALS_SCHEMA_VERSION;
456        // 写回是 best-effort:失败不影响当前 load(下次启动再试)
457        // v1.4.102 BUG-012: 0600 secret-file
458        if let Ok(json) = serde_json::to_string_pretty(&cred) {
459            write_secret_file_best_effort(&path, json.as_bytes(), "legacy_account_upgrade");
460        }
461        return Some(cred);
462    }
463
464    let (normalized_got, _) = normalize_phone_account(&cred.account);
465    if normalized_got != normalized_expected {
466        // Normalize 后仍不等 = 真 cross-account 污染(md5 16-hex 碰撞概率 ~2^-64)
467        // v1.4.106 codex 0558 F2+F3: 用 fingerprint 替代 raw account/uid
468        // (cross-account corruption 时仍能比对两个 fp 不同, 但不泄漏真账号号 / uid).
469        tracing::warn!(
470            file = %path.display(),
471            expected_account_fp = %super::redact::account_log_fingerprint(account),
472            got_account_fp = %super::redact::account_log_fingerprint(&cred.account),
473            got_uid_fp = %super::redact::uid_log_fingerprint(cred.uid),
474            "credentials cross-account corruption detected — removing poisoned file, \
475             will re-authenticate via password + SMS (v1.4.67 Bug #1 defense)"
476        );
477        cleanup_remove_file(&path, "load_credentials cross-account cleanup");
478        return None;
479    }
480
481    if cred.schema_version < CURRENT_CREDENTIALS_SCHEMA_VERSION {
482        tracing::info!(
483            file = %path.display(),
484            from_schema_version = cred.schema_version,
485            to_schema_version = CURRENT_CREDENTIALS_SCHEMA_VERSION,
486            account_fp = %super::redact::account_log_fingerprint(&normalized_expected),
487            "legacy credentials schema detected — migrating in place"
488        );
489        cred.schema_version = CURRENT_CREDENTIALS_SCHEMA_VERSION;
490        // Best-effort: current auth flow can continue with migrated in-memory
491        // credentials even if the rewrite fails; next startup will try again.
492        if let Ok(json) = serde_json::to_string_pretty(&cred) {
493            write_secret_file_best_effort(&path, json.as_bytes(), "legacy_schema_migration");
494        }
495    }
496
497    Some(cred)
498}
499
500/// v1.4.72 BUG-009 Fix 9a: 在现有 credentials 文件里 upsert `device_verify_sig`
501/// 和时间戳。Daemon 收到 `/authority/` code=20 响应后调一次,保留 dvs 供下次
502/// daemon 重启时探测"5min 内已有 dvs → 不要重 POST /authority 避免新 SMS"。
503///
504/// 如果 credentials 文件不存在(首次 auth),不做任何事(dvs 会在完整 auth
505/// 成功后由 `save_credentials_from_response` 以完整 cred 形式写入;但 post-auth
506/// 流程完成后 dvs 已用过,persist 其实不必要 —— 仅 remember_login → code=20
507/// 的 retry scenario 需要本 helper)。
508/// v1.4.81 BUG-009 Fix 9a gap: first-auth context for shell credentials write.
509///
510/// 当 credentials 文件尚不存在时(首次登录 / `rm credentials` 后),
511/// 调用方应传入此 context,让 `persist_device_verify_sig` 能写一个最小壳
512/// (account + device_id + uid + rand_key_b64 + attribution + dvs + dvs_ts),
513/// 使下次启动能走 Fix 9a cached-dvs 路径(跳过 re-POST /authority/,避免新 SMS
514/// 覆盖老 SMS 码)。
515///
516/// **不传 ctx(= None)** 保持 v1.4.72 原语义(credentials 不存在直接 return)。
517pub(super) struct FirstAuthContext<'a> {
518    pub uid: u64,
519    pub rand_key_b64: &'a str,
520    pub user_attribution: UserAttribution,
521    pub device_id: &'a str,
522}
523
524pub(super) fn persist_device_verify_sig(
525    account: &str,
526    dvs: &str,
527    first_auth_ctx: Option<FirstAuthContext<'_>>,
528) {
529    let path = match try_credentials_path(account) {
530        Ok(path) => path,
531        Err(err) => {
532            tracing::warn!(
533                error = %err,
534                "persist_device_verify_sig skipped because credentials path is unavailable"
535            );
536            return;
537        }
538    };
539    let now = auth_device_now_secs_or_zero("persist_device_verify_sig");
540
541    // Case A (v1.4.72 原语义): credentials 已存在 → upsert dvs/ts
542    if let Ok(data) = std::fs::read_to_string(&path) {
543        match serde_json::from_str::<SavedCredentials>(&data) {
544            Ok(mut cred) => {
545                if let Some(ctx) = first_auth_ctx.as_ref() {
546                    // Existing credentials can be stale when password auth is
547                    // re-entered after remember-login rejection. The new DVS
548                    // belongs to the freshly generated TGTGT/rand_key branch,
549                    // so keep a pending-verify shell instead of pairing the
550                    // new DVS/DCS with old tgtgt/rand_key material.
551                    cred.uid = ctx.uid;
552                    cred.rand_key_b64 = ctx.rand_key_b64.to_string();
553                    cred.user_attribution = ctx.user_attribution;
554                    cred.device_id = ctx.device_id.to_string();
555                    cred.device_sig.clear();
556                    cred.tgtgt.clear();
557                    cred.web_sig.clear();
558                    cred.moomoo_client_sig.clear();
559                    cred.moomoo_web_sig.clear();
560                }
561                cred.device_verify_sig = Some(dvs.to_string());
562                cred.device_verify_sig_ts = Some(now);
563                // A new DVS invalidates any previously cached DCS, because
564                // device_code_sig is tied to one req_device_code/DVS branch.
565                cred.device_code_sig = None;
566                cred.device_code_sig_ts = None;
567                if let Ok(json) = serde_json::to_string_pretty(&cred)
568                    && write_secret_file_best_effort(
569                        &path,
570                        json.as_bytes(),
571                        "persist_device_verify_sig_upsert",
572                    )
573                {
574                    tracing::info!(
575                        path = %path.display(),
576                        dvs_len = dvs.len(),
577                        "v1.4.72 BUG-009 Fix 9a: device_verify_sig cached (5min TTL, upsert)"
578                    );
579                }
580                return;
581            }
582            Err(_) => {
583                tracing::warn!(
584                    path = %path.display(),
585                    "persist_device_verify_sig: 现有 credentials 解析失败,尝试用 shell 覆盖"
586                );
587                // fallthrough to Case B (若 caller 提供 ctx)
588            }
589        }
590    }
591
592    // Case B (v1.4.81 新增): credentials 不存在 OR 解析失败,且 caller 提供
593    // first-auth context → 写最小壳以启用下次启动的 Fix 9a 路径
594    let Some(ctx) = first_auth_ctx else {
595        // Caller 未提供 ctx(旧调用路径或不关心 Fix 9a gap)→ 保持 v1.4.72 原语义
596        return;
597    };
598    debug_assert!(
599        !account.is_empty(),
600        "persist_device_verify_sig shell: account must be populated (v1.4.67 guard)"
601    );
602    let shell = SavedCredentials {
603        schema_version: CURRENT_CREDENTIALS_SCHEMA_VERSION,
604        account: account.to_string(),
605        device_id: ctx.device_id.to_string(),
606        device_sig: String::new(),
607        tgtgt: String::new(),
608        tgtgt_saved_at: 0,
609        rand_key_b64: ctx.rand_key_b64.to_string(),
610        uid: ctx.uid,
611        user_attribution: ctx.user_attribution,
612        device_verify_sig: Some(dvs.to_string()),
613        device_verify_sig_ts: Some(now),
614        device_code_sig: None,
615        device_code_sig_ts: None,
616        // v1.4.93 G3: shell 路径写一个空 web_sig(首次 auth 还没收到 web_sig_new;
617        // /authority/ POST 成功路径会 upsert 到完整 cred。RepullAuthCode 调用前
618        // check empty 跳过 → 此场景 fallback 走 platform refresh)。
619        web_sig: String::new(),
620        // v1.4.94 G6 默认空 (parse.rs server-side fields populated separately)
621        moomoo_client_sig: String::new(),
622        moomoo_web_sig: String::new(),
623    };
624    if let Ok(json) = serde_json::to_string_pretty(&shell)
625        && write_secret_file_best_effort(&path, json.as_bytes(), "persist_device_verify_sig_shell")
626    {
627        // v1.4.106 codex 0558 F3: log fingerprint 替代 raw uid
628        tracing::info!(
629            path = %path.display(),
630            dvs_len = dvs.len(),
631            uid_fp = %super::redact::uid_log_fingerprint(ctx.uid),
632            "v1.4.81 BUG-009 Fix 9a gap: first-auth credentials shell persisted \
633             (enables Fix 9a cached-dvs path on next startup; tgtgt/device_sig empty, \
634             handle_device_verify only needs dvs+uid+rand_key)"
635        );
636    }
637}
638
639/// v1.4.72 BUG-009 Fix 9a: 检查 SavedCredentials 里的 `device_verify_sig` 是否
640/// 在 `DEVICE_VERIFY_SIG_TTL_SECS` 内未过期。
641///
642/// 返 `Some(dvs)` 若 cached dvs 仍新鲜(用户可能刚收到过 SMS,手里的码还有效)。
643/// 返 `None` 若缺 dvs 或已过期 → daemon 应走正常 /authority POST 流程。
644pub(super) fn fresh_cached_device_verify_sig(cred: &SavedCredentials) -> Option<&str> {
645    let dvs = cred.device_verify_sig.as_deref()?;
646    let ts = cred.device_verify_sig_ts?;
647    let now = auth_device_now_secs_or_zero("fresh_cached_device_verify_sig");
648    let age = now.saturating_sub(ts);
649    if age < DEVICE_VERIFY_SIG_TTL_SECS {
650        Some(dvs)
651    } else {
652        None
653    }
654}
655
656/// v1.4.81 BUG-009 Fix 9a Option B: 检查 SavedCredentials 里的
657/// `device_code_sig` 是否在 `DEVICE_CODE_SIG_TTL_SECS` 内未过期。
658///
659/// 返 `Some(dcs)` 若 cached dcs 仍新鲜 → Fix 9a Option B 路径可跳
660/// `req_device_code` 整步,直接用 cached dcs + 用户传入的 `--verify-code`
661/// 调 `verify_device_code`。
662pub(super) fn fresh_cached_device_code_sig(cred: &SavedCredentials) -> Option<&str> {
663    let dcs = cred.device_code_sig.as_deref()?;
664    let ts = cred.device_code_sig_ts?;
665    let now = auth_device_now_secs_or_zero("fresh_cached_device_code_sig");
666    let age = now.saturating_sub(ts);
667    if age < DEVICE_CODE_SIG_TTL_SECS {
668        Some(dcs)
669    } else {
670        None
671    }
672}
673
674/// v1.4.81 BUG-009 Fix 9a Option B: upsert `device_code_sig` + ts 到已存在的
675/// credentials 文件。credentials 不存在时返 —— Option B 必然在 shell-persist
676/// 之后调用(Step 1 `persist_device_verify_sig(Some(ctx))` 已写 shell)。
677pub(super) fn persist_device_code_sig(account: &str, dcs: &str) {
678    let path = match try_credentials_path(account) {
679        Ok(path) => path,
680        Err(err) => {
681            tracing::warn!(
682                error = %err,
683                "persist_device_code_sig skipped because credentials path is unavailable"
684            );
685            return;
686        }
687    };
688    let Ok(data) = std::fs::read_to_string(&path) else {
689        tracing::warn!(
690            path = %path.display(),
691            "persist_device_code_sig: credentials 文件不存在,跳过 dcs 持久化 \
692             (Option B 前置不满足,需先跑 persist_device_verify_sig shell path)"
693        );
694        return;
695    };
696    let Ok(mut cred) = serde_json::from_str::<SavedCredentials>(&data) else {
697        tracing::warn!(
698            path = %path.display(),
699            "persist_device_code_sig: credentials 解析失败,跳过 dcs 持久化"
700        );
701        return;
702    };
703    let now = auth_device_now_secs_or_zero("persist_device_code_sig");
704    cred.device_code_sig = Some(dcs.to_string());
705    cred.device_code_sig_ts = Some(now);
706    // v1.4.102 BUG-012: 0600 secret-file
707    if let Ok(json) = serde_json::to_string_pretty(&cred)
708        && write_secret_file_best_effort(&path, json.as_bytes(), "persist_device_code_sig")
709    {
710        tracing::info!(
711            path = %path.display(),
712            dcs_len = dcs.len(),
713            "v1.4.81 BUG-009 Fix 9a Option B: device_code_sig cached (5min TTL)"
714        );
715    }
716}
717
718fn auth_device_now_secs_or_zero(context: &'static str) -> u64 {
719    match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) {
720        Ok(duration) => duration.as_secs(),
721        Err(err) => {
722            tracing::warn!(
723                context,
724                error = ?err,
725                "auth device wall clock is before UNIX_EPOCH; falling back to zero timestamp"
726            );
727            0
728        }
729    }
730}
731
732pub(super) fn credential_ticket_status(account: &str) -> Option<CredentialTicketStatus> {
733    let cred = load_credentials(account)?;
734    Some(credential_ticket_status_from_saved(
735        cred.tgtgt_saved_at,
736        auth_device_now_secs_or_zero("credential_ticket_status"),
737    ))
738}
739
740fn credential_ticket_status_from_saved(saved_at: u64, now: u64) -> CredentialTicketStatus {
741    if saved_at == 0 {
742        return CredentialTicketStatus {
743            saved_at: None,
744            age_days: None,
745            expires_in_days: None,
746            expiry_warning: Some(
747                "cached tgtgt age is unknown; run futucli daemon-reload once to refresh metadata"
748                    .to_string(),
749            ),
750        };
751    }
752    let age_secs = now.saturating_sub(saved_at);
753    let age_days = age_secs / 86_400;
754    let expires_at = saved_at.saturating_add(u64::from(TGTGT_VALIDITY_SECS));
755    let remaining_secs = i128::from(expires_at) - i128::from(now);
756    let expires_in_days = remaining_secs.div_euclid(86_400) as i64;
757    let expiry_warning = if remaining_secs <= 0 {
758        Some(
759            "cached tgtgt is expired; restart with password/SMS or refresh credentials".to_string(),
760        )
761    } else if expires_in_days < 5 {
762        Some(format!(
763            "cached tgtgt expires in {expires_in_days} days; run futucli daemon-reload before expiry"
764        ))
765    } else {
766        None
767    };
768    CredentialTicketStatus {
769        saved_at: Some(saved_at),
770        age_days: Some(age_days),
771        expires_in_days: Some(expires_in_days),
772        expiry_warning,
773    }
774}
775
776#[cfg(test)]
777mod tests;