diff --git a/Cargo.lock b/Cargo.lock index 959171b..d59407c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4810,6 +4810,7 @@ dependencies = [ "ratatui", "regex", "serde", + "serde_json", "toml", "tui-markdown", ] diff --git a/Cargo.toml b/Cargo.toml index c88a5b4..2bf9730 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo dirs = "6.0" rand = { version = "0.8", features = ["getrandom"] } aes-gcm = "0.10" +serde_json = "1.0" [target.'cfg(target_os = "macos")'.dependencies] keyring = { version = "3", features = ["apple-native"] } diff --git a/README.md b/README.md index 4f99c94..3a04960 100644 --- a/README.md +++ b/README.md @@ -40,8 +40,8 @@ Tuimail stores passwords securely in the **OS keychain** (macOS Keychain, GNOME Keyring, KWallet, Windows Credential Manager). No passwords are ever written to disk in plain text. -Note: every time you recreate the binary file using cargo, -Macos will need reapproval (4 times) for access to the keychain. +Note: every time you recreate the binary file using cargo, +macOS will need reapproval for access to the keychain. ## Local cache: Messages are safely stored locally using AES-256-GCM. diff --git a/src/config.rs b/src/config.rs index 6f16e3d..25d0d7f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -101,34 +101,37 @@ impl Config { /// Fill in password fields from the OS keychain (with env-var fallback). /// Must be called after `load()` and before any provider operations. + /// Accesses the keychain exactly once regardless of how many credentials + /// are needed, so macOS only shows one approval prompt per binary rebuild. pub fn inject_credentials(&mut self) -> Result<(), String> { - use crate::credentials; + use crate::credentials::{self, CredentialStore}; + let store = CredentialStore::load(); match self.provider { Provider::Imap => { if let Some(ref mut imap) = self.imap && imap.password.is_none() { - imap.password = Some(credentials::get(credentials::IMAP_PASSWORD)?); + imap.password = Some(store.require(credentials::IMAP_PASSWORD)?); } - if let Some(ref mut smtp) = self.smtp && smtp.password.is_none(){ - smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?); + if let Some(ref mut smtp) = self.smtp && smtp.password.is_none() { + smtp.password = Some(store.require(credentials::SMTP_PASSWORD)?); } } Provider::Proton => { if let Some(ref mut proton) = self.proton { if proton.password.is_none() { - proton.password = Some(credentials::get(credentials::PROTON_PASSWORD)?); + proton.password = Some(store.require(credentials::PROTON_PASSWORD)?); } if proton.mailbox_password.is_none() { proton.mailbox_password = - credentials::get(credentials::PROTON_MAILBOX_PASSWORD).ok(); + store.require(credentials::PROTON_MAILBOX_PASSWORD).ok(); } } if let Some(ref mut bridge) = self.bridge && bridge.local_password.is_none() { bridge.local_password = - Some(credentials::get(credentials::BRIDGE_LOCAL_PASSWORD)?); + Some(store.require(credentials::BRIDGE_LOCAL_PASSWORD)?); } } } diff --git a/src/credentials.rs b/src/credentials.rs index 133079c..4660bf8 100644 --- a/src/credentials.rs +++ b/src/credentials.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + pub const IMAP_PASSWORD: &str = "imap_password"; pub const SMTP_PASSWORD: &str = "smtp_password"; pub const PROTON_PASSWORD: &str = "proton_password"; @@ -5,35 +7,93 @@ pub const PROTON_MAILBOX_PASSWORD: &str = "proton_mailbox_password"; pub const BRIDGE_LOCAL_PASSWORD: &str = "bridge_local_password"; const SERVICE: &str = "tuimail"; +/// All credentials live in one keychain entry as a JSON map. +/// One entry = one macOS approval prompt per binary rebuild. +const BUNDLE_KEY: &str = "credentials"; -pub fn get(key: &str) -> Result { - // 1. OS keychain - let keychain_err = match keyring::Entry::new(SERVICE, key) { - Ok(entry) => match entry.get_password() { - Ok(val) => return Ok(val), - Err(e) => format!("{e}"), - }, - Err(e) => format!("entry creation failed: {e}"), - }; - // 2. env var: TUIMAIL_ - let env_key = format!("TUIMAIL_{}", key.to_uppercase()); - std::env::var(&env_key).map_err(|_| { - format!( - "Credential '{key}' not found (keychain: {keychain_err}). \ - Run with --configure to set up credentials, or set {env_key}." - ) - }) +/// Holds all credentials in memory. Load once with `CredentialStore::load()`, +/// mutate, then flush with `save()`. This keeps keychain accesses to a minimum. +pub struct CredentialStore { + bundle: HashMap, } -pub fn set(key: &str, value: &str) -> Result<(), String> { - keyring::Entry::new(SERVICE, key) - .map_err(|e| e.to_string())? - .set_password(value) - .map_err(|e| e.to_string()) -} +impl CredentialStore { + /// Load the credential bundle from the keychain. One keychain access. + pub fn load() -> Self { + Self { bundle: load_bundle() } + } -pub fn delete(key: &str) { - if let Ok(entry) = keyring::Entry::new(SERVICE, key) { - let _ = entry.delete_credential(); + pub fn contains(&self, key: &str) -> bool { + self.bundle.contains_key(key) + } + + /// Return a stored value (in-memory; no keychain access). + pub fn get(&self, key: &str) -> Option<&str> { + self.bundle.get(key).map(String::as_str) + } + + /// Return a stored value, falling back to env `TUIMAIL_`. + pub fn require(&self, key: &str) -> Result { + if let Some(val) = self.bundle.get(key) { + return Ok(val.clone()); + } + let env_key = format!("TUIMAIL_{}", key.to_uppercase()); + std::env::var(&env_key).map_err(|_| { + format!( + "Credential '{key}' not found. \ + Run with --configure to set up credentials, or set {env_key}." + ) + }) + } + + /// Update a value in memory (no keychain write until `save()`). + pub fn set(&mut self, key: &str, value: &str) { + self.bundle.insert(key.to_string(), value.to_string()); + } + + /// Remove a value in memory (no keychain write until `save()`). + pub fn delete(&mut self, key: &str) { + self.bundle.remove(key); + } + + /// Persist the bundle to the keychain. One keychain write. + pub fn save(&self) -> Result<(), String> { + let json = serde_json::to_string(&self.bundle).map_err(|e| e.to_string())?; + keyring::Entry::new(SERVICE, BUNDLE_KEY) + .map_err(|e| e.to_string())? + .set_password(&json) + .map_err(|e| e.to_string()) } } + +fn load_bundle() -> HashMap { + let Ok(entry) = keyring::Entry::new(SERVICE, BUNDLE_KEY) else { + return HashMap::new(); + }; + let Ok(json) = entry.get_password() else { + return HashMap::new(); + }; + serde_json::from_str(&json).unwrap_or_default() +} + +// ── Convenience wrappers (single-key operations) ───────────────────────────── + +/// Get a single credential. Prefer `CredentialStore::load()` when fetching +/// multiple credentials so the keychain is accessed only once. +pub fn get(key: &str) -> Result { + CredentialStore::load().require(key) +} + +/// Set a single credential (read-modify-write on the bundle). +pub fn set(key: &str, value: &str) -> Result<(), String> { + let mut store = CredentialStore::load(); + store.set(key, value); + store.save() +} + +/// Delete a single credential (read-modify-write on the bundle). +pub fn delete(key: &str) { + let mut store = CredentialStore::load(); + store.delete(key); + let _ = store.save(); +} diff --git a/src/setup.rs b/src/setup.rs index 415b08b..739b546 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -2,7 +2,7 @@ use std::io::{self, BufRead, Write}; use std::path::PathBuf; use crate::config::{BridgeConfig, Config, ImapConfig, Provider, ProtonConfig, SmtpConfig, TlsMode}; -use crate::credentials; +use crate::credentials::{self, CredentialStore}; // ── helpers ────────────────────────────────────────────────────────────────── @@ -184,6 +184,9 @@ fn setup_imap(existing: Option<&Config>) -> Result { let ex_imap = existing.and_then(|c| c.imap.as_ref()); let ex_smtp = existing.and_then(|c| c.smtp.as_ref()); + // Load once; all existence checks and updates happen in memory. + let mut store = CredentialStore::load(); + println!("--- IMAP settings ---"); let imap_host = prompt("IMAP host", ex_imap.map(|i| i.host.as_str())); let imap_port_default = ex_imap.map(|i| i.port.to_string()); @@ -193,13 +196,13 @@ fn setup_imap(existing: Option<&Config>) -> Result { let imap_user = prompt("IMAP username (email)", ex_imap.map(|i| i.username.as_str())); let imap_tls = prompt_bool("Use TLS for IMAP?", ex_imap.map(|i| i.use_tls).unwrap_or(true)); - let imap_pass = if credentials::get(credentials::IMAP_PASSWORD).is_ok() { + let imap_pass = if store.contains(credentials::IMAP_PASSWORD) { prompt_password_optional("IMAP password") } else { Some(prompt_password_required("IMAP password")) }; if let Some(ref pw) = imap_pass { - credentials::set(credentials::IMAP_PASSWORD, pw)?; + store.set(credentials::IMAP_PASSWORD, pw); } println!("\n--- SMTP settings ---"); @@ -212,15 +215,18 @@ fn setup_imap(existing: Option<&Config>) -> Result { let smtp_tls = prompt_tls_mode(ex_smtp.map(|s| &s.tls_mode)); let smtp_from = prompt("From address (e.g. Name )", ex_smtp.map(|s| s.from.as_str())); - let smtp_pass = if credentials::get(credentials::SMTP_PASSWORD).is_ok() { + let smtp_pass = if store.contains(credentials::SMTP_PASSWORD) { prompt_password_optional("SMTP password") } else { Some(prompt_password_required("SMTP password")) }; if let Some(ref pw) = smtp_pass { - credentials::set(credentials::SMTP_PASSWORD, pw)?; + store.set(credentials::SMTP_PASSWORD, pw); } + // One write for all credentials. + store.save()?; + Ok(Config { provider: Provider::Imap, imap: Some(ImapConfig { @@ -249,16 +255,19 @@ fn setup_proton(existing: Option<&Config>) -> Result { let ex_proton = existing.and_then(|c| c.proton.as_ref()); let ex_bridge = existing.and_then(|c| c.bridge.as_ref()); + // Load once; all existence checks and updates happen in memory. + let mut store = CredentialStore::load(); + println!("--- ProtonMail settings ---"); let username = prompt("Proton account email", ex_proton.map(|p| p.username.as_str())); - let proton_pass = if credentials::get(credentials::PROTON_PASSWORD).is_ok() { + let proton_pass = if store.contains(credentials::PROTON_PASSWORD) { prompt_password_optional("Proton login password") } else { Some(prompt_password_required("Proton login password")) }; if let Some(ref pw) = proton_pass { - credentials::set(credentials::PROTON_PASSWORD, pw)?; + store.set(credentials::PROTON_PASSWORD, pw); } let two_pw = prompt_bool( @@ -266,16 +275,16 @@ fn setup_proton(existing: Option<&Config>) -> Result { ex_proton.and_then(|p| p.mailbox_password.as_ref()).is_some(), ); if two_pw { - let mbx_pass = if credentials::get(credentials::PROTON_MAILBOX_PASSWORD).is_ok() { + let mbx_pass = if store.contains(credentials::PROTON_MAILBOX_PASSWORD) { prompt_password_optional("Mailbox password") } else { Some(prompt_password_required("Mailbox password")) }; if let Some(ref pw) = mbx_pass { - credentials::set(credentials::PROTON_MAILBOX_PASSWORD, pw)?; + store.set(credentials::PROTON_MAILBOX_PASSWORD, pw); } } else { - credentials::delete(credentials::PROTON_MAILBOX_PASSWORD); + store.delete(credentials::PROTON_MAILBOX_PASSWORD); } let bridge_imap_default = ex_bridge.map(|b| b.imap_port.to_string()); @@ -294,13 +303,14 @@ fn setup_proton(existing: Option<&Config>) -> Result { .unwrap_or(1025); // Generate a new local bridge password only if one isn't already stored. - let local_pw_exists = credentials::get(credentials::BRIDGE_LOCAL_PASSWORD).is_ok(); - if !local_pw_exists { - let local_pw = random_hex(16); - credentials::set(credentials::BRIDGE_LOCAL_PASSWORD, &local_pw)?; + if !store.contains(credentials::BRIDGE_LOCAL_PASSWORD) { + store.set(credentials::BRIDGE_LOCAL_PASSWORD, &random_hex(16)); println!("Generated bridge local password (stored in keychain)."); } + // One write for all credentials. + store.save()?; + Ok(Config { provider: Provider::Proton, imap: None,