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