From f831212d406947467340c347d1fcc015136714ce Mon Sep 17 00:00:00 2001 From: yggverse Date: Wed, 23 Jul 2025 02:13:10 +0300 Subject: [PATCH] init profile TOFU features --- Cargo.toml | 9 +- README.md | 13 ++- .../window/tab/item/client/driver/gemini.rs | 45 +++++++- .../browser/window/tab/item/page/content.rs | 64 ++++++----- .../window/tab/item/page/content/status.rs | 1 + .../tab/item/page/content/status/tofu.rs | 40 +++++++ src/profile.rs | 18 ++- src/profile/tofu.rs | 103 ++++++++++++++++++ src/profile/tofu/certificate.rs | 52 +++++++++ src/profile/tofu/database.rs | 97 +++++++++++++++++ src/profile/tofu/database/row.rs | 5 + 11 files changed, 403 insertions(+), 44 deletions(-) create mode 100644 src/app/browser/window/tab/item/page/content/status/tofu.rs create mode 100644 src/profile/tofu.rs create mode 100644 src/profile/tofu/certificate.rs create mode 100644 src/profile/tofu/database.rs create mode 100644 src/profile/tofu/database/row.rs diff --git a/Cargo.toml b/Cargo.toml index ac7462a0..b36d4593 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "Yoda" -version = "0.11.9" +version = "0.12.0" edition = "2024" license = "MIT" readme = "README.md" @@ -31,7 +31,8 @@ version = "0.9.1" [dependencies] ansi-parser = "0.9.1" anyhow = "1.0.97" -ggemini = "0.18.0" +async-channel = "2.5.0" +ggemini = "0.19.0" ggemtext = "0.6.0" indexmap = "2.7.0" itertools = "0.14.0" @@ -44,7 +45,7 @@ r2d2_sqlite = "0.30.0" syntect = "5.2.0" # development -# [patch.crates-io] -# ggemini = { git = "https://github.com/YGGverse/ggemini.git" } +[patch.crates-io] +ggemini = { git = "https://github.com/YGGverse/ggemini.git" } # ggemtext = { git = "https://github.com/YGGverse/ggemtext.git" } # plurify = { git = "https://github.com/YGGverse/plurify.git" } diff --git a/README.md b/README.md index 0fe0519c..0ccaa5cf 100644 --- a/README.md +++ b/README.md @@ -21,11 +21,14 @@ GTK 4 / Libadwaita client written in Rust * [ ] [Audio](#audio) * [ ] [Video](#video) * [x] Certificates - * [x] Generate new identity - * [x] Select for path - * [x] Export to PEM - * [x] Import from PEM - * [x] Delete + * [x] Server + * [x] [TOFU](https://en.wikipedia.org/wiki/Trust_on_first_use) + * [x] Client + * [x] Generate new identity + * [x] Select for path + * [x] Export to PEM + * [x] Import from PEM + * [x] Delete * [x] Custom search providers * [ ] Downloads * [ ] Browser window diff --git a/src/app/browser/window/tab/item/client/driver/gemini.rs b/src/app/browser/window/tab/item/client/driver/gemini.rs index 64f477ee..62bcf17a 100644 --- a/src/app/browser/window/tab/item/client/driver/gemini.rs +++ b/src/app/browser/window/tab/item/client/driver/gemini.rs @@ -11,7 +11,7 @@ use gtk::{ glib::{Priority, Uri}, prelude::{ButtonExt, FileExt, SocketClientExt}, }; -use sourceview::prelude::InputStreamExtManual; +use sourceview::prelude::{ActionExt, InputStreamExtManual, TlsConnectionExt}; use std::{cell::Cell, path::MAIN_SEPARATOR, rc::Rc, time::Duration}; /// [Gemini protocol](https://geminiprotocol.net/docs/protocol-specification.gmi) client driver @@ -150,6 +150,8 @@ fn handle( ) { const EVENT_COMPLETED: &str = "Completed"; let uri = request.uri().clone(); + let server_certificates = this.page.profile.tofu.server_certificates(&uri); + let has_server_certificates = server_certificates.is_some(); this.client.request_async( request, Priority::DEFAULT, @@ -160,6 +162,7 @@ fn handle( .profile .identity .get(&uri.to_string()).map(|identity|identity.to_tls_certificate().unwrap()), + server_certificates, { let page = this.page.clone(); let redirects = this.redirects.clone(); @@ -186,6 +189,12 @@ fn handle( // * unwrap fails only on `connection.socket_connection.is_closed()` // drop the panic as unexpected here. } + // Register new peer certificate if the TOFU index is empty + if !has_server_certificates { + page.profile.tofu.add( + connection.tls_client_connection.peer_certificate().unwrap() + ).unwrap() // expect new record + } // Handle response match response { // https://geminiprotocol.net/docs/protocol-specification.gmi#input-expected @@ -608,8 +617,38 @@ fn handle( } } Err(e) => { - let s = page.content.to_status_failure(); - s.set_description(Some(&e.to_string())); + let s = match e { + ggemini::client::Error::Request(connection, e) => match e { + ggemini::client::connection::Error::Request(_, e) => { + use gtk::gio::TlsError; + if e.kind::().is_some_and(|e| matches!(e, TlsError::BadCertificate)) { + page.content.to_status_tofu({ + let p = page.clone(); + move || { + p.profile.tofu.add( + connection.tls_client_connection.peer_certificate().unwrap() + ).unwrap(); // expect new record + p.item_action.reload.activate(None) + } + }) + } else { + let s = page.content.to_status_failure(); + s.set_description(Some(&e.to_string())); + s + } + }, + _ => { + let s = page.content.to_status_failure(); + s.set_description(Some(&e.to_string())); + s + } + }, + _ => { + let s = page.content.to_status_failure(); + s.set_description(Some(&e.to_string())); + s + } + }; page.set_progress(0.0); page.set_title(&s.title()); if is_snap_history { diff --git a/src/app/browser/window/tab/item/page/content.rs b/src/app/browser/window/tab/item/page/content.rs index 08d5b56f..016121dd 100644 --- a/src/app/browser/window/tab/item/page/content.rs +++ b/src/app/browser/window/tab/item/page/content.rs @@ -51,9 +51,9 @@ impl Content { /// * action removes previous children component from `Self` pub fn to_image(&self, paintable: &impl IsA) -> Image { self.clean(); - let image = Image::new_from_paintable(paintable); - self.g_box.append(&image.picture); - image + let i = Image::new_from_paintable(paintable); + self.g_box.append(&i.picture); + i } /// Set new `content::Status` component for `Self` with new `status::Download` preset @@ -66,9 +66,9 @@ impl Content { on_choose: impl Fn(File, Rc) + 'static, ) -> StatusPage { self.clean(); - let status = status::download::build(initial_filename, cancellable, on_choose); - self.g_box.append(&status); - status + let s = status::download::build(initial_filename, cancellable, on_choose); + self.g_box.append(&s); + s } /// Set new `content::Status` component for `Self` with new `status::Failure` preset @@ -76,9 +76,19 @@ impl Content { /// * action removes previous children component from `Self` pub fn to_status_failure(&self) -> StatusPage { self.clean(); - let status = status::failure::new(); - self.g_box.append(&status); - status + let s = status::failure::new(); + self.g_box.append(&s); + s + } + + /// Set new `content::Status` component for `Self` with new `status::Tofu` preset + /// + /// * action removes previous children component from `Self` + pub fn to_status_tofu(&self, on_accept: impl Fn() + 'static) -> StatusPage { + self.clean(); + let s = status::tofu::build(on_accept); + self.g_box.append(&s); + s } /// Set new `content::Status` component for `Self` with new `status::Mime` issue preset @@ -90,9 +100,9 @@ impl Content { download: Option<(&Rc, &Uri)>, ) -> StatusPage { self.clean(); - let status = status::mime::build(mime, download); - self.g_box.append(&status); - status + let s = status::mime::build(mime, download); + self.g_box.append(&s); + s } /// Set new `content::Status` component for `Self` with new `status::Identity` preset @@ -100,9 +110,9 @@ impl Content { /// * action removes previous children component from `Self` pub fn to_status_identity(&self) -> StatusPage { self.clean(); - let status = status::identity::build((&self.tab_action, &self.item_action)); - self.g_box.append(&status); - status + let s = status::identity::build((&self.tab_action, &self.item_action)); + self.g_box.append(&s); + s } /// Set new `content::Status` component for `Self` with new `status::Loading` preset @@ -110,9 +120,9 @@ impl Content { /// * action removes previous children component from `Self` pub fn to_status_loading(&self, show_with_delay: Option) -> StatusPage { self.clean(); - let status = status::loading::build(show_with_delay); - self.g_box.append(&status); - status + let s = status::loading::build(show_with_delay); + self.g_box.append(&s); + s } /// `text/gemini` @@ -147,17 +157,17 @@ impl Content { /// `text/plain` pub fn to_text_plain(&self, data: &str) -> Text { self.clean(); - let text = Text::plain(data); - self.g_box.append(&text.scrolled_window); - text + let t = Text::plain(data); + self.g_box.append(&t.scrolled_window); + t } /// [text/nex](https://nightfall.city/nex/info/specification.txt) pub fn to_text_nex(&self, base: &Uri, data: &str) -> Text { self.clean(); - let text = Text::nex((&self.window_action, &self.item_action), base, data); - self.g_box.append(&text.scrolled_window); - text + let t = Text::nex((&self.window_action, &self.item_action), base, data); + self.g_box.append(&t.scrolled_window); + t } pub fn to_directory( @@ -172,9 +182,9 @@ impl Content { /// * system `source:` pub fn to_text_source(&self, data: &str) -> Text { self.clean(); - let text = Text::source(data); - self.g_box.append(&text.scrolled_window); - text + let t = Text::source(data); + self.g_box.append(&t.scrolled_window); + t } /// Remove all children components from `Self` diff --git a/src/app/browser/window/tab/item/page/content/status.rs b/src/app/browser/window/tab/item/page/content/status.rs index 5ecfa4f7..863f23b4 100644 --- a/src/app/browser/window/tab/item/page/content/status.rs +++ b/src/app/browser/window/tab/item/page/content/status.rs @@ -3,5 +3,6 @@ pub mod failure; pub mod identity; pub mod loading; pub mod mime; +pub mod tofu; use super::{ItemAction, TabAction}; diff --git a/src/app/browser/window/tab/item/page/content/status/tofu.rs b/src/app/browser/window/tab/item/page/content/status/tofu.rs new file mode 100644 index 00000000..37e83b4a --- /dev/null +++ b/src/app/browser/window/tab/item/page/content/status/tofu.rs @@ -0,0 +1,40 @@ +use adw::StatusPage; +use gtk::{ + Align, Button, + prelude::{BoxExt, ButtonExt, WidgetExt}, +}; + +pub fn build(on_accept: impl Fn() + 'static) -> StatusPage { + let b = gtk::Box::builder() + .halign(Align::Center) + .orientation(gtk::Orientation::Horizontal) + .spacing(16) + .build(); + + b.append(>k::Label::builder().selectable(true).use_markup(true).label( + "Read more..." + ).build()); + + b.append(&{ + let b = Button::builder() + .css_classes(["warning"]) + .label("Accept") + .tooltip_text("Add an exception") + .halign(Align::Center) + .build(); + + b.connect_clicked(move |this| { + this.set_sensitive(false); + on_accept() + }); + + b + }); + + StatusPage::builder() + .child(&b) + .icon_name("security-medium-symbolic") + .title("Server certificate has been changed") + .description("it could be a man-in-the-middle attack") + .build() +} diff --git a/src/profile.rs b/src/profile.rs index 1e0e32fd..e3bc3378 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -3,6 +3,7 @@ mod database; mod history; mod identity; mod search; +mod tofu; use anyhow::Result; use bookmark::Bookmark; @@ -15,6 +16,7 @@ use r2d2_sqlite::SqliteConnectionManager; use search::Search; use sqlite::Transaction; use std::{fs::create_dir_all, path::PathBuf}; +use tofu::Tofu; const VENDOR: &str = "YGGverse"; const APP_ID: &str = "Yoda"; @@ -24,11 +26,12 @@ const DB_NAME: &str = "database.sqlite3"; pub struct Profile { pub bookmark: Bookmark, + pub config_path: PathBuf, pub database: Database, pub history: History, pub identity: Identity, pub search: Search, - pub config_path: PathBuf, + pub tofu: Tofu, } impl Profile { @@ -82,24 +85,28 @@ impl Profile { // Init components let bookmark = Bookmark::build(&database_pool, profile_id)?; let history = History::build(&database_pool, profile_id)?; - let search = Search::build(&database_pool, profile_id)?; let identity = Identity::build(&database_pool, profile_id)?; + let search = Search::build(&database_pool, profile_id)?; + let tofu = Tofu::init(&database_pool, profile_id)?; // Result Ok(Self { bookmark, + config_path, database, history, identity, search, - config_path, + tofu, }) } // Actions pub fn save(&self) -> Result<()> { - self.history.save() + self.history.save()?; + self.tofu.save()?; + Ok(()) } } @@ -109,9 +116,10 @@ pub fn migrate(tx: &Transaction) -> Result<()> { // Delegate migration to children components bookmark::migrate(tx)?; + history::migrate(tx)?; identity::migrate(tx)?; search::migrate(tx)?; - history::migrate(tx)?; + tofu::migrate(tx)?; // Success Ok(()) diff --git a/src/profile/tofu.rs b/src/profile/tofu.rs new file mode 100644 index 00000000..e90dd140 --- /dev/null +++ b/src/profile/tofu.rs @@ -0,0 +1,103 @@ +mod certificate; +mod database; + +use anyhow::Result; +use certificate::Certificate; +use database::Database; +use gtk::{gio::TlsCertificate, glib::Uri}; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use sourceview::prelude::TlsCertificateExt; +use sqlite::Transaction; +use std::sync::RwLock; + +/// TOFU wrapper for the Gemini protocol +/// +/// https://geminiprotocol.net/docs/protocol-specification.gmi#tls-server-certificate-validation +pub struct Tofu { + database: Database, + memory: RwLock>, +} + +impl Tofu { + // Constructors + + pub fn init(database_pool: &Pool, profile_id: i64) -> Result { + let database = Database::init(database_pool, profile_id); + + let records = database.records()?; + let memory = RwLock::new(Vec::with_capacity(records.len())); + + { + // build in-memory index... + let mut m = memory.write().unwrap(); + for r in records { + m.push(Certificate::from_db(Some(r.id), &r.pem, r.time)?) + } + } + + Ok(Self { database, memory }) + } + + // Actions + + pub fn add(&self, tls_certificate: TlsCertificate) -> Result<()> { + self.memory + .write() + .unwrap() + .push(Certificate::from_tls_certificate(tls_certificate)?); + Ok(()) + } + + pub fn server_certificates(&self, uri: &Uri) -> Option> { + fn f(subject_name: &str) -> String { + subject_name + .trim_start_matches("CN=") + .trim_start_matches('*') + .trim_matches('.') + .to_lowercase() + } + if let Some(h) = uri.host() { + let k = f(&h); + let m = self.memory.read().unwrap(); + let b: Vec = m + .iter() + .filter_map(|certificate| { + let tls_certificate = certificate.tls_certificate(); + if k.ends_with(&f(&tls_certificate.subject_name().unwrap())) { + Some(tls_certificate.clone()) + } else { + None + } + }) + .collect(); + if !b.is_empty() { + return Some(b); + } + } + None + } + + /// Save in-memory index to the permanent database (on app close) + pub fn save(&self) -> Result<()> { + for c in self.memory.read().unwrap().iter() { + if c.id().is_none() { + self.database.add(c.time(), &c.pem())?; + } + } + Ok(()) + } +} + +// Tools + +pub fn migrate(tx: &Transaction) -> Result<()> { + // Migrate self components + database::init(tx)?; + + // Delegate migration to childs + // nothing yet... + + // Success + Ok(()) +} diff --git a/src/profile/tofu/certificate.rs b/src/profile/tofu/certificate.rs new file mode 100644 index 00000000..82531280 --- /dev/null +++ b/src/profile/tofu/certificate.rs @@ -0,0 +1,52 @@ +use anyhow::Result; + +use gtk::{ + gio::TlsCertificate, + glib::{DateTime, GString}, +}; +use sourceview::prelude::TlsCertificateExt; + +#[derive(PartialEq, Eq, Hash)] +pub struct Certificate { + id: Option, + time: DateTime, + tls_certificate: TlsCertificate, +} + +impl Certificate { + // Constructors + + pub fn from_db(id: Option, pem: &str, time: DateTime) -> Result { + Ok(Self { + id, + time, + tls_certificate: TlsCertificate::from_pem(pem)?, + }) + } + + pub fn from_tls_certificate(tls_certificate: TlsCertificate) -> Result { + Ok(Self { + id: None, + time: DateTime::now_local()?, + tls_certificate, + }) + } + + // Getters + + pub fn pem(&self) -> GString { + self.tls_certificate.certificate_pem().unwrap() + } + + pub fn id(&self) -> Option { + self.id + } + + pub fn time(&self) -> &DateTime { + &self.time + } + + pub fn tls_certificate(&self) -> &TlsCertificate { + &self.tls_certificate + } +} diff --git a/src/profile/tofu/database.rs b/src/profile/tofu/database.rs new file mode 100644 index 00000000..f78bf888 --- /dev/null +++ b/src/profile/tofu/database.rs @@ -0,0 +1,97 @@ +mod row; + +use anyhow::Result; +use gtk::glib::DateTime; +use r2d2::Pool; +use r2d2_sqlite::SqliteConnectionManager; +use row::Row; +use sqlite::Transaction; + +pub struct Database { + pool: Pool, + profile_id: i64, +} + +impl Database { + // Constructors + + /// Create new `Self` + pub fn init(pool: &Pool, profile_id: i64) -> Self { + Self { + pool: pool.clone(), + profile_id, + } + } + + // Getters + + /// Get records from database with optional filter by `request` + pub fn records(&self) -> Result> { + select(&self.pool.get()?.unchecked_transaction()?, self.profile_id) + } + + // Setters + + /// Create new record in database + /// * return last insert ID on success + pub fn add(&self, time: &DateTime, pem: &str) -> Result { + let mut connection = self.pool.get()?; + let tx = connection.transaction()?; + let id = insert(&tx, self.profile_id, time, pem)?; + tx.commit()?; + Ok(id) + } +} + +// Low-level DB API + +pub fn init(tx: &Transaction) -> Result { + Ok(tx.execute( + "CREATE TABLE IF NOT EXISTS `profile_tofu` + ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `profile_id` INTEGER NOT NULL, + `time` INTEGER NOT NULL, + `pem` TEXT NOT NULL, + + FOREIGN KEY (`profile_id`) REFERENCES `profile` (`id`) + )", + [], + )?) +} + +pub fn insert(tx: &Transaction, profile_id: i64, time: &DateTime, pem: &str) -> Result { + tx.execute( + "INSERT INTO `profile_tofu` ( + `profile_id`, + `time`, + `pem` + ) VALUES (?, ?, ?)", + (profile_id, time.to_unix(), pem), + )?; + Ok(tx.last_insert_rowid()) +} + +pub fn select(tx: &Transaction, profile_id: i64) -> Result> { + let mut stmt = tx.prepare( + "SELECT `id`, `profile_id`, `time`, `pem` FROM `profile_tofu` WHERE `profile_id` = ?", + )?; + + let result = stmt.query_map([profile_id], |row| { + Ok(Row { + id: row.get(0)?, + //profile_id: row.get(1)?, + time: DateTime::from_unix_local(row.get(2)?).unwrap(), + pem: row.get(3)?, + }) + })?; + + let mut records = Vec::new(); + + for record in result { + let table = record?; + records.push(table); + } + + Ok(records) +} diff --git a/src/profile/tofu/database/row.rs b/src/profile/tofu/database/row.rs new file mode 100644 index 00000000..e8a1ff18 --- /dev/null +++ b/src/profile/tofu/database/row.rs @@ -0,0 +1,5 @@ +pub struct Row { + pub id: i64, + pub pem: String, + pub time: gtk::glib::DateTime, +}