1use 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#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
50pub(super) struct SavedCredentials {
51 #[serde(default = "legacy_credentials_schema_version")]
59 pub(super) schema_version: u32,
60 #[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 #[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 #[serde(default)]
97 pub(super) device_verify_sig: Option<String>,
98 #[serde(default)]
99 pub(super) device_verify_sig_ts: Option<u64>,
100 #[serde(default)]
114 pub(super) device_code_sig: Option<String>,
115 #[serde(default)]
116 pub(super) device_code_sig_ts: Option<u64>,
117 #[serde(default)]
132 pub(super) web_sig: String,
133 #[serde(default)]
144 pub(super) moomoo_client_sig: String,
145 #[serde(default)]
149 pub(super) moomoo_web_sig: String,
150}
151
152pub(super) const DEVICE_VERIFY_SIG_TTL_SECS: u64 = 5 * 60;
157
158pub(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
188pub 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 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 }
251}
252
253pub 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 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 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 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 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 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 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
314pub 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 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 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 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 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 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 let (normalized_expected, _) = normalize_phone_account(account);
445
446 if cred.account.is_empty() {
447 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 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 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 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
500pub(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 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 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 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 }
589 }
590 }
591
592 let Some(ctx) = first_auth_ctx else {
595 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 web_sig: String::new(),
620 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 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
639pub(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
656pub(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
674pub(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 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;