473 lines
19 KiB
Rust
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;
|