less keychain access
This commit is contained in:
parent
5a3ac2eed9
commit
a2d6e72b7a
6 changed files with 124 additions and 49 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -4810,6 +4810,7 @@ dependencies = [
|
||||||
"ratatui",
|
"ratatui",
|
||||||
"regex",
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"toml",
|
"toml",
|
||||||
"tui-markdown",
|
"tui-markdown",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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"] }
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)?);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}"),
|
|
||||||
},
|
impl CredentialStore {
|
||||||
Err(e) => format!("entry creation failed: {e}"),
|
/// Load the credential bundle from the keychain. One keychain access.
|
||||||
};
|
pub fn load() -> Self {
|
||||||
// 2. env var: TUIMAIL_<KEY_UPPERCASE>
|
Self { bundle: load_bundle() }
|
||||||
|
}
|
||||||
|
|
||||||
|
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());
|
let env_key = format!("TUIMAIL_{}", key.to_uppercase());
|
||||||
std::env::var(&env_key).map_err(|_| {
|
std::env::var(&env_key).map_err(|_| {
|
||||||
format!(
|
format!(
|
||||||
"Credential '{key}' not found (keychain: {keychain_err}). \
|
"Credential '{key}' not found. \
|
||||||
Run with --configure to set up credentials, or set {env_key}."
|
Run with --configure to set up credentials, or set {env_key}."
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn set(key: &str, value: &str) -> Result<(), String> {
|
/// Update a value in memory (no keychain write until `save()`).
|
||||||
keyring::Entry::new(SERVICE, key)
|
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())?
|
.map_err(|e| e.to_string())?
|
||||||
.set_password(value)
|
.set_password(&json)
|
||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
|
||||||
|
|
||||||
pub fn delete(key: &str) {
|
|
||||||
if let Ok(entry) = keyring::Entry::new(SERVICE, key) {
|
|
||||||
let _ = entry.delete_credential();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
|
||||||
38
src/setup.rs
38
src/setup.rs
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue