From 2d0829e204ce04c5c727346fb82f7005e54afd5c Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Mon, 19 Oct 2026 17:28:39 +0600 Subject: [PATCH 1/4] Move to cipher sqlite --- db/Cargo.toml | 3 ++- db/src/lib.rs | 70 ++++++++++++++++++++++++++++++++++++++++++++------- 2 files changed, 63 insertions(+), 10 deletions(-) diff --git a/db/Cargo.toml b/db/Cargo.toml index 85d5eb07..6d770686 100644 --- a/db/Cargo.toml +++ b/db/Cargo.toml @@ -12,7 +12,8 @@ anyhow.workspace = true chrono.workspace = true diesel.workspace = true diesel_migrations = "2.3.2" -libsqlite3-sys = { version = "0.38.2", features = ["bundled"] } +libsqlite3-sys = { version = "0.38.2", features = ["bundled-sqlcipher-vendored-openssl"] } rex-shared.workspace = true strum.workspace = true strum_macros.workspace = true +thiserror.workspace = true diff --git a/db/src/lib.rs b/db/src/lib.rs index 1cdd0829..b4854cc8 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -2,6 +2,7 @@ pub mod models; mod schema; use anyhow::{Result, anyhow}; +use diesel::connection::SimpleConnection; use diesel::prelude::*; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use std::collections::{HashMap, HashSet}; @@ -91,19 +92,70 @@ impl Cache { } } -#[must_use] -pub fn get_connection(db_url: &str) -> SqliteConnection { - let mut conn = - SqliteConnection::establish(db_url).expect("Failed to create connection to database"); +/// Why a database could not be opened +#[derive(Debug, thiserror::Error)] +pub enum OpenError { + /// SQLite could not read the file. With an encrypted database this is the only signal a wrong + /// or missing password gives, and the same goes for a plain database opened with a password + #[error("Wrong password, or the file is not a database")] + NotReadable, + #[error(transparent)] + Other(#[from] anyhow::Error), +} + +/// Quotes text for use inside a SQL string literal +fn sql_quote(text: &str) -> String { + format!("'{}'", text.replace('\'', "''")) +} + +/// Opens the database, unlocking it with `key` if given, then runs pending migrations. A wrong +/// key is reported as [`OpenError::NotReadable`] instead of a panic or a failing later query. +pub fn open_connection(db_url: &str, key: Option<&str>) -> Result { + let mut conn = SqliteConnection::establish(db_url).map_err(|e| OpenError::Other(e.into()))?; + + if let Some(key) = key { + conn.batch_execute(&format!("PRAGMA key = {};", sql_quote(key))) + .map_err(|e| OpenError::Other(e.into()))?; + } + + // Opening never reads the file, so a wrong key only shows on the first real query + if conn + .batch_execute("SELECT count(*) FROM sqlite_master;") + .is_err() + { + return Err(OpenError::NotReadable); + } conn.run_pending_migrations(MIGRATIONS) - .expect("Failed to run database migrations"); + .map_err(|e| OpenError::Other(anyhow!("Failed to run database migrations: {e}")))?; - diesel::sql_query("PRAGMA foreign_keys = ON;") - .execute(&mut conn) - .unwrap(); + conn.batch_execute("PRAGMA foreign_keys = ON;") + .map_err(|e| OpenError::Other(e.into()))?; - conn + Ok(conn) +} + +/// Changes the key of an already unlocked encrypted database +pub fn change_key(conn: &mut SqliteConnection, new_key: &str) -> Result<()> { + conn.batch_execute(&format!("PRAGMA rekey = {};", sql_quote(new_key)))?; + Ok(()) +} + +/// Copies the whole database into a new encrypted file at `target` using `key` +pub fn export_encrypted(conn: &mut SqliteConnection, target: &str, key: &str) -> Result<()> { + conn.batch_execute(&format!( + "ATTACH DATABASE {} AS encrypted KEY {}; + SELECT sqlcipher_export('encrypted'); + DETACH DATABASE encrypted;", + sql_quote(target), + sql_quote(key) + ))?; + Ok(()) +} + +#[must_use] +pub fn get_connection(db_url: &str) -> SqliteConnection { + open_connection(db_url, None).expect("Failed to create connection to database") } #[must_use] From f9ae0d2ae1bdb2edc618284d824742aec16b1420 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Mon, 19 Oct 2026 17:29:36 +0600 Subject: [PATCH 2/4] High level db operations --- Cargo.lock | 24 +++++++ Cargo.toml | 1 + app/Cargo.toml | 2 +- app/src/conn.rs | 136 ++++++++++++++++++++++++++++++++++-- app/src/migration/old_db.rs | 2 +- 5 files changed, 158 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8a95e888..1da434ba 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1331,6 +1331,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", + "openssl-sys", "pkg-config", "vcpkg", ] @@ -1616,6 +1617,28 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.1+3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46eb8fb9fb3b61ce1c0f8a026c4c1a0714d3a9e138e7fbde78753ce2babc3846" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2198,6 +2221,7 @@ dependencies = [ "rex-shared", "strum", "strum_macros", + "thiserror 2.0.21", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 1092cd0d..cd77f433 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,7 @@ rex-db = "0.3.2" rex-shared = "0.3.2" strum = "0.28.0" strum_macros = "0.28" +thiserror = "2.0.21" [patch.crates-io] rex-app = { path = "app" } diff --git a/app/Cargo.toml b/app/Cargo.toml index 7defa162..acce477f 100644 --- a/app/Cargo.toml +++ b/app/Cargo.toml @@ -16,7 +16,7 @@ rex-shared.workspace = true strsim = "0.11.1" strum.workspace = true strum_macros.workspace = true -thiserror = "2.0.21" +thiserror.workspace = true [lints.clippy] too_many_arguments = { level = "allow" } diff --git a/app/src/conn.rs b/app/src/conn.rs index 26997be1..3b44d821 100644 --- a/app/src/conn.rs +++ b/app/src/conn.rs @@ -1,9 +1,13 @@ -use anyhow::{Error, Result}; +use anyhow::{Context, Error, Result, anyhow}; use chrono::{Local, NaiveDate}; -use diesel::{Connection, SqliteConnection}; +use diesel::sql_types::{BigInt, Text}; +use diesel::{Connection, QueryableByName, RunQueryDsl, SqliteConnection, sql_query}; +pub use rex_db::OpenError; use rex_db::models::{Balance, FullTx, NewSearch, NewTx, RecurringTx, Tag, Tx, TxMethod}; pub use rex_db::models::{FetchNature, FullRecurringTx, NewRecurringTx, RecurrenceFrequency}; -use rex_db::{Cache, ConnCache, get_connection, get_connection_no_migrations}; +use rex_db::{ + Cache, ConnCache, change_key, export_encrypted, get_connection_no_migrations, open_connection, +}; use std::collections::{HashMap, HashSet}; use crate::migration::migrate_to_v1; @@ -58,6 +62,8 @@ impl ConnCache for MutDbConn<'_> { pub struct DbConn { pub conn: SqliteConnection, pub cache: Cache, + location: String, + encrypted: bool, } impl ConnCache for DbConn { @@ -73,7 +79,12 @@ impl ConnCache for DbConn { impl DbConn { #[must_use] pub fn new(db_url: &str) -> Self { - let conn = get_connection(db_url); + Self::open(db_url, None).expect("Failed to create connection to database") + } + + /// Opens the database, unlocking it with `key` if it is encrypted. + pub fn open(db_url: &str, key: Option<&str>) -> Result { + let conn = open_connection(db_url, key)?; let mut to_return = DbConn { conn, @@ -82,13 +93,15 @@ impl DbConn { tx_methods: HashMap::new(), details: HashSet::new(), }, + location: db_url.to_string(), + encrypted: key.is_some(), }; to_return.reload_methods(); to_return.reload_tags(); to_return.reload_details(); - to_return + Ok(to_return) } #[must_use] @@ -101,7 +114,81 @@ impl DbConn { tx_methods: HashMap::new(), details: HashSet::new(), }, + location: db_url.to_string(), + encrypted: false, + } + } + + #[must_use] + pub fn is_encrypted(&self) -> bool { + self.encrypted + } + + /// Path of the database file this connection is using + #[must_use] + pub fn location(&self) -> &str { + &self.location + } + + /// Encrypts the database with `password`, or changes the password if it already is. + /// + /// An unencrypted database is copied into a new encrypted file, which is checked to hold + /// the same rows before it replaces the original. Nothing is touched if any step fails. + pub fn set_password(&mut self, password: &str) -> Result<()> { + if password.is_empty() { + return Err(anyhow!("Password cannot be empty")); + } + + if self.encrypted { + change_key(&mut self.conn, password)?; + return Ok(()); + } + + let temp_path = format!("{}.encrypting", self.location); + remove_file_if_exists(&temp_path)?; + + let result = self.encrypt_into(&temp_path, password); + + if result.is_err() { + let _ = std::fs::remove_file(&temp_path); + } + + result + } + + fn encrypt_into(&mut self, temp_path: &str, password: &str) -> Result<()> { + export_encrypted(&mut self.conn, temp_path, password) + .context("Failed to create the encrypted copy")?; + + // Prove the copy can be opened with the password and holds everything before the + // original is replaced + let mut copy = open_connection(temp_path, Some(password)) + .map_err(|e| anyhow!("Encrypted copy could not be reopened: {e}"))?; + + let expected = table_row_counts(&mut self.conn)?; + let actual = table_row_counts(&mut copy)?; + + if expected != actual { + return Err(anyhow!("Encrypted copy does not match the original data")); } + + // The files can't be swapped while either connection holds them open + drop(copy); + let placeholder = SqliteConnection::establish(":memory:")?; + drop(std::mem::replace(&mut self.conn, placeholder)); + + let swapped = std::fs::rename(temp_path, &self.location) + .with_context(|| format!("Failed to replace {}", self.location)); + + // Reopen whichever file is in place so the connection is never left on the placeholder + let key = swapped.is_ok().then_some(password); + self.conn = open_connection(&self.location, key) + .map_err(|e| anyhow!("Failed to reopen the database: {e}"))?; + + swapped?; + self.encrypted = true; + + Ok(()) } pub(crate) fn reload_methods(&mut self) { @@ -576,3 +663,42 @@ impl DbConn { }) } } + +fn remove_file_if_exists(path: &str) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e.into()), + } +} + +/// Row count of every table, to compare two databases that should hold the same data +fn table_row_counts(conn: &mut SqliteConnection) -> Result> { + #[derive(QueryableByName)] + struct TableName { + #[diesel(sql_type = Text)] + name: String, + } + + #[derive(QueryableByName)] + struct Count { + #[diesel(sql_type = BigInt)] + count: i64, + } + + let tables: Vec = sql_query( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%' \ + ORDER BY name", + ) + .load(conn)?; + + let mut counts = Vec::new(); + + for table in tables { + let count: Count = sql_query(format!("SELECT COUNT(*) AS count FROM \"{}\"", table.name)) + .get_result(conn)?; + counts.push((table.name, count.count)); + } + + Ok(counts) +} diff --git a/app/src/migration/old_db.rs b/app/src/migration/old_db.rs index 226e6b35..a38e3249 100644 --- a/app/src/migration/old_db.rs +++ b/app/src/migration/old_db.rs @@ -100,7 +100,7 @@ pub fn start_migration(mut old_db_conn: DbConn, db_conn: &mut DbConn) -> Result< let new_methods = add_new_tx_methods(&tx_methods, db_conn)?; db_conn.cache.new_tx_methods(new_methods); - let DbConn { conn, cache } = db_conn; + let DbConn { conn, cache, .. } = db_conn; let mut start_date = None; From f4e7ccf7d5dfe368496f0d1c11fe0c53403bb75d Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Mon, 19 Oct 2026 17:30:19 +0600 Subject: [PATCH 3/4] Add new popup for db password + unlock ui --- tui/src/config.rs | 41 +++++ tui/src/key_checker/key_handler.rs | 55 ++++++- tui/src/key_checker/popup.rs | 1 + tui/src/page_handler/initializer.rs | 48 +++++- tui/src/page_handler/mod.rs | 3 + tui/src/page_handler/unlock.rs | 162 +++++++++++++++++++ tui/src/pages/popups/choice.rs | 4 +- tui/src/pages/popups/info.rs | 7 + tui/src/pages/popups/mod.rs | 1 + tui/src/pages/popups/models.rs | 234 +++++++++++++++++++++++++++- tui/src/pages/popups/password.rs | 84 ++++++++++ 11 files changed, 626 insertions(+), 14 deletions(-) create mode 100644 tui/src/page_handler/unlock.rs create mode 100644 tui/src/pages/popups/password.rs diff --git a/tui/src/config.rs b/tui/src/config.rs index 8f75cb10..6f815956 100644 --- a/tui/src/config.rs +++ b/tui/src/config.rs @@ -23,6 +23,8 @@ pub struct Config { pub new_location: Option, pub theme_index: Option, pub migration_v1_done: Option, + /// Whether the database is encrypted and a password is asked on startup + pub db_encrypted: Option, } impl Config { @@ -39,6 +41,7 @@ impl Config { location: target_dir, theme_index: Some(0), migration_v1_done: None, + db_encrypted: None, }); } @@ -160,6 +163,11 @@ impl Config { } } + pub fn set_db_encrypted(&mut self, encrypted: bool) -> Result<()> { + self.db_encrypted = Some(encrypted); + self.save_config() + } + pub fn set_migration_v1_done(&mut self) -> Result<()> { self.migration_v1_done = Some(true); self.save_config() @@ -173,6 +181,7 @@ pub fn migrate_config(config_path: &PathBuf) -> Result<()> { location: PathBuf::new(), theme_index: Some(0), migration_v1_done: None, + db_encrypted: None, }; let mut backup_path = config_path.to_owned(); @@ -237,3 +246,35 @@ pub fn migrate_config(config_path: &PathBuf) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_from_before_encryption_loads_as_not_encrypted() { + let config: Config = serde_json::from_str( + r#"{"backup_db_path":null,"new_location":null,"theme_index":1,"migration_v1_done":true}"#, + ) + .unwrap(); + + assert_eq!(config.db_encrypted, None); + assert_eq!(config.migration_v1_done, Some(true)); + } + + #[test] + fn encrypted_flag_is_saved_and_loaded() { + let dir = std::env::temp_dir().join("rex_config_encrypted_test"); + fs::create_dir_all(&dir).unwrap(); + + let mut config = Config::get_config(&dir.join("rex.sqlite")).unwrap(); + assert_eq!(config.db_encrypted, None); + + config.set_db_encrypted(true).unwrap(); + + let loaded = Config::get_config(&dir.join("rex.sqlite")).unwrap(); + assert_eq!(loaded.db_encrypted, Some(true)); + + fs::remove_dir_all(dir).unwrap(); + } +} diff --git a/tui/src/key_checker/key_handler.rs b/tui/src/key_checker/key_handler.rs index 71f126d9..e804fcef 100644 --- a/tui/src/key_checker/key_handler.rs +++ b/tui/src/key_checker/key_handler.rs @@ -5,6 +5,7 @@ use rex_app::ui_helper::{DateType, StepType}; use rex_app::views::{ActivityView, ChartView, FullSummary, SearchView, SummaryView, TxViewGroup}; use std::collections::HashMap; use std::fmt::Write; +use std::path::PathBuf; use crate::config::Config; use crate::outputs::HandlingOutput; @@ -15,8 +16,8 @@ use crate::page_handler::{ }; use crate::pages::{ ACTIVITY_TABLE_ID, ChoicePopupState, ConfigChoices, DeletionChoices, HOME_TABLE_ID, - InfoPopupState, MovementDirection, NewPathChoices, PopupType, RECURRING_TABLE_ID, - SEARCH_TABLE_ID, SUMMARY_TABLE_ID, + InfoPopupState, MovementDirection, NewPathChoices, PasswordOutcome, PopupType, + RECURRING_TABLE_ID, SEARCH_TABLE_ID, SUMMARY_TABLE_ID, }; use crate::theme::Theme; use crate::tx_handler::TxData; @@ -282,12 +283,12 @@ impl<'a> InputKeyHandler<'a> { let status = InfoPopupState::RepositionHelp; *self.popup_status = PopupType::new_info(status); } - PopupType::Input(_) => unreachable!(), + PopupType::Input(_) | PopupType::Password(_) => unreachable!(), } } pub fn do_config_popup(&mut self) { - *self.popup_status = PopupType::new_choice_config(self.theme); + *self.popup_status = PopupType::new_choice_config(self.theme, self.conn.is_encrypted()); } /// Turns on deletion confirmation popup for the selected transaction @@ -931,6 +932,9 @@ impl<'a> InputKeyHandler<'a> { ConfigChoices::AddNewTxMethod => { *self.popup_status = PopupType::new_input(None, false); } + ConfigChoices::SetPassword => { + *self.popup_status = PopupType::new_password(self.conn.is_encrypted()); + } } } ChoicePopupState::TxMethods => { @@ -1055,6 +1059,49 @@ impl<'a> InputKeyHandler<'a> { Ok(()) } + pub fn handle_popup_password(&mut self) -> Result<()> { + match self.key.code { + KeyCode::Enter => { + if let PasswordOutcome::Accepted(password) = self.popup_status.accept_password() { + self.save_password(&password)?; + } + } + KeyCode::Esc => self.do_empty_popup(), + KeyCode::Backspace => self.popup_status.password_char(None), + KeyCode::Char(a) => self.popup_status.password_char(Some(a)), + _ => {} + } + + Ok(()) + } + + /// Encrypts the database with the password, or changes it if there already is one + fn save_password(&mut self, password: &str) -> Result<()> { + let changing = self.conn.is_encrypted(); + + if let Err(e) = self.conn.set_password(password) { + self.popup_status.password_failed(&e.to_string()); + return Ok(()); + } + + self.config.set_db_encrypted(true)?; + + // Backups and the default location hold copies from before the password existed, so + // replace them right away instead of waiting for the app to close + self.config + .save_backup(&PathBuf::from(self.conn.location())); + + let notice = if changing { + "Password changed. The new password will be asked the next time Rex starts" + } else { + "Database encrypted. The password will be asked every time Rex starts. A forgotten password cannot be recovered" + }; + + *self.popup_status = PopupType::new_info(InfoPopupState::Notice(notice.to_string())); + + Ok(()) + } + pub fn popup_move_up(&mut self) { self.popup_status.move_up(); } diff --git a/tui/src/key_checker/popup.rs b/tui/src/key_checker/popup.rs index 871498c4..e6cad73b 100644 --- a/tui/src/key_checker/popup.rs +++ b/tui/src/key_checker/popup.rs @@ -53,6 +53,7 @@ pub fn popup_keys(handler: &mut InputKeyHandler) -> Result handler.do_empty_popup(), }, PopupType::Input(_) => handler.handle_popup_input()?, + PopupType::Password(_) => handler.handle_popup_password()?, PopupType::Nothing => unreachable!(), } Ok(None) diff --git a/tui/src/page_handler/initializer.rs b/tui/src/page_handler/initializer.rs index e467a2ce..263b3353 100644 --- a/tui/src/page_handler/initializer.rs +++ b/tui/src/page_handler/initializer.rs @@ -1,6 +1,6 @@ use anyhow::Result; use atty::Stream; -use rex_app::conn::get_conn; +use rex_app::conn::{DbConn, OpenError}; use std::env::set_current_dir; use std::fs::{self, File}; use std::io::prelude::*; @@ -11,7 +11,8 @@ use std::thread::spawn; use crate::config::{Config, migrate_config}; use crate::outputs::HandlingOutput; -use crate::page_handler::start_app; +use crate::page_handler::{ask_password, start_app}; +use crate::theme::Theme; use crate::utility::{ already_relaunched, check_version, enter_tui_interface, exit_tui_interface, migrate_to_new_schema, start_terminal, @@ -89,7 +90,11 @@ pub fn initialize_app( migrated_db_path.to_path_buf() }; - let mut conn = get_conn(new_db_path.display().to_string().as_str()); + let Some(mut conn) = open_database(new_db_path.display().to_string().as_str(), &mut config)? + else { + // Quit from the password screen + return Ok(()); + }; let v1_migration_done = config.migration_v1_done.unwrap_or(false); @@ -127,3 +132,40 @@ pub fn initialize_app( Ok(()) } + +/// Opens the database, asking for the password first if it is encrypted. Returns `None` if the +/// user quits instead of entering one. +fn open_database(path: &str, config: &mut Config) -> Result> { + match DbConn::open(path, None) { + Ok(conn) => { + if config.db_encrypted.unwrap_or(false) { + config.set_db_encrypted(false)?; + } + + return Ok(Some(conn)); + } + Err(OpenError::NotReadable) => {} + Err(e) => return Err(e.into()), + } + + let theme = Theme::new_index(config.theme_index.unwrap_or(0)); + let mut error = None; + + loop { + let Some(password) = ask_password(&theme, error)? else { + return Ok(None); + }; + + match DbConn::open(path, Some(&password)) { + Ok(conn) => { + if !config.db_encrypted.unwrap_or(false) { + config.set_db_encrypted(true)?; + } + + return Ok(Some(conn)); + } + Err(OpenError::NotReadable) => error = Some("Wrong password"), + Err(e) => return Err(e.into()), + } + } +} diff --git a/tui/src/page_handler/mod.rs b/tui/src/page_handler/mod.rs index 45736bfc..1dfff965 100644 --- a/tui/src/page_handler/mod.rs +++ b/tui/src/page_handler/mod.rs @@ -4,6 +4,9 @@ mod ui_handler; mod ui_state; +mod unlock; + pub use initializer::initialize_app; pub use ui_handler::*; pub use ui_state::*; +pub use unlock::ask_password; diff --git a/tui/src/page_handler/unlock.rs b/tui/src/page_handler/unlock.rs new file mode 100644 index 00000000..8efdabc7 --- /dev/null +++ b/tui/src/page_handler/unlock.rs @@ -0,0 +1,162 @@ +use anyhow::Result; +use crossterm::event::{self, Event, KeyCode, KeyEventKind, KeyModifiers}; +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Position}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::{BorderType, Borders, Paragraph}; + +use crate::theme::Theme; +use crate::utility::{ + centered_rect_exact, enter_tui_interface, exit_tui_interface, main_block, styled_block, +}; + +/// Asks for the database password on a screen of its own, before the app has a connection. +/// Returns `None` if the user quits instead of answering. +pub fn ask_password(theme: &Theme, error: Option<&str>) -> Result> { + let mut terminal = enter_tui_interface()?; + let mut password = String::new(); + + let answer = loop { + terminal.draw(|f| show_unlock_ui(f, theme, password.chars().count(), error))?; + + let Event::Key(key) = event::read()? else { + continue; + }; + + if key.kind != KeyEventKind::Press { + continue; + } + + match key.code { + KeyCode::Enter if !password.is_empty() => break Some(password), + KeyCode::Esc => break None, + KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => break None, + KeyCode::Char(c) => password.push(c), + KeyCode::Backspace => { + password.pop(); + } + _ => {} + } + }; + + exit_tui_interface()?; + + Ok(answer) +} + +/// Only the length of the password is ever given here, so nothing typed can end up on screen +fn show_unlock_ui(f: &mut Frame, theme: &Theme, password_len: usize, error: Option<&str>) { + let area = centered_rect_exact(50, 10, f.area()); + + f.render_widget( + main_block(theme) + .border_type(BorderType::Rounded) + .borders(Borders::ALL) + .title(Span::styled( + "Rex - Locked", + Style::default().add_modifier(Modifier::BOLD), + )), + area, + ); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), + Constraint::Length(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .split(area); + + let masked = "*".repeat(password_len); + + f.render_widget( + Paragraph::new(masked) + .style(Style::default().bg(theme.background()).fg(theme.text())) + .block(styled_block("Password", theme)), + chunks[0], + ); + + f.set_cursor_position(Position { + x: chunks[0].x + password_len as u16 + 1, + y: chunks[0].y + 1, + }); + + let (status, color) = match error { + Some(error) => (error.to_string(), theme.negative()), + None => (String::new(), theme.text()), + }; + + f.render_widget( + Paragraph::new(status) + .style( + Style::default() + .bg(theme.background()) + .fg(color) + .add_modifier(Modifier::BOLD), + ) + .alignment(Alignment::Center), + chunks[1], + ); + + f.render_widget( + Paragraph::new("Enter to unlock. Esc to quit") + .style(Style::default().bg(theme.background()).fg(theme.text())) + .alignment(Alignment::Center), + chunks[3], + ); +} + +#[cfg(test)] +mod tests { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + use super::*; + use crate::theme::ThemeVariant; + + fn render(password_len: usize, error: Option<&str>) -> String { + let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap(); + let theme = Theme::new(ThemeVariant::Dracula); + + terminal + .draw(|f| show_unlock_ui(f, &theme, password_len, error)) + .unwrap(); + + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() + } + + #[test] + fn typed_characters_are_masked() { + let screen = render(6, None); + + assert!(screen.contains("******")); + assert!(!screen.contains("*******")); + assert!(screen.contains("Rex - Locked")); + } + + #[test] + fn wrong_password_message_is_shown() { + assert!(render(0, Some("Wrong password")).contains("Wrong password")); + assert!(!render(0, None).contains("Wrong password")); + } + + #[test] + fn fits_a_small_terminal_without_panicking() { + let mut terminal = Terminal::new(TestBackend::new(30, 8)).unwrap(); + let theme = Theme::new(ThemeVariant::Light); + + terminal + .draw(|f| show_unlock_ui(f, &theme, 40, Some("Wrong password"))) + .unwrap(); + } +} diff --git a/tui/src/pages/popups/choice.rs b/tui/src/pages/popups/choice.rs index fc43b3e5..97f35b10 100644 --- a/tui/src/pages/popups/choice.rs +++ b/tui/src/pages/popups/choice.rs @@ -34,12 +34,12 @@ impl ChoicePopup { title = "Configuration"; message = "Select an option to configure"; - y_value = 13; + y_value = 14; constraints = vec![ Constraint::Length(4), Constraint::Min(1), - Constraint::Length(8), + Constraint::Length(9), ]; } ChoicePopupState::ConfigForced => { diff --git a/tui/src/pages/popups/info.rs b/tui/src/pages/popups/info.rs index 4f57c8a6..3211d754 100644 --- a/tui/src/pages/popups/info.rs +++ b/tui/src/pages/popups/info.rs @@ -60,6 +60,13 @@ impl InfoPopup { x_value = 40; y_value = 20; } + InfoPopupState::Notice(notice) => { + title = "Done"; + message = notice.clone(); + + x_value = 50; + y_value = 12; + } InfoPopupState::ChoiceHelp => { message = choice_help(); diff --git a/tui/src/pages/popups/mod.rs b/tui/src/pages/popups/mod.rs index 921e91bb..90df8545 100644 --- a/tui/src/pages/popups/mod.rs +++ b/tui/src/pages/popups/mod.rs @@ -4,6 +4,7 @@ mod info; mod input; mod models; mod new_path; +mod password; mod reposition; pub use help_text::*; diff --git a/tui/src/pages/popups/models.rs b/tui/src/pages/popups/models.rs index 5fb813b3..8e0f3e12 100644 --- a/tui/src/pages/popups/models.rs +++ b/tui/src/pages/popups/models.rs @@ -18,6 +18,7 @@ pub enum PopupType { Reposition(RepositionPopup), Input(InputPopup), NewPaths(NewPathsPopup), + Password(PasswordPopup), Nothing, } @@ -35,6 +36,7 @@ pub enum InfoPopupState { RepositionHelp, Error(String), ShowDetails(String), + Notice(String), } pub struct InfoPopup { @@ -62,6 +64,30 @@ pub struct NewPathsPopup { pub table: TableData, } +pub const MIN_PASSWORD_LEN: usize = 8; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PasswordStage { + Enter, + Confirm, +} + +pub struct PasswordPopup { + pub stage: PasswordStage, + pub text: String, + first: String, + pub status: String, + /// The database already has a password, so this replaces it instead of creating one + pub changing: bool, +} + +pub enum PasswordOutcome { + /// More input needed. The status explains why when something was wrong + Pending, + /// Entered twice, identical and long enough + Accepted(String), +} + pub struct InputPopup { pub text: String, pub cursor_position: usize, @@ -121,6 +147,9 @@ pub enum ConfigChoices { BackupPaths, #[strum(to_string = "Rename a Tag")] RenameTag, + /// The text changes to match whether a password exists already + #[strum(to_string = "Set a password for app data")] + SetPassword, } impl InfoPopup { @@ -162,6 +191,7 @@ impl PopupType { PopupType::Reposition(reposition) => reposition.show_ui(f, theme), PopupType::NewPaths(new_paths) => new_paths.show_ui(f, theme), PopupType::Input(input) => input.show_ui(f, theme), + PopupType::Password(password) => password.show_ui(f, theme), PopupType::Nothing => {} } } @@ -209,7 +239,7 @@ impl PopupType { reposition.reposition_selected = true; } } - PopupType::Input(_) | PopupType::Nothing => unreachable!(), + PopupType::Input(_) | PopupType::Password(_) | PopupType::Nothing => unreachable!(), } } @@ -248,7 +278,7 @@ impl PopupType { reposition.reposition_selected = true; } } - PopupType::Input(_) | PopupType::Nothing => unreachable!(), + PopupType::Input(_) | PopupType::Password(_) | PopupType::Nothing => unreachable!(), } } @@ -324,6 +354,7 @@ impl PopupType { PopupType::Info(_) | PopupType::Choice(_) | PopupType::Input(_) + | PopupType::Password(_) | PopupType::NewPaths(_) | PopupType::Nothing => {} } @@ -354,9 +385,18 @@ impl PopupType { } } - pub fn new_choice_config(theme: &Theme) -> Self { - let choices: Vec = - ConfigChoices::iter().map(|v| v.to_choice(theme)).collect(); + pub fn new_choice_config(theme: &Theme, has_password: bool) -> Self { + let choices: Vec = ConfigChoices::iter() + .map(|v| { + let mut choice = v.to_choice(theme); + + if has_password && matches!(v, ConfigChoices::SetPassword) { + choice.text = String::from("Change the password of app data"); + } + + choice + }) + .collect(); let table_items = choices.iter().map(|c| vec![c.text.clone()]).collect(); let mut table_data = TableData::new(table_items); @@ -692,4 +732,188 @@ impl PopupType { } } } + + pub fn new_password(changing: bool) -> Self { + PopupType::Password(PasswordPopup { + stage: PasswordStage::Enter, + text: String::new(), + first: String::new(), + status: format!("Use at least {MIN_PASSWORD_LEN} characters"), + changing, + }) + } + + pub fn password_char(&mut self, char: Option) { + if let PopupType::Password(password) = self { + match char { + Some(c) => password.text.push(c), + None => { + password.text.pop(); + } + } + } + } + + pub fn accept_password(&mut self) -> PasswordOutcome { + let PopupType::Password(password) = self else { + return PasswordOutcome::Pending; + }; + + match password.stage { + PasswordStage::Enter => { + if password.text.chars().count() < MIN_PASSWORD_LEN { + password.status = format!("Needs at least {MIN_PASSWORD_LEN} characters"); + return PasswordOutcome::Pending; + } + + password.first = std::mem::take(&mut password.text); + password.stage = PasswordStage::Confirm; + password.status = String::from("Enter the same password again"); + + PasswordOutcome::Pending + } + PasswordStage::Confirm => { + if password.text != password.first { + password.text.clear(); + password.first.clear(); + password.stage = PasswordStage::Enter; + password.status = String::from("Passwords did not match. Start again"); + + return PasswordOutcome::Pending; + } + + PasswordOutcome::Accepted(std::mem::take(&mut password.text)) + } + } + } + + /// Shows why saving the password failed and starts over + pub fn password_failed(&mut self, error: &str) { + if let PopupType::Password(password) = self { + password.text.clear(); + password.first.clear(); + password.stage = PasswordStage::Enter; + password.status = format!("Failed: {error}"); + } + } +} + +#[cfg(test)] +mod password_tests { + use super::*; + + fn type_text(popup: &mut PopupType, text: &str) { + for c in text.chars() { + popup.password_char(Some(c)); + } + } + + fn status(popup: &PopupType) -> &str { + let PopupType::Password(password) = popup else { + panic!("not a password popup"); + }; + &password.status + } + + fn stage(popup: &PopupType) -> PasswordStage { + let PopupType::Password(password) = popup else { + panic!("not a password popup"); + }; + password.stage + } + + #[test] + fn matching_passwords_are_accepted_after_confirming() { + let mut popup = PopupType::new_password(false); + + type_text(&mut popup, "correct horse"); + assert!(matches!(popup.accept_password(), PasswordOutcome::Pending)); + assert_eq!(stage(&popup), PasswordStage::Confirm); + + type_text(&mut popup, "correct horse"); + let PasswordOutcome::Accepted(password) = popup.accept_password() else { + panic!("should have been accepted"); + }; + assert_eq!(password, "correct horse"); + } + + #[test] + fn short_passwords_are_refused_and_stay_on_the_first_step() { + let mut popup = PopupType::new_password(false); + + type_text(&mut popup, "1234567"); + assert!(matches!(popup.accept_password(), PasswordOutcome::Pending)); + + assert_eq!(stage(&popup), PasswordStage::Enter); + assert!(status(&popup).contains("at least 8")); + } + + #[test] + fn exactly_the_minimum_length_is_enough() { + let mut popup = PopupType::new_password(false); + + type_text(&mut popup, "12345678"); + popup.accept_password(); + + assert_eq!(stage(&popup), PasswordStage::Confirm); + } + + #[test] + fn length_counts_characters_not_bytes() { + let mut popup = PopupType::new_password(false); + + // 4 characters, 8 bytes + type_text(&mut popup, "éééé"); + popup.accept_password(); + + assert_eq!(stage(&popup), PasswordStage::Enter); + } + + #[test] + fn a_mismatch_starts_over_and_clears_both_entries() { + let mut popup = PopupType::new_password(false); + + type_text(&mut popup, "correct horse"); + popup.accept_password(); + type_text(&mut popup, "correct horsf"); + assert!(matches!(popup.accept_password(), PasswordOutcome::Pending)); + + assert_eq!(stage(&popup), PasswordStage::Enter); + assert!(status(&popup).contains("did not match")); + + // The first attempt must not linger as the thing to match + type_text(&mut popup, "another one"); + popup.accept_password(); + type_text(&mut popup, "correct horse"); + assert!(matches!(popup.accept_password(), PasswordOutcome::Pending)); + } + + #[test] + fn backspace_removes_the_last_character() { + let mut popup = PopupType::new_password(false); + + type_text(&mut popup, "abc"); + popup.password_char(None); + popup.password_char(None); + popup.password_char(None); + // Nothing left to remove + popup.password_char(None); + + let PopupType::Password(password) = &popup else { + panic!(); + }; + assert!(password.text.is_empty()); + } + + #[test] + fn a_failure_shows_its_reason_and_starts_over() { + let mut popup = PopupType::new_password(false); + + type_text(&mut popup, "correct horse"); + popup.accept_password(); + popup.password_failed("disk is full"); + + assert_eq!(stage(&popup), PasswordStage::Enter); + assert!(status(&popup).contains("disk is full")); + } } diff --git a/tui/src/pages/popups/password.rs b/tui/src/pages/popups/password.rs new file mode 100644 index 00000000..ac7d3f90 --- /dev/null +++ b/tui/src/pages/popups/password.rs @@ -0,0 +1,84 @@ +use ratatui::Frame; +use ratatui::layout::{Alignment, Constraint, Direction, Layout, Position}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::Span; +use ratatui::widgets::{BorderType, Borders, Clear, Paragraph, Wrap}; + +use crate::pages::{PasswordPopup, PasswordStage}; +use crate::theme::Theme; +use crate::utility::{centered_rect_exact, main_block, styled_block}; + +impl PasswordPopup { + pub fn show_ui(&mut self, f: &mut Frame, theme: &Theme) { + let area = centered_rect_exact(56, 12, f.area()); + + let title = if self.changing { + "Change Password" + } else { + "Set Password" + }; + + let block_name = match self.stage { + PasswordStage::Enter => "New password", + PasswordStage::Confirm => "Confirm password", + }; + + let block = main_block(theme) + .border_type(BorderType::Rounded) + .title(Span::styled( + title, + Style::default().add_modifier(Modifier::BOLD), + )) + .borders(Borders::ALL); + + let chunks = Layout::default() + .direction(Direction::Vertical) + .margin(1) + .constraints([ + Constraint::Length(3), + Constraint::Length(2), + Constraint::Min(1), + ]) + .split(area); + + f.render_widget(Clear, area); + f.render_widget(block, area); + + let masked = "*".repeat(self.text.chars().count()); + let cursor_x = chunks[0].x + masked.len() as u16 + 1; + + f.render_widget( + Paragraph::new(masked) + .style(Style::default().bg(theme.background()).fg(theme.text())) + .block(styled_block(block_name, theme)), + chunks[0], + ); + + f.set_cursor_position(Position { + x: cursor_x, + y: chunks[0].y + 1, + }); + + f.render_widget( + Paragraph::new(format!("Status: {}", self.status)) + .style( + Style::default() + .bg(theme.background()) + .fg(theme.text()) + .add_modifier(Modifier::BOLD), + ) + .wrap(Wrap { trim: true }), + chunks[1], + ); + + f.render_widget( + Paragraph::new( + "The database gets encrypted. A forgotten password cannot be recovered. Esc to cancel", + ) + .style(Style::default().bg(theme.background()).fg(theme.negative())) + .wrap(Wrap { trim: true }) + .alignment(Alignment::Left), + chunks[2], + ); + } +} From 7557881621820c745cf5da4234dee231aac959d7 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Mon, 19 Oct 2026 17:30:23 +0600 Subject: [PATCH 4/4] Update tests --- app/tests/encryption.rs | 245 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 app/tests/encryption.rs diff --git a/app/tests/encryption.rs b/app/tests/encryption.rs new file mode 100644 index 00000000..2d4d1442 --- /dev/null +++ b/app/tests/encryption.rs @@ -0,0 +1,245 @@ +use chrono::NaiveDate; +use rex_app::conn::{DbConn, FetchNature, OpenError}; +use std::fs; +use std::io::Read; +use std::path::Path; + +use crate::common::{add_tx, create_test_db}; + +mod common; + +const SQLITE_HEADER: &[u8] = b"SQLite format 3\0"; + +fn starts_with_sqlite_header(path: &str) -> bool { + let mut header = [0u8; 16]; + fs::File::open(path) + .unwrap() + .read_exact(&mut header) + .unwrap(); + header == SQLITE_HEADER +} + +fn tx_count(db: &mut DbConn) -> usize { + db.fetch_txs_with_date( + NaiveDate::from_ymd_opt(2024, 1, 1).unwrap(), + FetchNature::All, + ) + .unwrap() + .len() +} + +fn seed(db: &mut DbConn) { + add_tx( + db, + "2024-06-01", + "Salary", + "Cash", + "", + "3000.00", + "Income", + "Work", + ); + add_tx( + db, + "2024-06-05", + "Rent", + "Cash", + "", + "1000.00", + "Expense", + "Home", + ); + add_tx( + db, + "2024-07-05", + "Move", + "Cash", + "Bank", + "200.00", + "Transfer", + "", + ); +} + +fn cleanup(file_name: &str) { + fs::remove_file(file_name).unwrap(); + assert!(!Path::new(&format!("{file_name}.encrypting")).exists()); +} + +#[test] +fn plain_database_is_readable_and_reports_not_encrypted() { + let file_name = "test_encryption_plain.sqlite"; + let db = create_test_db(file_name); + + assert!(!db.is_encrypted()); + assert!(starts_with_sqlite_header(file_name)); + + drop(db); + cleanup(file_name); +} + +#[test] +fn set_password_makes_the_file_unreadable_without_it() { + let file_name = "test_encryption_set.sqlite"; + let mut db = create_test_db(file_name); + seed(&mut db); + + db.set_password("correct horse").unwrap(); + assert!(db.is_encrypted()); + + // The live connection keeps working + assert_eq!(tx_count(&mut db), 3); + drop(db); + + assert!(!starts_with_sqlite_header(file_name)); + assert!(matches!( + DbConn::open(file_name, None), + Err(OpenError::NotReadable) + )); + assert!(matches!( + DbConn::open(file_name, Some("wrong")), + Err(OpenError::NotReadable) + )); + + let mut reopened = DbConn::open(file_name, Some("correct horse")).unwrap(); + assert!(reopened.is_encrypted()); + assert_eq!(tx_count(&mut reopened), 3); + assert_eq!( + reopened + .get_tx_methods_sorted() + .iter() + .map(|m| m.name.as_str()) + .collect::>(), + vec!["Cash", "Bank", "Other"] + ); + + drop(reopened); + cleanup(file_name); +} + +#[test] +fn data_survives_encryption_including_balances_and_tags() { + let file_name = "test_encryption_data.sqlite"; + let mut db = create_test_db(file_name); + seed(&mut db); + + let before = db.get_final_balances().unwrap(); + let cash = db.get_tx_method_by_name("Cash").unwrap().id; + let before_cash = before[&cash].balance; + + db.set_password("pw").unwrap(); + drop(db); + + let mut db = DbConn::open(file_name, Some("pw")).unwrap(); + let after = db.get_final_balances().unwrap(); + assert_eq!(after[&cash].balance, before_cash); + + let tags: Vec = db + .get_tags_sorted() + .iter() + .map(|t| t.name.clone()) + .collect(); + assert_eq!(tags, vec!["Home", "Unknown", "Work"]); + + drop(db); + cleanup(file_name); +} + +#[test] +fn an_encrypted_database_accepts_new_data() { + let file_name = "test_encryption_write.sqlite"; + let mut db = create_test_db(file_name); + seed(&mut db); + db.set_password("pw").unwrap(); + + add_tx( + &mut db, + "2024-08-01", + "More", + "Cash", + "", + "5.00", + "Expense", + "Home", + ); + drop(db); + + let mut db = DbConn::open(file_name, Some("pw")).unwrap(); + assert_eq!(tx_count(&mut db), 4); + + drop(db); + cleanup(file_name); +} + +#[test] +fn changing_the_password_replaces_the_old_one() { + let file_name = "test_encryption_change.sqlite"; + let mut db = create_test_db(file_name); + seed(&mut db); + + db.set_password("first").unwrap(); + db.set_password("second").unwrap(); + drop(db); + + assert!(matches!( + DbConn::open(file_name, Some("first")), + Err(OpenError::NotReadable) + )); + + let mut db = DbConn::open(file_name, Some("second")).unwrap(); + assert_eq!(tx_count(&mut db), 3); + + drop(db); + cleanup(file_name); +} + +#[test] +fn passwords_with_quotes_and_unicode_work() { + let file_name = "test_encryption_quotes.sqlite"; + let mut db = create_test_db(file_name); + seed(&mut db); + + let password = "it's \"a\" pässwörd; DROP TABLE txs;--"; + db.set_password(password).unwrap(); + drop(db); + + let mut db = DbConn::open(file_name, Some(password)).unwrap(); + assert_eq!(tx_count(&mut db), 3); + + drop(db); + cleanup(file_name); +} + +#[test] +fn empty_password_is_rejected_and_changes_nothing() { + let file_name = "test_encryption_empty.sqlite"; + let mut db = create_test_db(file_name); + seed(&mut db); + + assert!(db.set_password("").is_err()); + assert!(!db.is_encrypted()); + drop(db); + + assert!(starts_with_sqlite_header(file_name)); + assert_eq!(tx_count(&mut DbConn::open(file_name, None).unwrap()), 3); + + cleanup(file_name); +} + +#[test] +fn a_leftover_temp_file_from_a_crash_does_not_block_encrypting() { + let file_name = "test_encryption_leftover.sqlite"; + let mut db = create_test_db(file_name); + seed(&mut db); + + fs::write(format!("{file_name}.encrypting"), b"garbage").unwrap(); + + db.set_password("pw").unwrap(); + drop(db); + + let mut db = DbConn::open(file_name, Some("pw")).unwrap(); + assert_eq!(tx_count(&mut db), 3); + + drop(db); + cleanup(file_name); +}