//! The security flow: enabling/disabling protection, key derivation, //! and the prompt that drives both. use super::*; use quicksearch_core::security::SALT_LEN; use crate::ui_util::{centered_modal, hint}; /// The two-step security flow: collect a password (enable/change), derive /// its key off the UI thread, then confirm the mandatory index rebuild. /// Disabling skips straight to the confirmation. pub(super) enum SecurityPrompt { SetPassword { pw1: String, pw2: String, remember: bool, change: bool, }, Deriving { rx: mpsc::Receiver, /// Built with the salt the pending key is being derived from, so the /// two always describe each other. new_security: SecurityConfig, }, ConfirmRebuild { new_security: SecurityConfig, new_key: Option, }, } impl Drop for SecurityPrompt { fn drop(&mut self) { if let SecurityPrompt::SetPassword { pw1, pw2, .. } = self { pw1.zeroize(); pw2.zeroize(); } } } /// The show-key flow: confirm the password, re-derive from it, then reveal /// the installed key. Nothing here can change the key or the config. pub(super) enum KeyPrompt { Confirm { pw: String, wrong: bool, }, Deriving { rx: mpsc::Receiver, }, /// The key as displayed: `0x` followed by 64 hex digits. Reveal { display: String, }, } impl Drop for KeyPrompt { fn drop(&mut self) { match self { KeyPrompt::Confirm { pw, .. } => pw.zeroize(), KeyPrompt::Reveal { display } => display.zeroize(), KeyPrompt::Deriving { .. } => {} } } } /// Derive a key off the UI thread. The password is consumed and dropped /// there, so it never outlives the derivation. fn spawn_derive( ctx: &egui::Context, password: Zeroizing, salt: [u8; SALT_LEN], ) -> mpsc::Receiver { let (tx, rx) = mpsc::channel(); let repaint = ctx.clone(); std::thread::spawn(move || { let key = derive_key(&password, &salt); drop(password); let _ = tx.send(key); repaint.request_repaint(); }); rx } /// Paint the spinner shown while a derivation runs. Not `centered_modal`: /// this one hides its title bar. fn deriving_window(ctx: &egui::Context) { egui::Window::new("Deriving key") .collapsible(false) .resizable(false) .title_bar(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { ui.horizontal(|ui| { ui.spinner(); ui.label("Deriving key…"); }); }); ctx.request_repaint_after(Duration::from_millis(100)); } impl QuickSearchApp { /// Route a click in the Settings tab's Security block. Keychain /// toggles act immediately; everything else opens the two-step flow. pub(super) fn handle_security_action(&mut self, action: SecurityAction) { match action { SecurityAction::Enable | SecurityAction::ChangePassword => { self.security_prompt = Some(SecurityPrompt::SetPassword { pw1: String::new(), pw2: String::new(), remember: self.cfg.security.use_keychain, change: matches!(action, SecurityAction::ChangePassword), }); } SecurityAction::Disable => { self.security_prompt = Some(SecurityPrompt::ConfirmRebuild { new_security: SecurityConfig::default(), new_key: None, }); } SecurityAction::ShowKey => { self.key_prompt = Some(KeyPrompt::Confirm { pw: String::new(), wrong: false, }); } SecurityAction::SetKeychain(remember) => { let db_path = self.cfg.resolved_database_path(); if remember { match db::process_key_hex() { Some(hex) => { if let Err(e) = keychain::store_key(&db_path.to_string_lossy(), &hex) { self.config_error = Some(e); return; // preference not saved either } } None => { // Unreachable while protected — the gate always // installs a key before the app starts. self.config_error = Some("no key to remember; restart and unlock first".to_string()); return; } } } else if let Err(e) = keychain::delete_key(&db_path.to_string_lossy()) { // The preference must not claim the key is gone while it // is still on the keychain. self.config_error = Some(e); return; } self.cfg.security.use_keychain = remember; if let Err(e) = self.cfg.save() { self.config_error = Some(e); } } } } /// Render the active security flow (drawn with the other modals). pub(super) fn security_prompt_ui(&mut self, ctx: &egui::Context) { let Some(prompt) = &mut self.security_prompt else { return; }; match prompt { SecurityPrompt::SetPassword { pw1, pw2, remember, change, } => { let title = if *change { "Change password" } else { "Enable password protection" }; let buttons = centered_modal(ctx, title, |ui| { ui.set_max_width(360.0); ui.add( egui::TextEdit::singleline(pw1) .id(egui::Id::new("security-pw1")) .password(true) .hint_text("Password") .desired_width(240.0), ); ui.add( egui::TextEdit::singleline(pw2) .id(egui::Id::new("security-pw2")) .password(true) .hint_text("Confirm password") .desired_width(240.0), ); ui.checkbox(remember, "Remember on this device") .on_hover_text( "Stores the derived key (not the password) in the OS \ keychain and skips the startup prompt.", ); if !pw1.is_empty() && !pw2.is_empty() && pw1 != pw2 { ui.colored_label(ui.visuals().error_fg_color, "Passwords do not match."); } ui.horizontal(|ui| { let ok = !pw1.is_empty() && pw1 == pw2; let submit = ui.add_enabled(ok, egui::Button::new("Continue")).clicked(); (submit, ui.button("Cancel").clicked()) }) .inner }); let (submit, cancel) = buttons.unwrap_or((false, false)); if cancel { self.security_prompt = None; // Drop impl zeroizes purge_security_field_state(ctx); } else if submit { let password = Zeroizing::new(std::mem::take(pw1)); pw2.zeroize(); let salt = generate_salt(); let new_security = SecurityConfig { password_protected: true, salt: Some(salt_to_hex(&salt)), use_keychain: *remember, }; purge_security_field_state(ctx); let rx = spawn_derive(ctx, password, salt); self.security_prompt = Some(SecurityPrompt::Deriving { rx, new_security }); } } SecurityPrompt::Deriving { rx, new_security } => match rx.try_recv() { Ok(key) => { self.security_prompt = Some(SecurityPrompt::ConfirmRebuild { new_security: new_security.clone(), new_key: Some(key), }); } Err(mpsc::TryRecvError::Empty) => deriving_window(ctx), Err(mpsc::TryRecvError::Disconnected) => { self.config_error = Some("key derivation thread died".to_string()); self.security_prompt = None; } }, SecurityPrompt::ConfirmRebuild { new_security, new_key, } => { let title = match (new_key.is_some(), self.cfg.security.password_protected) { (false, _) => "Disable password protection?", (true, false) => "Enable password protection?", (true, true) => "Change password?", }; let buttons = centered_modal(ctx, title, |ui| { ui.set_max_width(420.0); ui.label( "Changing index encryption deletes the index and \ re-indexes everything. Searches return incomplete \ results until the rebuild finishes. Your files are \ not touched.", ); ui.horizontal(|ui| { let rebuild = egui::RichText::new("Delete & rebuild index") .color(ui.visuals().error_fg_color); (ui.button(rebuild).clicked(), ui.button("Cancel").clicked()) }) .inner }); let (confirm, cancel) = buttons.unwrap_or((false, false)); if cancel { self.security_prompt = None; } else if confirm { let new_security = new_security.clone(); let new_key = new_key.clone(); self.security_prompt = None; self.apply_security_change(new_security, new_key); } } } } /// Render the show-key flow (drawn with the other modals). Only ever /// open while protection is on, so a salt and a process key both exist. pub(super) fn key_prompt_ui(&mut self, ctx: &egui::Context) { let Some(prompt) = &mut self.key_prompt else { return; }; match prompt { KeyPrompt::Confirm { pw, wrong } => { let (submit, cancel) = confirm_key_modal(ctx, pw, *wrong); if cancel { self.key_prompt = None; // Drop impl zeroizes purge_security_field_state(ctx); } else if submit { let password = Zeroizing::new(std::mem::take(pw)); purge_security_field_state(ctx); match self.cfg.security.salt_bytes() { Ok(salt) => { let rx = spawn_derive(ctx, password, salt); self.key_prompt = Some(KeyPrompt::Deriving { rx }); } Err(e) => { self.config_error = Some(e); self.key_prompt = None; } } } } KeyPrompt::Deriving { rx } => match rx.try_recv() { Ok(key) => match db::process_key_hex() { // What is shown is the installed key, not the derived // one: it is the key that actually opens the index. Some(installed) => match reveal_display(&installed, &key.to_hex()) { Some(display) => { self.key_prompt = Some(KeyPrompt::Reveal { display }); } None => { self.key_prompt = Some(KeyPrompt::Confirm { pw: String::new(), wrong: true, }); } }, None => { // Unreachable while protected — the gate always // installs a key before the app starts. self.config_error = Some("no key installed; restart and unlock first".to_string()); self.key_prompt = None; } }, Err(mpsc::TryRecvError::Empty) => deriving_window(ctx), Err(mpsc::TryRecvError::Disconnected) => { self.config_error = Some("key derivation thread died".to_string()); self.key_prompt = None; } }, KeyPrompt::Reveal { display } => { let (copy, close) = reveal_key_modal(ctx, display); if copy { ctx.copy_text(display.clone()); } if close { self.key_prompt = None; // Drop impl zeroizes } } } } /// Commit a confirmed security change: config, keychain, process key — /// in that order, before the rebuild so the fresh index is created /// under the new key (or none). fn apply_security_change(&mut self, new_security: SecurityConfig, new_key: Option) { let db_path = self .cfg .resolved_database_path() .to_string_lossy() .into_owned(); let previous_security = self.cfg.security.clone(); self.cfg.security = new_security; if let Err(e) = self.cfg.save() { // Fail closed, exactly as `SecurityAction::SetKeychain` does: the // salt this change depends on lives only in that file. Carrying on // would install the new key and rebuild the index under it while // the config on disk still describes the old state — an index // encrypted with a salt that reached no disk, which no password // can open afterwards. Put the config back and stop. self.cfg.security = previous_security; self.config_error = Some(e); return; } match (&new_key, self.cfg.security.use_keychain) { (Some(key), true) => { if let Err(e) = keychain::store_key(&db_path, &key.to_hex()) { self.config_error = Some(e); } } // Disabling protection, or "remember" off: no stored key may // survive pointing at the previous encryption state. _ => { if let Err(e) = keychain::delete_key(&db_path) { self.config_error = Some(e); } } } db::set_process_key(new_key); self.backend.rebuild_index(); self.dups.state = DupState::NotLoaded; } } /// Id of the show-key confirmation field, shared by the widget and the /// purge below. const SHOW_KEY_FIELD: &str = "show-key-pw"; /// Drop egui's retained text-field state (buffer + undo history) for the /// password dialog fields. fn purge_security_field_state(ctx: &egui::Context) { ctx.data_mut(|d| { d.remove::(egui::Id::new("security-pw1")); d.remove::(egui::Id::new("security-pw2")); d.remove::(egui::Id::new(SHOW_KEY_FIELD)); }); } /// The display form of the installed key, or `None` when the password the /// user typed does not derive it. Both arguments come from /// [`IndexKey::to_hex`], which is always lowercase, so a plain comparison is /// exact; nothing secret is learned from its timing, since the caller /// already holds the guess. fn reveal_display(installed_hex: &str, derived_hex: &str) -> Option { (installed_hex == derived_hex).then(|| format!("0x{}", installed_hex)) } /// Paint the password confirmation; `(submit, cancel)` from its buttons. /// Free, like the reveal below, so both halves of the flow can be rendered /// against a bare context. fn confirm_key_modal(ctx: &egui::Context, pw: &mut String, wrong: bool) -> (bool, bool) { centered_modal(ctx, "Show database key", |ui| { ui.set_max_width(360.0); ui.label( "Confirm your password to show the raw key the index is \ encrypted with.", ); let field = ui.add( egui::TextEdit::singleline(pw) .id(egui::Id::new(SHOW_KEY_FIELD)) .password(true) .hint_text("Password") .desired_width(240.0), ); // On open, and again after a wrong attempt. Never steals focus from // something the user moved to themselves. if ui.memory(|m| m.focused().is_none()) { field.request_focus(); } if wrong { ui.colored_label(ui.visuals().error_fg_color, "That password is not correct."); } ui.horizontal(|ui| { let ok = !pw.is_empty(); // Enter in the field submits, like the unlock screen. let entered = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); let submit = ui.add_enabled(ok, egui::Button::new("Show key")).clicked() || (ok && entered); (submit, ui.button("Cancel").clicked()) }) .inner }) .unwrap_or((false, false)) } /// Paint the revealed key; `(copy, close)` from its buttons. A free function /// rather than a method so it can be rendered against a bare context. fn reveal_key_modal(ctx: &egui::Context, display: &str) -> (bool, bool) { centered_modal(ctx, "Database key", |ui| { ui.set_max_width(420.0); ui.label( "This is the SQLCipher raw key for the index. Anyone holding it can \ read the index without the password.", ); ui.add_space(6.0); ui.horizontal_wrapped(|ui| { ui.label(egui::RichText::new(display).monospace()); }); ui.add_space(6.0); ui.label(hint( "Other SQLCipher tools take the key in this form. A copy stays on the \ clipboard until something else replaces it.", )); ui.add_space(6.0); ui.horizontal(|ui| (ui.button("Copy").clicked(), ui.button("Close").clicked())) .inner }) .unwrap_or((false, false)) } #[cfg(test)] #[path = "security_tests.rs"] mod tests;