diff --git a/data/resources/resources.gresource.xml b/data/resources/resources.gresource.xml index 77945325..1c935894 100644 --- a/data/resources/resources.gresource.xml +++ b/data/resources/resources.gresource.xml @@ -26,6 +26,8 @@ ui/account-settings-deactivate-account-subpage.ui ui/account-settings-device-row.ui ui/account-settings-devices-page.ui + ui/account-settings-import-export-keys-subpage.ui + ui/account-settings-security-page.ui ui/account-settings-user-page.ui ui/account-settings.ui ui/attachment-dialog.ui diff --git a/data/resources/ui/account-settings-change-password-subpage.ui b/data/resources/ui/account-settings-change-password-subpage.ui index 38d25f27..bba7b230 100644 --- a/data/resources/ui/account-settings-change-password-subpage.ui +++ b/data/resources/ui/account-settings-change-password-subpage.ui @@ -61,7 +61,7 @@ - Fractal’s support for encryption is unstable so you might lose access to your encrypted message history. It is recommended to backup your encryption keys from another Matrix client before proceeding. + Fractal’s support for encryption is unstable so you might lose access to your encrypted message history. It is recommended to backup your encryption keys before proceeding. True word-char 0.0 diff --git a/data/resources/ui/account-settings-import-export-keys-subpage.ui b/data/resources/ui/account-settings-import-export-keys-subpage.ui new file mode 100644 index 00000000..a2fc3057 --- /dev/null +++ b/data/resources/ui/account-settings-import-export-keys-subpage.ui @@ -0,0 +1,108 @@ + + + + diff --git a/data/resources/ui/account-settings-security-page.ui b/data/resources/ui/account-settings-security-page.ui new file mode 100644 index 00000000..4b8852f9 --- /dev/null +++ b/data/resources/ui/account-settings-security-page.ui @@ -0,0 +1,30 @@ + + + + + + + diff --git a/data/resources/ui/account-settings.ui b/data/resources/ui/account-settings.ui index c9ca479f..3140cc89 100644 --- a/data/resources/ui/account-settings.ui +++ b/data/resources/ui/account-settings.ui @@ -18,5 +18,10 @@ + + + + + diff --git a/po/POTFILES.in b/po/POTFILES.in index 41468cb5..12ef1700 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -9,7 +9,9 @@ data/resources/ui/account-settings-change-password-subpage.ui data/resources/ui/account-settings-deactivate-account-subpage.ui data/resources/ui/account-settings-device-row.ui data/resources/ui/account-settings-devices-page.ui +data/resources/ui/account-settings-import-export-keys-subpage.ui data/resources/ui/account-settings-user-page.ui +data/resources/ui/account-settings-security-page.ui data/resources/ui/account-settings.ui data/resources/ui/attachment-dialog.ui data/resources/ui/components-auth-dialog.ui @@ -51,6 +53,7 @@ src/login/mod.rs src/secret.rs src/session/account_settings/devices_page/device_list.rs src/session/account_settings/devices_page/device_row.rs +src/session/account_settings/security_page/import_export_keys_subpage.rs src/session/account_settings/user_page/change_password_subpage.rs src/session/account_settings/user_page/deactivate_account_subpage.rs src/session/account_settings/user_page/mod.rs diff --git a/src/session/account_settings/devices_page/device_row.rs b/src/session/account_settings/devices_page/device_row.rs index 6d657b33..bd8fa655 100644 --- a/src/session/account_settings/devices_page/device_row.rs +++ b/src/session/account_settings/devices_page/device_row.rs @@ -225,7 +225,7 @@ impl DeviceRow { self.imp().delete_logout_button.set_loading(true); let window: Option = self.root().and_then(|root| root.downcast().ok()); - let dialog = gtk::MessageDialog::new(window.as_ref(), gtk::DialogFlags::MODAL, gtk::MessageType::Info, gtk::ButtonsType::OkCancel, &gettext("Fractal’s support for encryption is unstable so you might lose access to your encrypted message history. It is recommended to backup your encryption keys from another Matrix client before proceeding.")); + let dialog = gtk::MessageDialog::new(window.as_ref(), gtk::DialogFlags::MODAL, gtk::MessageType::Info, gtk::ButtonsType::OkCancel, &gettext("Fractal doesn't support online backup of room encryption keys so you might lose access to your encrypted message history. It is recommended to backup your encryption keys before proceeding.")); dialog.show(); dialog.connect_response( clone!(@weak self as obj, @weak dialog => move |_, response| { diff --git a/src/session/account_settings/mod.rs b/src/session/account_settings/mod.rs index b50a0266..7d97a911 100644 --- a/src/session/account_settings/mod.rs +++ b/src/session/account_settings/mod.rs @@ -6,8 +6,10 @@ use gtk::{ }; mod devices_page; +mod security_page; mod user_page; use devices_page::DevicesPage; +use security_page::SecurityPage; use user_page::UserPage; use super::Session; @@ -35,6 +37,7 @@ mod imp { fn class_init(klass: &mut Self::Class) { DevicesPage::static_type(); UserPage::static_type(); + SecurityPage::static_type(); Self::bind_template(klass); klass.install_action("account-settings.close", None, |obj, _, _| { diff --git a/src/session/account_settings/security_page/import_export_keys_subpage.rs b/src/session/account_settings/security_page/import_export_keys_subpage.rs new file mode 100644 index 00000000..175a2fac --- /dev/null +++ b/src/session/account_settings/security_page/import_export_keys_subpage.rs @@ -0,0 +1,451 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gettextrs::gettext; +use gtk::{ + gio, + glib::{self, clone}, + CompositeTemplate, +}; +use log::error; +use matrix_sdk::encryption::{KeyExportError, RoomKeyImportError}; + +use crate::{ + components::{PasswordEntryRow, SpinnerButton}, + i18n::ngettext_f, + session::Session, + spawn, spawn_tokio, toast, +}; + +#[derive(Debug, Hash, Eq, PartialEq, Clone, Copy, glib::Enum)] +#[repr(u32)] +#[enum_type(name = "KeysSubpageMode")] +pub enum KeysSubpageMode { + Export = 0, + Import = 1, +} + +impl Default for KeysSubpageMode { + fn default() -> Self { + Self::Export + } +} + +mod imp { + use std::cell::{Cell, RefCell}; + + use glib::{subclass::InitializingObject, WeakRef}; + use once_cell::unsync::OnceCell; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Fractal/account-settings-import-export-keys-subpage.ui")] + pub struct ImportExportKeysSubpage { + pub session: OnceCell>, + #[template_child] + pub title: TemplateChild, + #[template_child] + pub description: TemplateChild, + #[template_child] + pub instructions: TemplateChild, + #[template_child] + pub passphrase: TemplateChild, + #[template_child] + pub confirm_passphrase: TemplateChild, + #[template_child] + pub file_row: TemplateChild, + #[template_child] + pub file_button: TemplateChild, + #[template_child] + pub proceed_button: TemplateChild, + pub file_path: RefCell>, + pub mode: Cell, + } + + #[glib::object_subclass] + impl ObjectSubclass for ImportExportKeysSubpage { + const NAME: &'static str = "ImportExportKeysSubpage"; + type Type = super::ImportExportKeysSubpage; + type ParentType = gtk::Box; + + fn class_init(klass: &mut Self::Class) { + klass.bind_template(); + Self::Type::bind_template_callbacks(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for ImportExportKeysSubpage { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![ + glib::ParamSpecObject::new( + "session", + "Session", + "The session", + Session::static_type(), + glib::ParamFlags::READWRITE, + ), + glib::ParamSpecString::new( + "file-path", + "File Path", + "The path to export the keys to", + None, + glib::ParamFlags::READABLE, + ), + glib::ParamSpecEnum::new( + "mode", + "Mode", + "The export/import mode of the subpage", + KeysSubpageMode::static_type(), + KeysSubpageMode::default() as i32, + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + ), + ] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "session" => obj.set_session(value.get().unwrap()), + "mode" => obj.set_mode(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => obj.session().to_value(), + "file-path" => obj + .file_path() + .and_then(|file| file.path()) + .map(|path| path.to_string_lossy().to_string()) + .to_value(), + "mode" => obj.mode().to_value(), + _ => unimplemented!(), + } + } + + fn constructed(&self, obj: &Self::Type) { + self.parent_constructed(obj); + + self.passphrase + .connect_changed(clone!(@weak obj => move|_| { + obj.update_button(); + })); + + self.confirm_passphrase + .connect_focused(clone!(@weak obj => move |entry, focused| { + if focused { + obj.validate_passphrase_confirmation(); + } else { + entry.remove_css_class("warning"); + entry.remove_css_class("success"); + } + })); + self.confirm_passphrase + .connect_changed(clone!(@weak obj => move|_| { + obj.validate_passphrase_confirmation(); + })); + + obj.update_for_mode(); + } + } + + impl WidgetImpl for ImportExportKeysSubpage {} + impl BoxImpl for ImportExportKeysSubpage {} +} + +glib::wrapper! { + /// Subpage to export room encryption keys for backup. + pub struct ImportExportKeysSubpage(ObjectSubclass) + @extends gtk::Widget, gtk::Box, @implements gtk::Accessible; +} + +#[gtk::template_callbacks] +impl ImportExportKeysSubpage { + pub fn new(session: &Session) -> Self { + glib::Object::new(&[("session", session)]) + .expect("Failed to create ImportExportKeysSubpage") + } + + pub fn session(&self) -> Option { + self.imp() + .session + .get() + .and_then(|session| session.upgrade()) + } + + pub fn set_session(&self, session: Option) { + if let Some(session) = session { + self.imp().session.set(session.downgrade()).unwrap(); + } + } + + pub fn file_path(&self) -> Option { + self.imp().file_path.borrow().clone() + } + + pub fn set_file_path(&self, path: Option) { + let priv_ = self.imp(); + if priv_.file_path.borrow().as_ref() == path.as_ref() { + return; + } + + priv_.file_path.replace(path); + self.update_button(); + self.notify("file-path"); + } + + pub fn mode(&self) -> KeysSubpageMode { + self.imp().mode.get() + } + + pub fn set_mode(&self, mode: KeysSubpageMode) { + if self.mode() == mode { + return; + } + + self.imp().mode.set(mode); + self.update_for_mode(); + self.clear(); + self.notify("mode"); + } + + fn clear(&self) { + let priv_ = self.imp(); + + self.set_file_path(None); + priv_.passphrase.set_text(""); + priv_.confirm_passphrase.set_text(""); + } + + fn update_for_mode(&self) { + let priv_ = self.imp(); + + if self.mode() == KeysSubpageMode::Export { + priv_ + .title + .set_label(&gettext("Export Room Encryption Keys")); + priv_.description.set_label(&gettext( + "Exporting your room encryption keys allows you to make a backup to be able to decrypt your messages in end-to-end encrypted rooms on another device or with another Matrix client.", + )); + priv_.instructions.set_label(&gettext( + "The backup must be stored in a safe place and must be protected with a strong passphrase that will be used to encrypt the data.", + )); + priv_.confirm_passphrase.show(); + priv_.proceed_button.set_label(&gettext("Export Keys")); + } else { + priv_ + .title + .set_label(&gettext("Import Room Encryption Keys")); + priv_.description.set_label(&gettext( + "Importing your room encryption keys allows you to decrypt your messages in end-to-end encrypted rooms with a previous backup from a Matrix client.", + )); + priv_.instructions.set_label(&gettext( + "Enter the passphrase provided when the backup file was created.", + )); + priv_.confirm_passphrase.hide(); + priv_.proceed_button.set_label(&gettext("Import Keys")); + } + + self.update_button(); + } + + #[template_callback] + fn handle_choose_file(&self) { + spawn!(clone!(@weak self as obj => async move { + obj.choose_file().await; + })); + } + + async fn choose_file(&self) { + let is_export = self.mode() == KeysSubpageMode::Export; + let (title, action) = if is_export { + ( + gettext("Save Encryption Keys To…"), + gtk::FileChooserAction::Save, + ) + } else { + ( + gettext("Import Encryption Keys From…"), + gtk::FileChooserAction::Open, + ) + }; + + let dialog = gtk::FileChooserNative::builder() + .title(&title) + .modal(true) + .transient_for( + self.root() + .as_ref() + .and_then(|root| root.downcast_ref::()) + .unwrap(), + ) + .action(action) + .accept_label(&gettext("Select")) + .cancel_label(&gettext("Cancel")) + .build(); + + if let Some(file) = self.file_path() { + let _ = dialog.set_file(&file); + } else if is_export { + // Translators: Do no translate "fractal" as it is the application + // name. + dialog.set_current_name(&format!("{}.txt", gettext("fractal-encryption-keys"))); + } + + if dialog.run_future().await == gtk::ResponseType::Accept { + if let Some(file) = dialog.file() { + self.set_file_path(Some(file)); + } else { + error!("No file chosen"); + toast!(self, gettext("No file was chosen")); + } + } + } + + fn validate_passphrase_confirmation(&self) { + let priv_ = self.imp(); + let entry = &priv_.confirm_passphrase; + let passphrase = priv_.passphrase.text(); + let confirmation = entry.text(); + + if confirmation.is_empty() { + entry.set_hint(""); + entry.remove_css_class("success"); + entry.remove_css_class("warning"); + return; + } + + if passphrase == confirmation { + entry.set_hint(""); + entry.add_css_class("success"); + entry.remove_css_class("warning"); + } else { + entry.remove_css_class("success"); + entry.add_css_class("warning"); + entry.set_hint(&gettext("Passphrases do not match")); + } + self.update_button(); + } + + fn update_button(&self) { + self.imp().proceed_button.set_sensitive(self.can_proceed()); + } + + fn can_proceed(&self) -> bool { + let priv_ = self.imp(); + let file_path = priv_.file_path.borrow(); + let passphrase = priv_.passphrase.text(); + + let mut res = file_path + .as_ref() + .filter(|file| file.path().is_some()) + .is_some() + && !passphrase.is_empty(); + + if self.mode() == KeysSubpageMode::Export { + let confirmation = priv_.confirm_passphrase.text(); + res = res && passphrase == confirmation; + } + + res + } + + #[template_callback] + fn handle_proceed(&self) { + spawn!(clone!(@weak self as obj => async move { + obj.proceed().await; + })); + } + + async fn proceed(&self) { + if !self.can_proceed() { + return; + } + + let priv_ = self.imp(); + let file_path = self.file_path().and_then(|file| file.path()).unwrap(); + let passphrase = priv_.passphrase.text(); + let is_export = self.mode() == KeysSubpageMode::Export; + + priv_.proceed_button.set_loading(true); + priv_.file_button.set_sensitive(false); + priv_.passphrase.set_entry_sensitive(false); + priv_.confirm_passphrase.set_entry_sensitive(false); + + let encryption = self.session().unwrap().client().encryption(); + + let handle = spawn_tokio!(async move { + if is_export { + encryption + .export_keys(file_path, passphrase.as_str(), |_| true) + .await + .map(|_| 0usize) + .map_err::, _>(|error| Box::new(error)) + } else { + encryption + .import_keys(file_path, passphrase.as_str()) + .await + .map(|res| res.imported_count) + .map_err::, _>(|error| Box::new(error)) + } + }); + + match handle.await.unwrap() { + Ok(nb) => { + if is_export { + toast!(self, gettext("Room encryption keys exported successfully")); + } else { + toast!( + self, + ngettext_f( + "Imported 1 room encryption key", + "Imported {n} room encryption keys", + nb as u32, + &[("n", &nb.to_string())] + ) + ); + } + self.clear(); + self.activate_action("win.close-subpage", None).unwrap(); + } + Err(err) => { + if is_export { + error!("Failed to export the keys: {err:?}"); + toast!(self, gettext("Could not export the keys")); + } else if err + .downcast_ref::() + .filter(|err| { + matches!(err, RoomKeyImportError::Export(KeyExportError::InvalidMac)) + }) + .is_some() + { + toast!( + self, + gettext("The passphrase doesn't match the one used to export the keys.") + ); + } else { + error!("Failed to import the keys: {err:?}"); + toast!(self, gettext("Could not import the keys")); + } + } + } + priv_.proceed_button.set_loading(false); + priv_.file_button.set_sensitive(true); + priv_.passphrase.set_entry_sensitive(true); + priv_.confirm_passphrase.set_entry_sensitive(true); + } +} diff --git a/src/session/account_settings/security_page/mod.rs b/src/session/account_settings/security_page/mod.rs new file mode 100644 index 00000000..7154e7ca --- /dev/null +++ b/src/session/account_settings/security_page/mod.rs @@ -0,0 +1,135 @@ +use adw::{prelude::*, subclass::prelude::*}; +use gtk::{glib, CompositeTemplate}; + +use crate::{components::ButtonRow, session::Session}; + +mod import_export_keys_subpage; +use import_export_keys_subpage::{ImportExportKeysSubpage, KeysSubpageMode}; + +mod imp { + use std::cell::RefCell; + + use glib::{subclass::InitializingObject, WeakRef}; + + use super::*; + + #[derive(Debug, Default, CompositeTemplate)] + #[template(resource = "/org/gnome/Fractal/account-settings-security-page.ui")] + pub struct SecurityPage { + pub session: RefCell>>, + #[template_child] + pub import_export_keys_subpage: TemplateChild, + } + + #[glib::object_subclass] + impl ObjectSubclass for SecurityPage { + const NAME: &'static str = "SecurityPage"; + type Type = super::SecurityPage; + type ParentType = adw::PreferencesPage; + + fn class_init(klass: &mut Self::Class) { + ButtonRow::static_type(); + Self::bind_template(klass); + Self::Type::bind_template_callbacks(klass); + } + + fn instance_init(obj: &InitializingObject) { + obj.init_template(); + } + } + + impl ObjectImpl for SecurityPage { + fn properties() -> &'static [glib::ParamSpec] { + use once_cell::sync::Lazy; + static PROPERTIES: Lazy> = Lazy::new(|| { + vec![glib::ParamSpecObject::new( + "session", + "Session", + "The session", + Session::static_type(), + glib::ParamFlags::READWRITE | glib::ParamFlags::EXPLICIT_NOTIFY, + )] + }); + + PROPERTIES.as_ref() + } + + fn set_property( + &self, + obj: &Self::Type, + _id: usize, + value: &glib::Value, + pspec: &glib::ParamSpec, + ) { + match pspec.name() { + "session" => obj.set_session(value.get().unwrap()), + _ => unimplemented!(), + } + } + + fn property(&self, obj: &Self::Type, _id: usize, pspec: &glib::ParamSpec) -> glib::Value { + match pspec.name() { + "session" => obj.session().to_value(), + _ => unimplemented!(), + } + } + } + + impl WidgetImpl for SecurityPage {} + impl PreferencesPageImpl for SecurityPage {} +} + +glib::wrapper! { + /// Security settings page. + pub struct SecurityPage(ObjectSubclass) + @extends gtk::Widget, adw::PreferencesPage, @implements gtk::Accessible; +} + +#[gtk::template_callbacks] +impl SecurityPage { + pub fn new(parent_window: &Option, session: &Session) -> Self { + glib::Object::new(&[("transient-for", parent_window), ("session", session)]) + .expect("Failed to create SecurityPage") + } + + pub fn session(&self) -> Option { + self.imp() + .session + .borrow() + .clone() + .and_then(|session| session.upgrade()) + } + + pub fn set_session(&self, session: Option) { + if self.session() == session { + return; + } + + self.imp() + .session + .replace(session.map(|session| session.downgrade())); + self.notify("session"); + } + + #[template_callback] + fn handle_export_keys(&self) { + let subpage = &*self.imp().import_export_keys_subpage; + subpage.set_mode(KeysSubpageMode::Export); + self.root() + .as_ref() + .and_then(|root| root.downcast_ref::()) + .unwrap() + .present_subpage(subpage); + } + + #[template_callback] + fn handle_import_keys(&self) { + let subpage = &*self.imp().import_export_keys_subpage; + subpage.set_mode(KeysSubpageMode::Import); + self.root() + .as_ref() + .and_then(|root| root.downcast_ref::()) + .unwrap() + .present_subpage(subpage); + } +}