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", "ratatui",
"regex", "regex",
"serde", "serde",
"serde_json",
"toml", "toml",
"tui-markdown", "tui-markdown",
] ]

View file

@ -24,6 +24,7 @@ lettre = { version = "0.11", default-features = false, features = ["smtp-transpo
dirs = "6.0" dirs = "6.0"
rand = { version = "0.8", features = ["getrandom"] } rand = { version = "0.8", features = ["getrandom"] }
aes-gcm = "0.10" aes-gcm = "0.10"
serde_json = "1.0"
[target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "macos")'.dependencies]
keyring = { version = "3", features = ["apple-native"] } 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 GNOME Keyring, KWallet, Windows Credential Manager). No passwords are ever
written to disk in plain text. written to disk in plain text.
Note: every time you recreate the binary file using cargo, Note: every time you recreate the binary file using cargo,
Macos will need reapproval (4 times) for access to the keychain. macOS will need reapproval for access to the keychain.
## Local cache: ## Local cache:
Messages are safely stored locally using AES-256-GCM. 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). /// Fill in password fields from the OS keychain (with env-var fallback).
/// Must be called after `load()` and before any provider operations. /// 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> { pub fn inject_credentials(&mut self) -> Result<(), String> {
use crate::credentials; use crate::credentials::{self, CredentialStore};
let store = CredentialStore::load();
match self.provider { match self.provider {
Provider::Imap => { Provider::Imap => {
if let Some(ref mut imap) = self.imap if let Some(ref mut imap) = self.imap
&& imap.password.is_none() && 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(){ if let Some(ref mut smtp) = self.smtp && smtp.password.is_none() {
smtp.password = Some(credentials::get(credentials::SMTP_PASSWORD)?); smtp.password = Some(store.require(credentials::SMTP_PASSWORD)?);
} }
} }
Provider::Proton => { Provider::Proton => {
if let Some(ref mut proton) = self.proton { if let Some(ref mut proton) = self.proton {
if proton.password.is_none() { 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() { if proton.mailbox_password.is_none() {
proton.mailbox_password = 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 if let Some(ref mut bridge) = self.bridge
&& bridge.local_password.is_none() && bridge.local_password.is_none()
{ {
bridge.local_password = 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 IMAP_PASSWORD: &str = "imap_password";
pub const SMTP_PASSWORD: &str = "smtp_password"; pub const SMTP_PASSWORD: &str = "smtp_password";
pub const PROTON_PASSWORD: &str = "proton_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"; pub const BRIDGE_LOCAL_PASSWORD: &str = "bridge_local_password";
const SERVICE: &str = "tuimail"; 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> { /// Holds all credentials in memory. Load once with `CredentialStore::load()`,
// 1. OS keychain /// mutate, then flush with `save()`. This keeps keychain accesses to a minimum.
let keychain_err = match keyring::Entry::new(SERVICE, key) { pub struct CredentialStore {
Ok(entry) => match entry.get_password() { bundle: HashMap<String, String>,
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}."
)
})
} }
pub fn set(key: &str, value: &str) -> Result<(), String> { impl CredentialStore {
keyring::Entry::new(SERVICE, key) /// Load the credential bundle from the keychain. One keychain access.
.map_err(|e| e.to_string())? pub fn load() -> Self {
.set_password(value) Self { bundle: load_bundle() }
.map_err(|e| e.to_string()) }
}
pub fn delete(key: &str) { pub fn contains(&self, key: &str) -> bool {
if let Ok(entry) = keyring::Entry::new(SERVICE, key) { self.bundle.contains_key(key)
let _ = entry.delete_credential(); }
/// 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 std::path::PathBuf;
use crate::config::{BridgeConfig, Config, ImapConfig, Provider, ProtonConfig, SmtpConfig, TlsMode}; use crate::config::{BridgeConfig, Config, ImapConfig, Provider, ProtonConfig, SmtpConfig, TlsMode};
use crate::credentials; use crate::credentials::{self, CredentialStore};
// ── helpers ────────────────────────────────────────────────────────────────── // ── 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_imap = existing.and_then(|c| c.imap.as_ref());
let ex_smtp = existing.and_then(|c| c.smtp.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 ---"); println!("--- IMAP settings ---");
let imap_host = prompt("IMAP host", ex_imap.map(|i| i.host.as_str())); 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()); 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_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_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") prompt_password_optional("IMAP password")
} else { } else {
Some(prompt_password_required("IMAP password")) Some(prompt_password_required("IMAP password"))
}; };
if let Some(ref pw) = imap_pass { if let Some(ref pw) = imap_pass {
credentials::set(credentials::IMAP_PASSWORD, pw)?; store.set(credentials::IMAP_PASSWORD, pw);
} }
println!("\n--- SMTP settings ---"); 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_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_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") prompt_password_optional("SMTP password")
} else { } else {
Some(prompt_password_required("SMTP password")) Some(prompt_password_required("SMTP password"))
}; };
if let Some(ref pw) = smtp_pass { 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 { Ok(Config {
provider: Provider::Imap, provider: Provider::Imap,
imap: Some(ImapConfig { 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_proton = existing.and_then(|c| c.proton.as_ref());
let ex_bridge = existing.and_then(|c| c.bridge.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 ---"); println!("--- ProtonMail settings ---");
let username = prompt("Proton account email", ex_proton.map(|p| p.username.as_str())); 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") prompt_password_optional("Proton login password")
} else { } else {
Some(prompt_password_required("Proton login password")) Some(prompt_password_required("Proton login password"))
}; };
if let Some(ref pw) = proton_pass { if let Some(ref pw) = proton_pass {
credentials::set(credentials::PROTON_PASSWORD, pw)?; store.set(credentials::PROTON_PASSWORD, pw);
} }
let two_pw = prompt_bool( 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(), ex_proton.and_then(|p| p.mailbox_password.as_ref()).is_some(),
); );
if two_pw { 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") prompt_password_optional("Mailbox password")
} else { } else {
Some(prompt_password_required("Mailbox password")) Some(prompt_password_required("Mailbox password"))
}; };
if let Some(ref pw) = mbx_pass { if let Some(ref pw) = mbx_pass {
credentials::set(credentials::PROTON_MAILBOX_PASSWORD, pw)?; store.set(credentials::PROTON_MAILBOX_PASSWORD, pw);
} }
} else { } 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()); 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); .unwrap_or(1025);
// Generate a new local bridge password only if one isn't already stored. // 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 !store.contains(credentials::BRIDGE_LOCAL_PASSWORD) {
if !local_pw_exists { store.set(credentials::BRIDGE_LOCAL_PASSWORD, &random_hex(16));
let local_pw = random_hex(16);
credentials::set(credentials::BRIDGE_LOCAL_PASSWORD, &local_pw)?;
println!("Generated bridge local password (stored in keychain)."); println!("Generated bridge local password (stored in keychain).");
} }
// One write for all credentials.
store.save()?;
Ok(Config { Ok(Config {
provider: Provider::Proton, provider: Provider::Proton,
imap: None, imap: None,