mail client (IMAP/SMTP)
git clone https://git.lucas.co/cce-mail.git
feat: account passwords live in the Secret Service keyring
resolve_account_secrets() runs at load: a plaintext password in
accounts.json is migrated into the keyring (service=cce-email,
account=<email>) and the file rewritten with the field blanked; an empty
field resolves from the keyring. In-memory passwords stay resolved for
the IMAP/SMTP workers, and save_accounts redacts keyring-backed entries
(serde-skipped keyring_backed flag) so secrets never return to disk. The
mock account is exempt. With a locked keyring the provider (KeePassXC
here) pops its unlock dialog and load blocks until answered — the
standard desktop flow; if no keyring answers, plaintext keeps working.
Live-verified: migration blanked the on-disk field, secret-tool sees the
entry, and a restart synced Gmail with the keyring-resolved password.
Co-Authored-By: Claude Fable 5 <[email protected]>
Cargo.toml | 1 +
src/main.rs | 53 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
2 files changed, 53 insertions(+), 1 deletion(-)
diff --git a/Cargo.toml b/Cargo.toml
index dd36dcc..9bdac45 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -4,6 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
+keyring = { version = "3", features = ["sync-secret-service"] }
cce-ui = { path = "../cce-ui" }
smithay-client-toolkit = "0.19.2"
calloop = "0.13.0"
diff --git a/src/main.rs b/src/main.rs
index e173739..ef290c0 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -39,6 +39,10 @@ struct AccountInfo {
client_id: Option<String>,
#[serde(default)]
client_secret: Option<String>,
+ /// True when the password came from (or was migrated into) the Secret
+ /// Service keyring — save_accounts blanks it on disk. Never serialized.
+ #[serde(skip)]
+ keyring_backed: bool,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
@@ -175,7 +179,12 @@ fn load_accounts() -> Vec<AccountInfo> {
let path = get_accounts_path();
if path.exists() {
if let Ok(content) = std::fs::read_to_string(&path) {
- if let Ok(accounts) = serde_json::from_str(&content) {
+ if let Ok(mut accounts) = serde_json::from_str::<Vec<AccountInfo>>(&content) {
+ if resolve_account_secrets(&mut accounts) {
+ // A plaintext password just moved into the keyring —
+ // rewrite the file now so it stops living on disk.
+ save_accounts(&accounts);
+ }
return accounts;
}
}
@@ -193,11 +202,53 @@ fn load_accounts() -> Vec<AccountInfo> {
token_expiry: None,
client_id: None,
client_secret: None,
+ keyring_backed: false,
},
]
}
+/// Resolve account passwords through the Secret Service (KeePassXC here).
+/// An empty on-disk password field is filled from the keyring; a plaintext
+/// one is migrated INTO the keyring (returns true so the caller rewrites the
+/// redacted file). In-memory passwords stay resolved for the IMAP/SMTP
+/// workers. The mock account never touches the keyring. If the keyring is
+/// locked, the provider pops its unlock dialog and this blocks until the
+/// user answers — the standard desktop flow.
+fn resolve_account_secrets(accounts: &mut [AccountInfo]) -> bool {
+ let mut migrated = false;
+ for acc in accounts.iter_mut() {
+ if is_mock_account(acc) {
+ continue;
+ }
+ let Ok(entry) = keyring::Entry::new("cce-email", &acc.email) else {
+ continue;
+ };
+ if acc.password.is_empty() {
+ if let Ok(p) = entry.get_password() {
+ acc.password = p;
+ acc.keyring_backed = true;
+ }
+ } else if entry.set_password(&acc.password).is_ok() {
+ acc.keyring_backed = true;
+ migrated = true;
+ }
+ }
+ migrated
+}
+
fn save_accounts(accounts: &[AccountInfo]) {
+ // Keyring-backed passwords never go back to disk.
+ let redacted: Vec<AccountInfo> = accounts
+ .iter()
+ .map(|a| {
+ let mut a = a.clone();
+ if a.keyring_backed {
+ a.password = String::new();
+ }
+ a
+ })
+ .collect();
+ let accounts = &redacted;
let path = get_accounts_path();
if let Ok(content) = serde_json::to_string_pretty(accounts) {
let _ = std::fs::write(&path, content);