Skip to main content

futu_opend/
credentials.rs

1//! v1.4.110 P1-2: 凭据解析 helper 抽自 main.rs lines 844-973.
2
3use anyhow::Result;
4use futu_auth::secret_store::{PasswordLookup, SecretStoreError};
5
6use crate::config::{RuntimeConfig, read_explicit_credential_file};
7
8/// 按 7 层优先级解析登录密码(v1.4.18+)。返回 `(password, is_md5)`。
9///
10/// 优先级(高到低):
11///   1. `--login-pwd-file <path>`   读文件(Docker secrets / systemd LoadCredential)
12///   2. `--login-pwd <plain>`       明文 argv(打 deprecation WARN)
13///   3. `--login-pwd-md5 <hex>`     md5 argv(同样 WARN)
14///   4. `FUTU_PWD` env var
15///   5. OS credential store(兼容读取;macOS 发布包不支持跨 binary 写入)
16///   6. 交互式 tty prompt(`rpassword`,不回显不进 history)
17///   7. 都没有 → `None`
18///
19/// `account` 用来查 keychain 条目(每账号一条,`login-password.<account>`)。
20///
21/// codex 0547 F2 (P2) fix: 显式 `--login-pwd-file` / `[login_pwd_file]` 配置
22/// 路径读取失败 / 内容空 = fatal Err. 不再 silent fallback to 后续 6 项 (老
23/// 行为)。systemd `LoadCredential=` / Docker secret mount 失败时, 之前 daemon
24/// 会用旧 `FUTU_PWD` / keychain 密码继续登 → 用户以为换密码, 实际还在用旧值
25/// (silent failure 反模式 / pitfall #45). explicit ≠ auto-detect; auto-detect
26/// 该 silent fallback, explicit 该 fail-closed.
27///
28/// 返回 `Result<Option<(Option<String>, bool)>>`:
29/// - `Ok(Some((Some(pwd), is_md5)))` — 找到密码 (任一来源)
30/// - `Ok(None)` — 7 层全无 (caller 按 "无凭据" 处理, e.g. offline mode)
31/// - `Err(...)` — explicit 来源 #1 (login_pwd_file) 失败, daemon 应 abort
32pub fn resolve_login_password(
33    account: Option<&str>,
34    config: &RuntimeConfig,
35) -> Result<Option<(Option<String>, bool)>> {
36    // 1. --login-pwd-file (显式 → fail-closed per codex 0547 F2)
37    //
38    // 之前 v1.4.18 - v1.4.105: read 失败 / empty → tracing::warn + 走下一项.
39    // 修后: 用户**显式**给了 path 但读失败 / 文件空 = fatal, 不 silent
40    // fallback. 走 `read_explicit_credential_file` helper 与 F1 保持一致.
41    if let Some(path) = &config.login_pwd_file {
42        let pwd = read_explicit_credential_file("--login-pwd-file", path)?;
43        tracing::info!(path = %path, "loaded login password from --login-pwd-file");
44        return Ok(Some((Some(pwd), false)));
45    }
46
47    // 2. --login-pwd(明文)—— 保留但打 deprecation WARN
48    if let Some(pwd) = &config.login_pwd
49        && !pwd.is_empty()
50    {
51        let account_fp = account.map(futu_backend::auth::redact::account_log_fingerprint);
52        tracing::warn!(
53            account_fp = ?account_fp,
54            "⚠️  --login-pwd passes plaintext password via argv; visible in `ps aux` \
55                 and shell history. Recommended: store it in a mode-0600 file and pass \
56                 --login-pwd-file <path>."
57        );
58        return Ok(Some((Some(pwd.clone()), false)));
59    }
60
61    // 3. --login-pwd-md5 —— 同样 WARN(md5 等同明文,可以直接登录)
62    if let Some(md5) = &config.login_pwd_md5
63        && !md5.is_empty()
64    {
65        tracing::warn!(
66            "⚠️  --login-pwd-md5 is equivalent to plaintext (can log in directly); \
67                 same argv exposure as --login-pwd. Recommended: use \
68                 --login-pwd-file <path>."
69        );
70        return Ok(Some((Some(md5.clone()), true)));
71    }
72
73    // 4. FUTU_PWD env var
74    if let Ok(pwd) = std::env::var("FUTU_PWD")
75        && !pwd.is_empty()
76    {
77        tracing::info!("loaded login password from FUTU_PWD env var");
78        return Ok(Some((Some(pwd), false)));
79    }
80
81    // 5. OS keychain —— 需要知道 account 才能查对应条目
82    if let Some(acc) = account {
83        let account_fp = futu_backend::auth::redact::account_log_fingerprint(acc);
84        // Issue #31: the shared credential-store owner now enforces a three-second
85        // safety boundary; this log identifies the bounded compatibility lookup.
86        tracing::info!(
87            account_fp = %account_fp,
88            "loading login password from OS credential store (3-second safety boundary)"
89        );
90        let username = futu_auth::keyring_username_for_login_pwd(acc);
91        match resolve_login_keychain_entry(&username, futu_auth::secret_store::read_password)? {
92            PasswordLookup::Found(pwd) => {
93                tracing::info!(account_fp = %account_fp, "loaded login password from OS keychain");
94                return Ok(Some((Some(pwd), false)));
95            }
96            PasswordLookup::Empty => {
97                tracing::debug!(account_fp = %account_fp, "keychain entry exists but is empty");
98            }
99            PasswordLookup::NoEntry => {
100                tracing::debug!(account_fp = %account_fp, "no keychain entry for this account");
101            }
102        }
103    }
104
105    // 6. 交互式 prompt(stdin 是 tty 时)
106    if std::io::IsTerminal::is_terminal(&std::io::stdin())
107        && let Some(acc) = account
108    {
109        match rpassword::prompt_password(format!("Login password for account {acc}: ")) {
110            Ok(pwd) if !pwd.is_empty() => {
111                tracing::info!("loaded login password from interactive prompt");
112                eprintln!(
113                    "  tip: for unattended startup, store the password in a mode-0600 \
114                         file and pass `--login-pwd-file <path>`."
115                );
116                return Ok(Some((Some(pwd), false)));
117            }
118            Ok(_) => {
119                tracing::warn!("empty password from prompt");
120            }
121            Err(e) => {
122                tracing::warn!(error = %e, "prompt_password failed");
123            }
124        }
125    }
126
127    // 7. 都没有
128    Ok(None)
129}
130
131fn resolve_login_keychain_entry(
132    username: &str,
133    read: impl FnOnce(&str) -> std::result::Result<PasswordLookup, SecretStoreError>,
134) -> Result<PasswordLookup> {
135    match read(username) {
136        Ok(outcome) => Ok(outcome),
137        Err(
138            SecretStoreError::Timeout { .. }
139            | SecretStoreError::WorkerDisconnected { .. }
140            | SecretStoreError::OperationInFlight
141            | SecretStoreError::CircuitOpen,
142        ) => Err(anyhow::anyhow!(
143            "OS keychain login-password lookup did not complete within {} seconds. \
144                 {} Run `futucli clear-login-pwd --account <account>`, then start with \
145                 `--login-pwd-file <0600-secret-file>`.",
146            futu_auth::secret_store::KEYRING_OPERATION_TIMEOUT.as_secs(),
147            credential_store_platform_detail()
148        )),
149        Err(error @ SecretStoreError::Backend { .. }) => {
150            tracing::warn!(error = %error, "keychain read failed");
151            Ok(PasswordLookup::NoEntry)
152        }
153    }
154}
155
156#[cfg(target_os = "macos")]
157fn credential_store_platform_detail() -> &'static str {
158    "Packaged macOS binaries cannot reliably share ad-hoc Keychain entries unattended."
159}
160
161#[cfg(not(target_os = "macos"))]
162fn credential_store_platform_detail() -> &'static str {
163    "The platform credential-store operation did not finish safely."
164}
165
166#[cfg(test)]
167mod tests;