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

@ -41,7 +41,7 @@ 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,