//! Startup gate for password-protected indexes. //! //! [`Gate`] wraps the real app: while locked it renders a full-window //! unlock screen and starts none of the backend (no coordinator, no //! watcher, no database opens). Only after the password verifies — or the //! keychain supplied a working key before the window even opened — is //! [`QuickSearchApp`] constructed. //! //! Password hygiene: the typed password moves into a [`Zeroizing`] buffer //! on submit, travels to a worker thread that derives the key and drops //! it, and the visible text-field state (including egui's undo buffer) is //! purged. Only the derived key crosses the channel back. use std::sync::mpsc; use quicksearch_core::config::{Config, SecurityConfig}; use quicksearch_core::db; use quicksearch_core::security::{derive_key, IndexKey}; use zeroize::{Zeroize, Zeroizing}; use crate::app::QuickSearchApp; use crate::keychain; /// Where this session's index key came from. /// /// The app needs it for anything that *refers* to the key: telling someone /// "the password you just entered" is wrong when they never typed one, because /// the keychain answered before the window opened. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum KeySource { /// The index is not password-protected. Unprotected, /// Typed at the unlock prompt during this session. Prompt, /// Supplied by the OS keychain, with no prompt shown. Keychain, } /// The application shell handed to eframe: locked (unlock screen) or /// running (the real app). pub enum Gate { Locked(UnlockScreen), Running(Box), } impl Gate { /// Start unlocked: protection is off, or the keychain already /// provided a verified key. pub fn running( ctx: &egui::Context, cfg: Config, config_error: Option, initial_query: Option, key_source: KeySource, ) -> Result { QuickSearchApp::new(ctx, cfg, config_error, initial_query, key_source) .map(|app| Gate::Running(Box::new(app))) } pub fn locked( cfg: Config, config_error: Option, initial_query: Option, ) -> Gate { Gate::Locked(UnlockScreen::new(cfg, config_error, initial_query)) } } impl eframe::App for Gate { fn update(&mut self, ctx: &egui::Context, frame: &mut eframe::Frame) { match self { Gate::Running(app) => app.update(ctx, frame), Gate::Locked(screen) => { if let Some(app) = screen.update(ctx) { *self = Gate::Running(Box::new(app)); } } } } fn on_exit(&mut self, gl: Option<&eframe::glow::Context>) { if let Gate::Running(app) = self { app.on_exit(gl); } } } /// Try to unlock with the keychain before any window exists. `true` means /// the process key is installed and verified — skip the prompt entirely. pub fn try_keychain_unlock(cfg: &Config) -> bool { if !cfg.security.use_keychain || cfg.security.salt_bytes().is_err() { return false; } let db_path = cfg.resolved_database_path(); let hex = match keychain::load_key(&db_path.to_string_lossy()) { Ok(Some(hex)) => hex, Ok(None) => return false, Err(e) => { eprintln!("warning: {}", e); return false; } }; let Ok(key) = IndexKey::from_hex(&hex) else { return false; }; db::set_process_key(Some(key)); match db::verify_process_key(&db_path.to_string_lossy()) { Ok(()) => true, Err(_) => { // Stale entry or missing/foreign database file: fall back to // the prompt with a clean slate. db::set_process_key(None); false } } } /// What the unlock screen is being used for. enum Mode { /// An index exists: the password must open it. Unlock, /// Protection is on but no index file exists yet, so the typed password /// becomes the one the new index is built under. /// /// Still not a *new* password: this mode is only reachable with a salt /// already in the config, so one was chosen previously and this is the /// user re-entering it. Choosing a genuinely new password happens in the /// Options window, which does its own confirmation. Create, /// `password_protected = true` but the salt is missing or corrupt; no /// password can help. Only the reset escape hatch applies. BrokenSalt(String), } pub struct UnlockScreen { cfg: Config, config_error: Option, initial_query: Option, mode: Mode, password: String, remember: bool, error: Option, /// Put the caret in the password field on the next frame. Set once at /// startup and again after a failed attempt, so the user can retype /// straight away — but *not* every frame: re-focusing unconditionally /// traps the caret, and nothing else on the screen can be tabbed to or /// clicked into. focus_password: bool, /// In-flight Argon2 derivation (+ verification) on a worker thread. job: Option>>, forgot_confirm: bool, } impl UnlockScreen { fn new( cfg: Config, config_error: Option, initial_query: Option, ) -> UnlockScreen { let mode = match cfg.security.salt_bytes() { Err(e) => Mode::BrokenSalt(e), Ok(_) => { if cfg.resolved_database_path().exists() { Mode::Unlock } else { Mode::Create } } }; let remember = cfg.security.use_keychain; UnlockScreen { cfg, config_error, initial_query, mode, password: String::new(), remember, error: None, focus_password: true, job: None, forgot_confirm: false, } } /// Render one frame; `Some(app)` when the gate opens. fn update(&mut self, ctx: &egui::Context) -> Option { if let Some(result) = self.poll_job() { match result { Ok(key) => return self.unlocked(ctx, key), Err(e) => { self.error = Some(if e.starts_with(db::KEY_MISMATCH_PREFIX) { "Wrong password.".to_string() } else { e }); // The field was cleared on submit, so put the caret back // in it rather than making the user click before retrying. self.focus_password = true; } } } let mut submitted = false; egui::CentralPanel::default().show(ctx, |ui| { ui.vertical_centered(|ui| { ui.add_space(ui.available_height() * 0.25); ui.heading("QuickSearch"); ui.add_space(12.0); match &self.mode { Mode::BrokenSalt(reason) => { ui.colored_label(ui.visuals().error_fg_color, reason); ui.label( egui::RichText::new( "The index cannot be unlocked with this configuration.", ) .small(), ); return; } Mode::Unlock => { ui.label("The search index is password-protected."); } Mode::Create => { ui.label("Password protection is enabled, but no index exists yet."); ui.label( egui::RichText::new( "The new index will be encrypted with the password you \ enter here.", ) .small() .weak(), ); } } ui.add_space(8.0); let busy = self.job.is_some(); ui.add_enabled_ui(!busy, |ui| { let field = ui.add( egui::TextEdit::singleline(&mut self.password) .id(pw_field_id()) .password(true) .hint_text("Password") .desired_width(240.0), ); ui.add_space(4.0); ui.checkbox(&mut self.remember, "Remember on this device") .on_hover_text( "Stores the derived key (not the password) in the OS \ keychain and skips this prompt.", ); ui.add_space(8.0); let label = match self.mode { Mode::Unlock => "Unlock", _ => "Create index", }; let clicked = ui.button(label).clicked(); let entered = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)); submitted = clicked || entered; if self.focus_password && !busy { field.request_focus(); self.focus_password = false; } }); if busy { ui.add_space(6.0); ui.spinner(); ui.label(egui::RichText::new("Deriving key…").small().weak()); ctx.request_repaint_after(std::time::Duration::from_millis(100)); } if let Some(error) = &self.error { ui.add_space(6.0); ui.colored_label(ui.visuals().error_fg_color, error); } }); ui.with_layout(egui::Layout::bottom_up(egui::Align::Center), |ui| { ui.add_space(16.0); if !matches!(self.mode, Mode::Create) && ui.small_button("Forgot password…").clicked() { self.forgot_confirm = true; } }); }); if submitted && self.job.is_none() { self.submit(ctx); } if self.forgot_confirm { if let Some(app) = self.forgot_confirm_ui(ctx) { return Some(app); } } None } fn poll_job(&mut self) -> Option> { let rx = self.job.as_ref()?; match rx.try_recv() { Ok(result) => { self.job = None; Some(result) } Err(mpsc::TryRecvError::Empty) => None, Err(mpsc::TryRecvError::Disconnected) => { self.job = None; Some(Err("key derivation thread died".to_string())) } } } /// Move the typed password off to the derivation thread and scrub the /// UI-side buffers. fn submit(&mut self, ctx: &egui::Context) { self.error = None; if matches!(self.mode, Mode::Create) { if self.password.is_empty() { self.error = Some("The password may not be empty.".to_string()); return; } } let Ok(salt) = self.cfg.security.salt_bytes() else { return; // BrokenSalt mode never reaches submit }; let password = Zeroizing::new(std::mem::take(&mut self.password)); purge_text_state(ctx, pw_field_id()); let verify_against = match self.mode { Mode::Unlock => Some(self.cfg.resolved_database_path()), _ => None, }; let (tx, rx) = mpsc::channel(); let repaint = ctx.clone(); std::thread::spawn(move || { let key = derive_key(&password, &salt); drop(password); db::set_process_key(Some(key.clone())); let result = match &verify_against { Some(db_path) => db::verify_process_key(&db_path.to_string_lossy()).map(|()| key), None => Ok(key), }; let _ = tx.send(result); repaint.request_repaint(); }); self.job = Some(rx); } /// The key verified (or a fresh index is being created): remember it if /// asked, persist the keychain preference, and start the real app. fn unlocked(&mut self, ctx: &egui::Context, key: IndexKey) -> Option { let db_path = self.cfg.resolved_database_path(); if self.remember { if let Err(e) = keychain::store_key(&db_path.to_string_lossy(), &key.to_hex()) { // Non-fatal: unlock proceeds, the preference just can't // stick. Surface it in the running app's banner. self.config_error = Some(e); } } else { keychain::delete_key(&db_path.to_string_lossy()); } if self.cfg.security.use_keychain != self.remember { self.cfg.security.use_keychain = self.remember; if let Err(e) = self.cfg.save() { self.config_error = Some(e); } } self.launch(ctx) } /// Construct the real app; on failure stay locked and show why. fn launch(&mut self, ctx: &egui::Context) -> Option { // Reaching here means either the password was just typed, or the // "forgot password" path disabled protection on the way. The config // says which, and no caller has to remember to pass it. let key_source = if self.cfg.security.password_protected { KeySource::Prompt } else { KeySource::Unprotected }; match QuickSearchApp::new( ctx, self.cfg.clone(), self.config_error.take(), self.initial_query.take(), key_source, ) { Ok(app) => Some(app), Err(e) => { self.error = Some(format!("Failed to start: {}", e)); None } } } /// "Forgot password" confirmation. The index is derived data: deleting /// it and disabling protection loses nothing but time. `Some(app)` when /// the reset happened and the app launched unprotected. fn forgot_confirm_ui(&mut self, ctx: &egui::Context) -> Option { let mut launched = None; let mut close = false; egui::Window::new("Reset the index?") .collapsible(false) .resizable(false) .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) .show(ctx, |ui| { ui.set_max_width(420.0); ui.label( "Without the password the index cannot be read. This deletes \ the index and turns password protection off. Your files are \ not touched; the index is rebuilt by indexing again.", ); ui.horizontal(|ui| { if ui .button( egui::RichText::new("Delete index & disable protection") .color(ui.visuals().error_fg_color), ) .clicked() { let db_path = self.cfg.resolved_database_path(); if let Err(e) = delete_index_files(&db_path) { self.error = Some(e); } else { keychain::delete_key(&db_path.to_string_lossy()); db::set_process_key(None); self.cfg.security = SecurityConfig::default(); if let Err(e) = self.cfg.save() { self.config_error = Some(e); } launched = self.launch(ctx); } close = true; } if ui.button("Cancel").clicked() { close = true; } }); }); if close { self.forgot_confirm = false; } launched } } impl Drop for UnlockScreen { fn drop(&mut self) { self.password.zeroize(); } } fn pw_field_id() -> egui::Id { egui::Id::new("unlock-password") } /// Drop egui's retained state for a password field — its text buffer and /// undo history — so the plaintext doesn't outlive the submit. fn purge_text_state(ctx: &egui::Context, id: egui::Id) { ctx.data_mut(|d| d.remove::(id)); } /// Delete the index and its WAL/SHM/journal sidecars. No coordinator /// exists while the gate is locked, so plain filesystem deletes are safe. fn delete_index_files(db_path: &std::path::Path) -> Result<(), String> { match quicksearch_core::platform::remove_file_retrying(db_path) { Ok(()) => {} Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} Err(e) => return Err(format!("Failed to delete {}: {}", db_path.display(), e)), } for suffix in ["-wal", "-shm", "-journal"] { let name = format!( "{}{}", db_path.file_name().and_then(|s| s.to_str()).unwrap_or(""), suffix ); let _ = quicksearch_core::platform::remove_file_retrying(&db_path.with_file_name(name)); } Ok(()) } #[cfg(test)] mod tests { use super::*; /// A protected config whose salt parses, so the screen lands in a real /// password mode rather than `BrokenSalt`. fn locked_config() -> Config { let mut cfg = Config::default(); cfg.security.password_protected = true; cfg.security.salt = Some("0f1e2d3c4b5a69788796a5b4c3d2e1f0".to_string()); cfg.paths.database_path = std::env::temp_dir() .join(format!("qs-unlock-test-{}.sqlite", std::process::id())) .to_string_lossy() .into_owned(); cfg } fn frame(ctx: &egui::Context, screen: &mut UnlockScreen) { let input = egui::RawInput { screen_rect: Some(egui::Rect::from_min_size( egui::Pos2::ZERO, egui::vec2(900.0, 600.0), )), ..Default::default() }; let _ = ctx.run(input, |ctx| { // `update` only builds the app on a successful unlock, which needs // a derived key — so with nothing submitted this stays on screen. assert!(screen.update(ctx).is_none()); }); } /// The caret starts in the password field, and — the regression — can then /// leave it. /// /// The screen used to call `request_focus()` on every frame the field did /// not have focus, which yanked the caret back the instant anything else /// took it. Nothing else on the screen could be tabbed to or clicked into. #[test] fn the_password_field_takes_focus_once_and_then_releases_it() { let ctx = egui::Context::default(); let mut screen = UnlockScreen::new(locked_config(), None, None); frame(&ctx, &mut screen); assert!( ctx.memory(|m| m.has_focus(pw_field_id())), "the caret should start in the password field" ); // Whatever the user clicks or tabs to next takes focus away. ctx.memory_mut(|m| m.surrender_focus(pw_field_id())); frame(&ctx, &mut screen); assert!( !ctx.memory(|m| m.has_focus(pw_field_id())), "focus was stolen back; the caret is trapped in the password field" ); // And it stays released across further frames. frame(&ctx, &mut screen); assert!(!ctx.memory(|m| m.has_focus(pw_field_id()))); } /// A failed attempt is the one case that *should* re-focus: the field was /// cleared on submit, so the user would otherwise have to click before /// retyping. #[test] fn a_failed_attempt_puts_the_caret_back() { let ctx = egui::Context::default(); let mut screen = UnlockScreen::new(locked_config(), None, None); frame(&ctx, &mut screen); ctx.memory_mut(|m| m.surrender_focus(pw_field_id())); frame(&ctx, &mut screen); assert!(!ctx.memory(|m| m.has_focus(pw_field_id()))); screen.focus_password = true; // what the error path sets frame(&ctx, &mut screen); assert!(ctx.memory(|m| m.has_focus(pw_field_id()))); } }