account-settings: Allow to import and export room encryption keys

Part-of: <https://gitlab.gnome.org/GNOME/fractal/-/merge_requests/1157>
This commit is contained in:
Kévin Commaille 2022-09-15 18:52:14 +02:00 committed by Kévin Commaille
parent dc33441fad
commit 8dd205ffce
10 changed files with 739 additions and 2 deletions

View file

@ -26,6 +26,8 @@
<file compressed="true" preprocess="xml-stripblanks" alias="account-settings-deactivate-account-subpage.ui">ui/account-settings-deactivate-account-subpage.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="account-settings-device-row.ui">ui/account-settings-device-row.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="account-settings-devices-page.ui">ui/account-settings-devices-page.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="account-settings-import-export-keys-subpage.ui">ui/account-settings-import-export-keys-subpage.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="account-settings-security-page.ui">ui/account-settings-security-page.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="account-settings-user-page.ui">ui/account-settings-user-page.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="account-settings.ui">ui/account-settings.ui</file>
<file compressed="true" preprocess="xml-stripblanks" alias="attachment-dialog.ui">ui/attachment-dialog.ui</file>

View file

@ -61,7 +61,7 @@
<style>
<class name="body"/>
</style>
<property name="label">Fractals 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.</property>
<property name="label">Fractals 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.</property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>

View file

@ -0,0 +1,108 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="ImportExportKeysSubpage" parent="GtkBox">
<property name="orientation">vertical</property>
<child>
<object class="GtkHeaderBar">
<property name="title-widget">
<object class="GtkLabel" id="title">
<property name="single-line-mode">True</property>
<property name="ellipsize">end</property>
<property name="width-chars">5</property>
<style>
<class name="title"/>
</style>
</object>
</property>
<child type="start">
<object class="GtkButton" id="back">
<property name="icon-name">go-previous-symbolic</property>
<property name="action-name">win.close-subpage</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesPage">
<style>
<class name="status-page"/>
</style>
<property name="vexpand">true</property>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="GtkLabel" id="description">
<style>
<class name="body"/>
</style>
<property name="label"></property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
</object>
</child>
<child>
<object class="GtkLabel" id="instructions">
<style>
<class name="body"/>
</style>
<property name="label"></property>
<property name="wrap">True</property>
<property name="wrap-mode">word-char</property>
<property name="xalign">0.0</property>
<property name="margin-top">12</property>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="ComponentsPasswordEntryRow" id="passphrase">
<property name="title" translatable="yes">Passphrase</property>
<signal name="activated" handler="handle_proceed" swapped="yes"/>
</object>
</child>
<child>
<object class="ComponentsPasswordEntryRow" id="confirm_passphrase">
<property name="title" translatable="yes">Confirm Passphrase</property>
<signal name="activated" handler="handle_proceed" swapped="yes"/>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="AdwActionRow" id="file_row">
<property name="title">File</property>
<property name="subtitle" bind-source="ImportExportKeysSubpage" bind-property="file-path" bind-flags="sync-create"/>
<child>
<object class="GtkButton" id="file_button">
<property name="label" translatable="yes">Choose…</property>
<property name="valign">center</property>
<signal name="clicked" handler="handle_choose_file" swapped="yes"/>
</object>
</child>
</object>
</child>
</object>
</child>
<child>
<object class="AdwPreferencesGroup">
<child>
<object class="SpinnerButton" id="proceed_button">
<style>
<class name="row"/>
<class name="suggested-action"/>
</style>
<property name="sensitive">false</property>
<signal name="clicked" handler="handle_proceed" swapped="yes"/>
</object>
</child>
</object>
</child>
</object>
</child>
</template>
</interface>

