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