Skip to main content

futu_mcp/
trade_pwd.rs

1//! 交易密码来源封装
2//!
3//! 优先级(对 LLM 透明,它只会调 `futu_unlock_trade` 工具,密码永不入 prompt):
4//!   1. 环境变量 `FUTU_TRADE_PWD`(显式运行时 override)
5//!   2. 账号级 OS credential store(macOS 发布包仅兼容读取旧条目)
6//!   3. legacy 全局 credential store —— v1.4.109 前的 `trade-password` 兜底读取
7//!   4. 都没有 → 报错
8//!
9//! MD5 在 server 端计算后再发网关。LLM 从来看不到明文 / MD5。所有
10//! credential-store 操作由 futu-auth 的 single-flight / circuit-breaker owner 执行。
11
12use futu_auth::{
13    KEYRING_USERNAME_TRADE_PWD, keyring_username_for_trade_pwd,
14    secret_store::{PasswordLookup, SecretStoreError},
15};
16
17fn non_empty_trimmed(s: &str) -> Option<String> {
18    let s = s.trim();
19    (!s.is_empty()).then(|| s.to_string())
20}
21
22fn trade_pwd_account_from(
23    explicit: Option<&str>,
24    trade_pwd_account_env: Option<&str>,
25    futu_account_env: Option<&str>,
26) -> Option<String> {
27    explicit
28        .and_then(non_empty_trimmed)
29        .or_else(|| trade_pwd_account_env.and_then(non_empty_trimmed))
30        .or_else(|| futu_account_env.and_then(non_empty_trimmed))
31}
32
33fn read_keyring_password(
34    username: &str,
35) -> Result<Option<String>, futu_auth::secret_store::SecretStoreError> {
36    match futu_auth::secret_store::read_password(username) {
37        Ok(PasswordLookup::Found(password)) => Ok(Some(password)),
38        Ok(PasswordLookup::Empty | PasswordLookup::NoEntry) => Ok(None),
39        // Preserve legacy fallback for ordinary backend errors. Timeout and a
40        // disconnected worker are fatal because hiding them recreates Issue #31.
41        Err(SecretStoreError::Backend { .. }) => Ok(None),
42        Err(error) => Err(error),
43    }
44}
45
46fn trade_password_from_sources(
47    account_hint: Option<&str>,
48    trade_pwd_account_env: Option<&str>,
49    futu_account_env: Option<&str>,
50    env_pwd: Option<&str>,
51    mut read_keyring: impl FnMut(&str) -> Result<Option<String>, SecretStoreError>,
52) -> Result<String, String> {
53    if let Some(pwd) = env_pwd.and_then(non_empty_trimmed) {
54        return Ok(pwd);
55    }
56
57    if let Some(account) =
58        trade_pwd_account_from(account_hint, trade_pwd_account_env, futu_account_env)
59    {
60        let scoped_username = keyring_username_for_trade_pwd(&account);
61        if let Some(pwd) = read_keyring(&scoped_username).map_err(keyring_read_error)? {
62            return Ok(pwd);
63        }
64        if let Some(pwd) = read_keyring(KEYRING_USERNAME_TRADE_PWD).map_err(keyring_read_error)? {
65            return Ok(pwd);
66        }
67        return Err(missing_trade_password_message(Some(&account)));
68    }
69
70    if let Some(pwd) = read_keyring(KEYRING_USERNAME_TRADE_PWD).map_err(keyring_read_error)? {
71        return Ok(pwd);
72    }
73    Err(missing_trade_password_message(None))
74}
75
76fn keyring_read_error(error: SecretStoreError) -> String {
77    format!(
78        "OS keychain trade-password lookup failed within the {}-second safety boundary: \
79         {error}; set FUTU_TRADE_PWD instead. {}",
80        futu_auth::secret_store::KEYRING_OPERATION_TIMEOUT.as_secs(),
81        keyring_platform_detail()
82    )
83}
84
85#[cfg(target_os = "macos")]
86fn keyring_platform_detail() -> &'static str {
87    "Packaged cross-binary Keychain sharing is not supported."
88}
89
90#[cfg(not(target_os = "macos"))]
91fn keyring_platform_detail() -> &'static str {
92    "The platform credential-store operation did not finish safely."
93}
94
95#[cfg(target_os = "macos")]
96fn missing_trade_password_message(account: Option<&str>) -> String {
97    let account_hint = account
98        .map(|value| format!(" for account {value}"))
99        .unwrap_or_default();
100    format!(
101        "no trade password configured{account_hint}: set FUTU_TRADE_PWD; packaged macOS \
102         futucli/futu-mcp binaries have distinct ad-hoc signing identities, so \
103         set-trade-pwd Keychain sharing is not supported"
104    )
105}
106
107#[cfg(not(target_os = "macos"))]
108fn missing_trade_password_message(account: Option<&str>) -> String {
109    match account {
110        Some(account) => format!(
111            "no trade password configured for account {account}: run `futucli set-trade-pwd \
112             --account {account}`, then start futu-mcp with `--trade-pwd-account {account}` \
113             (or FUTU_TRADE_PWD_ACCOUNT={account}); alternatively set FUTU_TRADE_PWD"
114        ),
115        None => "no trade password configured: run `futucli set-trade-pwd --account \
116                 <login-account>` and start futu-mcp with `--trade-pwd-account \
117                 <login-account>` (or set FUTU_TRADE_PWD_ACCOUNT); legacy deployments may \
118                 still use FUTU_TRADE_PWD"
119            .to_string(),
120    }
121}
122
123/// 试着拿到交易密码明文。成功则返回 `Ok(pwd)`;任何来源都没找到返回 `Err`。
124pub fn get_trade_password_for_account(account_hint: Option<&str>) -> Result<String, String> {
125    let trade_pwd_account_env = std::env::var("FUTU_TRADE_PWD_ACCOUNT").ok();
126    let futu_account_env = std::env::var("FUTU_ACCOUNT").ok();
127    let env_pwd = std::env::var("FUTU_TRADE_PWD").ok();
128    trade_password_from_sources(
129        account_hint,
130        trade_pwd_account_env.as_deref(),
131        futu_account_env.as_deref(),
132        env_pwd.as_deref(),
133        read_keyring_password,
134    )
135}
136
137/// 把密码 MD5 化再返回(unlock_trade RPC 要的格式)
138pub fn get_trade_password_md5_for_account(account_hint: Option<&str>) -> Result<String, String> {
139    let pwd = get_trade_password_for_account(account_hint)?;
140    Ok(md5_hex(&pwd))
141}
142
143fn md5_hex(pwd: &str) -> String {
144    format!("{:x}", md5::compute(pwd.as_bytes()))
145}
146
147#[cfg(test)]
148mod tests;