1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
44pub(super) struct SavedCredentials {
45 #[serde(default = "legacy_credentials_schema_version")]
53 pub(super) schema_version: u32,
54 #[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 #[serde(default)]
83 pub(super) device_verify_sig: Option<String>,
84 #[serde(default)]
85 pub(super) device_verify_sig_ts: Option<u64>,
86 #[serde(default)]
100 pub(super) device_code_sig: Option<String>,
101 #[serde(default)]
102 pub(super) device_code_sig_ts: Option<u64>,
103 #[serde(default)]
118 pub(super) web_sig: String,
119 #[serde(default)]
130 pub(super) moomoo_client_sig: String,
131 #[serde(default)]
135 pub(super) moomoo_web_sig: String,
136}
137
138pub(super) const DEVICE_VERIFY_SIG_TTL_SECS: u64 = 5 * 60;
143
144pub(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
174pub 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 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 }
237}
238
239pub 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 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 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 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 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 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 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
300pub 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 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 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 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 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 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 let (normalized_expected, _) = normalize_phone_account(account);
431
432 if cred.account.is_empty() {
433 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 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 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 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
486pub(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 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 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 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 }
575 }
576 }
577
578 let Some(ctx) = first_auth_ctx else {
581 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 web_sig: String::new(),
605 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 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
624pub(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
641pub(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
659pub(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 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#[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
754pub(super) fn save_credentials(
760 account: &str,
761 cred: &SavedCredentials,
762) -> Result<(), CredentialsStoreError> {
763 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 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;