View file

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<interface>
<template class="SecurityPage" parent="AdwPreferencesPage">
<property name="icon-name">channel-secure-symbolic</property>
<property name="title" translatable="yes">Security</property>
<property name="name">security</property>
<child>
<object class="AdwPreferencesGroup">
<property name="title" translatable="yes">Room Encryption Keys</property>
<child>
<object class="ComponentsButtonRow">
<property name="title" translatable="yes">Export Room Encryption Keys</property>
<property name="to-subpage">true</property>
<signal name="activated" handler="handle_export_keys" swapped="yes"/>
</object>
</child>
<child>
<object class="ComponentsButtonRow">
<property name="title" translatable="yes">Import Room Encryption Keys</property>
<property name="to-subpage">true</property>
<signal name="activated" handler="handle_import_keys" swapped="yes"/>
</object>
</child>
</object>
</child>
</template>
<object class="ImportExportKeysSubpage" id="import_export_keys_subpage">
<property name="session" bind-source="SecurityPage" bind-property="session" bind-flags="sync-create"/>
</object>
</interface>

View file

@ -18,5 +18,10 @@
</binding>
</object>
</child>
<child>
<object class="SecurityPage">
<property name="session" bind-source="AccountSettings" bind-property="session" bind-flags="sync-create"/>
</object>
</child>
</template>
</interface>

View file

@ -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

View file

@ -225,7 +225,7 @@ impl DeviceRow {
self.imp().delete_logout_button.set_loading(true);
let window: Option<gtk::Window> = 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("Fractals 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| {

View file

@ -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, _, _| {

View file

@ -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<WeakRef<Session>>,
#[template_child]
pub title: TemplateChild<gtk::Label>,
#[template_child]
pub description: TemplateChild<gtk::Label>,
#[template_child]
pub instructions: TemplateChild<gtk::Label>,
#[template_child]
pub passphrase: TemplateChild<PasswordEntryRow>,
#[template_child]
pub confirm_passphrase: TemplateChild<PasswordEntryRow>,
#[template_child]
pub file_row: TemplateChild<adw::ActionRow>,
#[template_child]
pub file_button: TemplateChild<gtk::Button>,
#[template_child]
pub proceed_button: TemplateChild<SpinnerButton>,
pub file_path: RefCell<Option<gio::File>>,
pub mode: Cell<KeysSubpageMode>,
}
#[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<Self>) {
obj.init_template();
}
}
impl ObjectImpl for ImportExportKeysSubpage {
fn properties() -> &'static [glib::ParamSpec] {
use once_cell::sync::Lazy;
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = 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<imp::ImportExportKeysSubpage>)
@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<Session> {
self.imp()
.session
.get()
.and_then(|session| session.upgrade())
}
pub fn set_session(&self, session: Option<Session>) {
if let Some(session) = session {
self.imp().session.set(session.downgrade()).unwrap();
}
}
pub fn file_path(&self) -> Option<gio::File> {
self.imp().file_path.borrow().clone()
}
pub fn set_file_path(&self, path: Option<gio::File>) {
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::<gtk::Window>())
.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::<Box<dyn std::error::Error + Send>, _>(|error| Box::new(error))
} else {
encryption
.import_keys(file_path, passphrase.as_str())
.await
.map(|res| res.imported_count)
.map_err::<Box<dyn std::error::Error + Send>, _>(|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::<RoomKeyImportError>()
.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);
}
}

View file

@ -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<Option<WeakRef<Session>>>,
#[template_child]
pub import_export_keys_subpage: TemplateChild<ImportExportKeysSubpage>,
}
#[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<Self>) {
obj.init_template();
}
}
impl ObjectImpl for SecurityPage {
fn properties() -> &'static [glib::ParamSpec] {
use once_cell::sync::Lazy;
static PROPERTIES: Lazy<Vec<glib::ParamSpec>> = 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<imp::SecurityPage>)
@extends gtk::Widget, adw::PreferencesPage, @implements gtk::Accessible;
}
#[gtk::template_callbacks]
impl SecurityPage {
pub fn new(parent_window: &Option<gtk::Window>, session: &Session) -> Self {
glib::Object::new(&[("transient-for", parent_window), ("session", session)])
.expect("Failed to create SecurityPage")
}
pub fn session(&self) -> Option<Session> {
self.imp()
.session
.borrow()
.clone()
.and_then(|session| session.upgrade())
}
pub fn set_session(&self, session: Option<Session>) {
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::<adw::PreferencesWindow>())
.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::<adw::PreferencesWindow>())
.unwrap()
.present_subpage(subpage);
}
}