less keychain access

This commit is contained in:
Shautvast 2026-03-02 16:41:19 +01:00
parent 5a3ac2eed9
commit a2d6e72b7a
6 changed files with 124 additions and 49 deletions

1
Cargo.lock generated
View file

@ -4810,6 +4810,7 @@ dependencies = [
"ratatui",
"regex",
"serde",
"serde_json",
"toml",
"tui-markdown",
]

View file

@ -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"] }

View file

@ -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.

View file

@ -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)?);
}
}
}

View file

@ -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<String, String> {
// 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_<KEY_UPPERCASE>
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<String, String>,
}
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_<KEY>`.
pub fn require(&self, key: &str) -> Result<String, String> {
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<String, String> {
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<String, String> {
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();
}

View file

@ -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<Config, String> {
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<Config, String> {
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<Config, String> {
let smtp_tls = prompt_tls_mode(ex_smtp.map(|s| &s.tls_mode));
let smtp_from = prompt("From address (e.g. Name <addr@example.com>)", 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<Config, String> {
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<Config, String> {
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<Config, String> {
.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,