futu_opend/
credentials.rs1use anyhow::Result;
4use futu_auth::secret_store::{PasswordLookup, SecretStoreError};
5
6use crate::config::{RuntimeConfig, read_explicit_credential_file};
7
8pub fn resolve_login_password(
33 account: Option<&str>,
34 config: &RuntimeConfig,
35) -> Result<Option<(Option<String>, bool)>> {
36 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 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 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 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 if let Some(acc) = account {
83 let account_fp = futu_backend::auth::redact::account_log_fingerprint(acc);
84 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 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 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;