quick_search/crates/quicksearch-gui/src/app/security.rs

473 lines
19 KiB
Rust

//! 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<IndexKey>,
/// 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<IndexKey>,
},
}
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<IndexKey>,
},
/// 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<String>,
salt: [u8; SALT_LEN],
) -> mpsc::Receiver<IndexKey> {
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<IndexKey>) {
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::text_edit::TextEditState>(egui::Id::new("security-pw1"));
d.remove::<egui::text_edit::TextEditState>(egui::Id::new("security-pw2"));
d.remove::<egui::text_edit::TextEditState>(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<String> {
(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;