Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions tauri-app/backend/src/keychain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ const VAULT_ACCOUNT: &str = "vault";
struct Vault {
/// pubkey -> private key
keys: HashMap<String, String>,
/// The public key of the account the user last made active. Persisted so
/// the app restores the same account on restart instead of picking an
/// arbitrary entry from `keys` (a HashMap has no stable order). Optional
/// and `#[serde(default)]` for backward compatibility with older vaults
/// written before this field existed.
#[serde(default)]
active: Option<String>,
}

/// Manages private keys in the OS keychain with an in-memory cache.
Expand Down Expand Up @@ -115,6 +122,11 @@ impl KeychainManager {
pub fn delete_key(&self, public_key: &str) -> Result<(), String> {
let mut vault = self.load_vault()?;
vault.keys.remove(public_key);
// Drop the active pointer if it referenced the key we just removed,
// so a restart doesn't try to restore a deleted account.
if vault.active.as_deref() == Some(public_key) {
vault.active = None;
}
self.save_vault(&vault)
}

Expand All @@ -123,6 +135,30 @@ impl KeychainManager {
Ok(vault.keys.keys().cloned().collect())
}

/// Persist which account is active so it survives an app restart. Only
/// records the pointer if the key exists in the vault.
pub fn set_active(&self, public_key: &str) -> Result<(), String> {
let mut vault = self.load_vault()?;
if !vault.keys.contains_key(public_key) {
return Err(format!(
"Cannot set active account: no key in vault for {}",
&public_key[..20.min(public_key.len())]
));
}
vault.active = Some(public_key.to_string());
self.save_vault(&vault)
}

/// The persisted active account, if any. Returns `None` when the pointer
/// is unset or refers to a key that no longer exists in the vault.
pub fn get_active(&self) -> Result<Option<String>, String> {
let vault = self.load_vault()?;
match vault.active {
Some(ref pk) if vault.keys.contains_key(pk) => Ok(Some(pk.clone())),
_ => Ok(None),
}
}

#[cfg(not(target_os = "android"))]
pub fn clear_all(&self) -> Result<(), String> {
let entry = Self::entry().map_err(|e| format!("Keychain entry error: {}", e))?;
Expand Down Expand Up @@ -249,3 +285,42 @@ mod android {
})
}
}

#[cfg(test)]
mod tests {
use super::*;

// Serde-only tests for the Vault format. These deliberately avoid the OS
// keychain (no D-Bus / Secret Service needed) so they run in headless CI.

/// Vaults written before the `active` field existed (only `keys`) must
/// still deserialize, defaulting `active` to `None`.
#[test]
fn test_vault_deserializes_legacy_json_without_active() {
let legacy = r#"{"keys":{"npub_abc":"nsec_abc"}}"#;
let vault: Vault = serde_json::from_str(legacy).unwrap();
assert_eq!(vault.keys.get("npub_abc").map(String::as_str), Some("nsec_abc"));
assert_eq!(vault.active, None);
}

/// The active pointer round-trips through serialization.
#[test]
fn test_vault_roundtrips_active() {
let mut vault = Vault::default();
vault.keys.insert("npub_abc".to_string(), "nsec_abc".to_string());
vault.active = Some("npub_abc".to_string());

let json = serde_json::to_string(&vault).unwrap();
let restored: Vault = serde_json::from_str(&json).unwrap();
assert_eq!(restored.active.as_deref(), Some("npub_abc"));
assert_eq!(restored.keys, vault.keys);
}

/// A freshly created vault has no active account.
#[test]
fn test_vault_default_has_no_active() {
let vault = Vault::default();
assert!(vault.active.is_none());
assert!(vault.keys.is_empty());
}
}
45 changes: 37 additions & 8 deletions tauri-app/backend/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1210,28 +1210,57 @@ fn keychain_list_accounts(state: tauri::State<AppState>) -> Result<Vec<AccountIn
}).collect())
}

/// Load the given account's key from the keychain into in-memory state
/// (`current_keys` + `current_private_key`) so it becomes the active account
/// for signing, AUTH, and email sync. Does not touch the persisted active
/// pointer or the IMAP watcher — callers handle those as needed.
fn activate_account_in_memory(public_key: &str, state: &AppState) -> Result<(), String> {
let private_key = state.keychain.get_key(public_key)?
.ok_or_else(|| format!("No key found in keychain for {}", &public_key[..20.min(public_key.len())]))?;
let secret_key = nostr_sdk::prelude::SecretKey::from_bech32(&private_key).map_err(|e| e.to_string())?;
let keys = nostr_sdk::prelude::Keys::new(secret_key);
*state.current_keys.lock().unwrap() = Some(keys);
*state.current_private_key.lock().unwrap() = Some(private_key);
Ok(())
}

#[tauri::command]
fn keychain_set_active_account(public_key: String, state: tauri::State<AppState>) -> Result<(), String> {
println!("[RUST] keychain_set_active_account called for: {}...", &public_key[..20.min(public_key.len())]);
// Switching the active account: tear down the previous account's IMAP IDLE
// watcher and pooled connections so nothing credential-bound to the old
// user is reused. The frontend restarts IDLE for the new account.
state.stop_idle_watcher();
let private_key = state.keychain.get_key(&public_key)?
.ok_or_else(|| format!("No key found in keychain for {}", &public_key[..20.min(public_key.len())]))?;
let secret_key = nostr_sdk::prelude::SecretKey::from_bech32(&private_key).map_err(|e| e.to_string())?;
let keys = nostr_sdk::prelude::Keys::new(secret_key);
*state.current_keys.lock().unwrap() = Some(keys);
*state.current_private_key.lock().unwrap() = Some(private_key);
activate_account_in_memory(&public_key, &state)?;
// Persist the choice so the same account is restored on the next app
// launch. Non-fatal if it fails — the account is still active this session.
if let Err(e) = state.keychain.set_active(&public_key) {
println!("[RUST] Warning: failed to persist active account: {}", e);
}
Ok(())
}

#[tauri::command]
fn keychain_get_active_account(state: tauri::State<AppState>) -> Result<Option<String>, String> {
let pubkey = state.current_private_key.lock().unwrap()
// Fast path: an account is already loaded in memory this session.
let in_memory = state.current_private_key.lock().unwrap()
.as_ref()
.and_then(|pk| crypto::get_public_key_from_private(pk).ok());
Ok(pubkey)
if in_memory.is_some() {
return Ok(in_memory);
}

// Cold start (e.g. after an app restart): nothing is loaded yet. Restore
// the account the user last made active from the persisted vault pointer,
// loading its key into memory so the rest of the app sees it as active.
match state.keychain.get_active()? {
Some(pubkey) => {
activate_account_in_memory(&pubkey, &state)?;
println!("[RUST] Restored persisted active account: {}...", &pubkey[..20.min(pubkey.len())]);
Ok(Some(pubkey))
}
None => Ok(None),
}
}

#[tauri::command]
Expand Down
Loading