From 8d3bd144b1fd0359d2b3d69e54aaacc1168f86e8 Mon Sep 17 00:00:00 2001 From: Romain Gallet Date: Fri, 29 Mar 2024 23:29:26 +0100 Subject: [PATCH] Import GAuth account exports (#142) * Import GAuth account exports * PO files update --- Cargo.lock | 33 ++++++++++++++++ Cargo.toml | 1 + data/resources/gtk/ui/error_popup.ui | 25 +++++++----- data/resources/gtk/ui/system_menu.ui | 49 +++++++++++++++++------ po/en_GB.po | 25 +++++++++++- po/fr.po | 25 +++++++++++- src/exporting.rs | 28 +++++++++++--- src/helpers/backup.rs | 58 +++++++++++++++++++++++++--- src/helpers/qr_code.rs | 33 +++++++++++++++- src/helpers/repository_error.rs | 1 + src/main.rs | 3 +- src/ui/add_group.rs | 16 ++++---- src/ui/edit_account_window.rs | 47 +++++----------------- src/ui/menu.rs | 28 +++++++------- 14 files changed, 274 insertions(+), 98 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 007c626f..3582d705 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -373,6 +373,7 @@ dependencies = [ "gio 0.19.3", "glib 0.19.3", "glib-macros 0.19.3", + "google_authenticator_converter", "gtk", "gtk-macros", "image", @@ -446,6 +447,12 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "bit_field" version = "0.10.2" @@ -1551,6 +1558,20 @@ dependencies = [ "system-deps", ] +[[package]] +name = "google_authenticator_converter" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea46886f208ca69afadd7b74e4bac43c66f4247c92f21b2b1a1533a706d61a7d" +dependencies = [ + "base32", + "base64", + "protobuf", + "serde", + "serde_json", + "urlencoding", +] + [[package]] name = "gtk" version = "0.18.1" @@ -2656,6 +2677,12 @@ dependencies = [ "syn 2.0.55", ] +[[package]] +name = "protobuf" +version = "2.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "106dd99e98437432fed6519dedecfade6a06a73bb7b2a1e019fdd2bee5778d94" + [[package]] name = "qoi" version = "0.4.1" @@ -3680,6 +3707,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "urlencoding" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" + [[package]] name = "utf-8" version = "0.7.6" diff --git a/Cargo.toml b/Cargo.toml index d6b3a448..42fcfbba 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,7 @@ gettext-rs = {version = "0", features = ["gettext-system"]} gio = "0.19" gtk-macros = "0.3" glib-macros = "0.19" +google_authenticator_converter = "0.2.0" image = "0" log = "0" log4rs = "1" diff --git a/data/resources/gtk/ui/error_popup.ui b/data/resources/gtk/ui/error_popup.ui index ce4ff192..8136f674 100644 --- a/data/resources/gtk/ui/error_popup.ui +++ b/data/resources/gtk/ui/error_popup.ui @@ -93,15 +93,23 @@ object-select-symbolic True - - - text/yaml - - - *.yaml - *.yml - + + + text/yaml + + + *.yaml + *.yml + + + + image/png + + + *.png + + dialog 1200 @@ -110,7 +118,6 @@ center-on-parent dialog save - yaml_filter False False diff --git a/data/resources/gtk/ui/system_menu.ui b/data/resources/gtk/ui/system_menu.ui index 624a4a46..6db81b68 100644 --- a/data/resources/gtk/ui/system_menu.ui +++ b/data/resources/gtk/ui/system_menu.ui @@ -1,5 +1,5 @@ - + @@ -15,9 +15,8 @@ True vertical - - import_button - True + + import_button_yaml True True 3 @@ -28,10 +27,10 @@ True - True False + import_button_yaml_tootlip start - Import accounts + Import YAML @@ -41,6 +40,32 @@ 0 + + + import_button_ga + True + True + 3 + 3 + 3 + 3 + True + True + + + False + import_button_gauth_tootlip + start + Import GAuth + + + + + False + True + 1 + + export_button @@ -57,15 +82,16 @@ True False + export_button_yaml_tootlip start - Export accounts + Export YAML False True - 1 + 2 @@ -105,7 +131,7 @@ False True - 2 + 3 @@ -118,7 +144,7 @@ False True - 3 + 4 @@ -145,7 +171,7 @@ False True - 4 + 5 @@ -160,7 +186,6 @@ - diff --git a/po/en_GB.po b/po/en_GB.po index e7a1d40b..c11d61fb 100644 --- a/po/en_GB.po +++ b/po/en_GB.po @@ -95,8 +95,23 @@ msgstr "Icon" msgid "Icon URL" msgstr "Icon URL" -msgid "Import accounts" -msgstr "Import accounts" +msgid "Import YAML" +msgstr "Import YAML" + +msgid "import_button_yaml_tootlip" +msgstr "Import accounts from a YAML file" + +msgid "Import GAuth" +msgstr "Import GAuth" + +msgid "import_button_gauth_tootlip" +msgstr "Import accounts from a Google Authenticator export file" + +msgid "Export YAML" +msgstr "Export YAML" + +msgid "export_button_yaml_tootlip" +msgstr "Export accounts to a YAML file" msgid "keyring_locked" msgstr "Gnome keyring is locked." @@ -154,3 +169,9 @@ msgstr "Collapse" msgid "Expand" msgstr "Expand" + +msgid "New group" +msgstr "New group" + +msgid "Edit group" +msgstr "Edit group" diff --git a/po/fr.po b/po/fr.po index 136d02fc..cfb9a996 100644 --- a/po/fr.po +++ b/po/fr.po @@ -95,8 +95,23 @@ msgstr "Icône" msgid "Icon URL" msgstr "URL de l'icône" -msgid "Import accounts" -msgstr "Importer" +msgid "Import YAML" +msgstr "Importer YAML" + +msgid "import_button_yaml_tootlip" +msgstr "Importer des comptes depuis un fichier YAML" + +msgid "Import GAuth" +msgstr "Importer GAuth" + +msgid "import_button_gauth_tootlip" +msgstr "Importer des comptes depuis l'application Google Authenticator" + +msgid "Export YAML" +msgstr "Exporter YAML" + +msgid "export_button_yaml_tootlip" +msgstr "Exportez vos comptes vers un fichier YAML" msgid "keyring_locked" msgstr "Gnome keyring est verrouillé." @@ -154,3 +169,9 @@ msgstr "Réduire" msgid "Expand" msgstr "Étendre" + +msgid "New group" +msgstr "Nouveau groupe" + +msgid "Edit group" +msgstr "Modifier le groupe" \ No newline at end of file diff --git a/src/exporting.rs b/src/exporting.rs index ff1d3348..9d127d2d 100644 --- a/src/exporting.rs +++ b/src/exporting.rs @@ -16,10 +16,16 @@ use crate::NAMESPACE_PREFIX; pub type AccountsImportExportResult = Result<(), RepositoryError>; type PopupButtonClosure = Box Option>; +#[derive(Debug, Clone)] +pub enum ImportType { + Internal, + GoogleAuthenticator, +} + pub trait Exporting { fn export_accounts(&self, popover: PopoverMenu, connection: Arc>) -> Box; - fn import_accounts(&self, popover: PopoverMenu, connection: Arc>) -> Box; + fn import_accounts(&self, import_type: ImportType, popover: PopoverMenu, connection: Arc>) -> Box; fn popup_close(popup: gtk::Window) -> PopupButtonClosure; } @@ -33,10 +39,14 @@ impl Exporting for MainWindow { get_widget!(builder, gtk::FileChooserDialog, dialog); get_widget!(builder, gtk::Window, error_popup); get_widget!(builder, gtk::Label, error_popup_body); + get_widget!(builder, gtk::FileFilter, yaml_filter); + + dialog.set_filter(&yaml_filter); dialog.set_do_overwrite_confirmation(true); error_popup_body.set_label(&gettext("Could not export accounts!")); + builder.connect_signals(clone!(@strong error_popup => move |_, handler_name| match handler_name { "export_account_error_close" => Self::popup_close(error_popup.clone()), _ => Box::new(|_| None), @@ -69,7 +79,7 @@ impl Exporting for MainWindow { })) } - fn import_accounts(&self, popover: PopoverMenu, connection: Arc>) -> Box { + fn import_accounts(&self, import_type: ImportType, popover: PopoverMenu, connection: Arc>) -> Box { Box::new(clone!(@strong self as gui => move |_b: &Button| { popover.set_visible(false); @@ -78,6 +88,13 @@ impl Exporting for MainWindow { get_widget!(builder, gtk::FileChooserDialog, dialog); get_widget!(builder, gtk::Window, error_popup); get_widget!(builder, gtk::Label, error_popup_body); + get_widget!(builder, gtk::FileFilter, import_filter); + get_widget!(builder, gtk::FileFilter, import_filter_ga); + + match import_type { + ImportType::Internal => dialog.set_filter(&import_filter), + ImportType::GoogleAuthenticator => dialog.set_filter(&import_filter_ga), + } error_popup.set_title(&gettext("Error")); error_popup_body.set_label(&gettext("Could not import accounts!")); @@ -98,8 +115,7 @@ impl Exporting for MainWindow { let (tx, rx) = async_channel::bounded::(1); glib::spawn_future_local(clone!(@strong gui, @strong connection => async move { - let result = rx.recv().await.unwrap(); - match result { + match rx.recv().await.unwrap() { Ok(_) => { gui.accounts_window.refresh_accounts(&gui, connection.clone()); gui.accounts_window.accounts_container.set_sensitive(true); @@ -111,8 +127,8 @@ impl Exporting for MainWindow { } })); - glib::spawn_future_local(clone!(@strong connection, @strong path, @strong tx => async move { - Backup::restore_account_and_signal_back(path, connection, tx).await + glib::spawn_future_local(clone!(@strong connection, @strong path, @strong import_type, @strong tx => async move { + Backup::restore_account_and_signal_back(import_type, path, connection, tx).await })); } _ => dialog.close(), diff --git a/src/helpers/backup.rs b/src/helpers/backup.rs index 88f906b0..b779f275 100644 --- a/src/helpers/backup.rs +++ b/src/helpers/backup.rs @@ -2,11 +2,14 @@ use std::io::Write; use std::path::{Path, PathBuf}; use std::sync::{Arc, Mutex}; +use glib_macros::clone; +use log::warn; use rusqlite::Connection; -use crate::exporting::AccountsImportExportResult; -use crate::helpers::{Database, Keyring, Paths, RepositoryError, SecretType}; -use crate::model::AccountGroup; +use crate::exporting::{AccountsImportExportResult, ImportType}; +use crate::helpers::RepositoryError::GAuthQrCodeError; +use crate::helpers::{Database, Keyring, Paths, QrCode, QrCodeResult, RepositoryError, SecretType}; +use crate::model::{Account, AccountGroup}; pub struct Backup; @@ -48,8 +51,16 @@ impl Backup { }) } - pub async fn restore_account_and_signal_back(path: PathBuf, connection: Arc>, tx: async_channel::Sender) { - let db = Self::restore_accounts(path, connection.clone()).await; + pub async fn restore_account_and_signal_back( + import_type: ImportType, + path: PathBuf, + connection: Arc>, + tx: async_channel::Sender, + ) { + let db = match import_type { + ImportType::Internal => Self::restore_accounts(path, connection.clone()).await, + ImportType::GoogleAuthenticator => Self::restore_gauth_accounts(path, connection.clone()).await, + }; match db.and_then(|_| Paths::update_keyring_secrets(connection)) { Ok(_) => tx.send(Ok(())).await.expect("Could not send message"), @@ -73,6 +84,43 @@ impl Backup { Ok(()) } + async fn restore_gauth_accounts(path: PathBuf, connection: Arc>) -> Result<(), RepositoryError> { + use google_authenticator_converter::process_data; + + let (tx, rx) = async_channel::bounded::(1); + + glib::spawn_future_local(clone!(@strong tx => async move { + QrCode::process_qr_code(path.to_str().unwrap().to_owned(), tx).await + })); + + match rx.recv().await.unwrap() { + QrCodeResult::Valid(qr_code) => { + let accounts = process_data(qr_code.qr_code_payload.as_str()); + + let entries = accounts + .unwrap() + .iter() + .map(|account| { + let secret = account.secret.clone(); + let secret_type = SecretType::LOCAL; + Account::new(0, 0, &account.name, &secret, secret_type) + }) + .collect(); + + let mut account_groups = AccountGroup::new(0, "GAuth", None, None, false, entries); + + let connection = connection.lock().unwrap(); + Database::save_group_and_accounts(&connection, &mut account_groups)?; + + Ok(()) + } + QrCodeResult::Invalid(e) => { + warn!("Invalid GAuth QR code: {}", e); + Err(GAuthQrCodeError(format!("Invalid GAuth code: {}", e))) + } + } + } + fn deserialise_accounts(out: &Path) -> Result, RepositoryError> { let file = std::fs::File::open(out).map_err(RepositoryError::IoError); diff --git a/src/helpers/qr_code.rs b/src/helpers/qr_code.rs index f1048fa1..a1347242 100644 --- a/src/helpers/qr_code.rs +++ b/src/helpers/qr_code.rs @@ -1,12 +1,15 @@ +use crate::helpers::QrCodeResult::{Invalid, Valid}; +use log::warn; use regex::Regex; +use rqrr::PreparedImage; -#[derive(PartialEq)] +#[derive(PartialEq, Debug)] pub enum QrCodeResult { Valid(QrCode), Invalid(String), } -#[derive(PartialEq)] +#[derive(PartialEq, Debug)] pub struct QrCode { pub qr_code_payload: String, } @@ -25,6 +28,32 @@ impl QrCode { secret.unwrap_or(self.qr_code_payload.as_str()) } + + pub async fn process_qr_code(path: String, tx: async_channel::Sender) { + let _ = match image::open(&path).map(|v| v.to_luma8()) { + Ok(img) => { + let mut luma = PreparedImage::prepare(img); + let grids = luma.detect_grids(); + + if grids.len() != 1 { + warn!("No grids found in {}", path); + tx.send(Invalid("Invalid QR code".to_owned())).await + } else { + match grids[0].decode() { + Ok((_, content)) => tx.send(Valid(QrCode::new(content))).await, + Err(e) => { + warn!("{}", e); + tx.send(Invalid("Invalid QR code".to_owned())).await + } + } + } + } + Err(e) => { + warn!("{}", e); + tx.send(Invalid("Invalid QR code".to_owned())).await + } + }; + } } #[cfg(test)] diff --git a/src/helpers/repository_error.rs b/src/helpers/repository_error.rs index c42f8c10..18d255b7 100644 --- a/src/helpers/repository_error.rs +++ b/src/helpers/repository_error.rs @@ -6,6 +6,7 @@ use thiserror::Error; #[error("{0}")] #[allow(clippy::enum_variant_names)] pub enum RepositoryError { + GAuthQrCodeError(String), SqlError(#[from] rusqlite::Error), IoError(#[from] io::Error), SerialisationError(#[from] serde_yaml::Error), diff --git a/src/main.rs b/src/main.rs index b1a47e9c..1f6483df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,7 +25,6 @@ const NAMESPACE: &str = "uk.co.grumlimited.authenticator-rs"; const NAMESPACE_PREFIX: &str = "/uk/co/grumlimited/authenticator-rs"; const GETTEXT_PACKAGE: &str = "authenticator-rs"; -const LOCALEDIR: &str = "/usr/share/locale"; fn main() { match Paths::check_configuration_dir() { @@ -54,8 +53,8 @@ fn main() { // Prepare i18n setlocale(LocaleCategory::LcAll, ""); - bindtextdomain(GETTEXT_PACKAGE, LOCALEDIR).unwrap(); textdomain(GETTEXT_PACKAGE).unwrap(); + bind_textdomain_codeset(GETTEXT_PACKAGE, "UTF-8").unwrap(); configure_logging(); diff --git a/src/ui/add_group.rs b/src/ui/add_group.rs index 8c4330c3..49d59651 100644 --- a/src/ui/add_group.rs +++ b/src/ui/add_group.rs @@ -156,18 +156,18 @@ impl AddGroupWindow { })); glib::spawn_future_local(clone!(@strong self as add_group => async move { - let account_group_icon = rx.recv().await.unwrap(); - - add_group.icon_reload.set_sensitive(true); - add_group.save_button.set_sensitive(true); - - match account_group_icon { - Ok(account_group_icon) => Self::write_tmp_icon(&state, &add_group.icon_filename, &add_group.image_input, account_group_icon.content.as_slice()), - Err(e) => { + match rx.recv().await { + Ok(Ok(account_group_icon)) => + Self::write_tmp_icon(&state, &add_group.icon_filename, &add_group.image_input, account_group_icon.content.as_slice()), + Ok(Err(e)) => { add_group.icon_error.set_label(format!("{}", e).as_str()); add_group.icon_error.set_visible(true); } + Err(e) => warn!("Channel is closed. Application terminated?: {:?}", e), } + + add_group.icon_reload.set_sensitive(true); + add_group.save_button.set_sensitive(true); })); { diff --git a/src/ui/edit_account_window.rs b/src/ui/edit_account_window.rs index 28bc7bb6..f6090cab 100644 --- a/src/ui/edit_account_window.rs +++ b/src/ui/edit_account_window.rs @@ -7,7 +7,6 @@ use gtk::prelude::*; use gtk::{Builder, EntryIconPosition, StateFlags}; use log::{debug, error, warn}; use regex::Regex; -use rqrr::PreparedImage; use rusqlite::Connection; use crate::helpers::QrCodeResult::{Invalid, Valid}; @@ -153,32 +152,6 @@ impl EditAccountWindow { } } - async fn process_qr_code(path: String, tx: async_channel::Sender) { - let _ = match image::open(&path).map(|v| v.to_luma8()) { - Ok(img) => { - let mut luma = PreparedImage::prepare(img); - let grids = luma.detect_grids(); - - if grids.len() != 1 { - warn!("No grids found in {}", path); - tx.send(Invalid("Invalid QR code".to_owned())).await - } else { - match grids[0].decode() { - Ok((_, content)) => tx.send(Valid(QrCode::new(content))).await, - Err(e) => { - warn!("{}", e); - tx.send(Invalid("Invalid QR code".to_owned())).await - } - } - } - } - Err(e) => { - warn!("{}", e); - tx.send(Invalid("Invalid QR code".to_owned())).await - } - }; - } - fn qrcode_action(&self, pool: ThreadPool) { let qr_button = self.qr_button.clone(); let dialog = self.image_dialog.clone(); @@ -188,21 +161,21 @@ impl EditAccountWindow { let (tx, rx) = async_channel::bounded::(1); glib::spawn_future_local(clone!(@strong save_button, @strong input_secret, @strong self as w, @strong rx => async move { - let qr_code_result = rx.recv().await.unwrap(); - let buffer = input_secret.buffer().unwrap(); - - w.reset_errors(); - save_button.set_sensitive(true); - - match qr_code_result { - Valid(qr_code) => { + match rx.recv().await { + Ok(Valid(qr_code)) => { + let buffer = input_secret.buffer().unwrap(); buffer.set_text(qr_code.extract()); } - Invalid(qr_code) => { + Ok(Invalid(qr_code)) => { + let buffer = input_secret.buffer().unwrap(); buffer.set_text(&gettext(qr_code)); } + Err(e) => warn!("Channel is closed. Application terminated?: {:?}", e), } + w.reset_errors(); + save_button.set_sensitive(true); + w.validate() })); @@ -218,7 +191,7 @@ impl EditAccountWindow { save_button.set_sensitive(false); dialog.hide(); - pool.spawn_ok(Self::process_qr_code(path.to_str().unwrap().to_owned(), tx)); + pool.spawn_ok(QrCode::process_qr_code(path.to_str().unwrap().to_owned(), tx)); } _ => dialog.hide(), } diff --git a/src/ui/menu.rs b/src/ui/menu.rs index 8e23ed2e..dcfe92fa 100644 --- a/src/ui/menu.rs +++ b/src/ui/menu.rs @@ -1,13 +1,14 @@ +use gettextrs::gettext; use std::sync::{Arc, Mutex}; use gio::prelude::*; use glib::clone; use gtk::prelude::*; -use gtk::{Button, MenuButton}; +use gtk::{Builder, Button, MenuButton, PopoverMenu}; use gtk_macros::get_widget; use rusqlite::Connection; -use crate::exporting::Exporting; +use crate::exporting::{Exporting, ImportType}; use crate::main_window::{Display, MainWindow}; use crate::ui::{AccountsWindow, AddGroupWindow}; use crate::{NAMESPACE, NAMESPACE_PREFIX}; @@ -37,8 +38,8 @@ impl Menus for MainWindow { } fn build_search_button(&mut self, connection: Arc>) -> Button { - let builder = gtk::Builder::from_resource(format!("{}/{}", NAMESPACE_PREFIX, "system_menu.ui").as_str()); - get_widget!(builder, gtk::Button, search_button); + let builder = Builder::from_resource(format!("{}/{}", NAMESPACE_PREFIX, "system_menu.ui").as_str()); + get_widget!(builder, Button, search_button); search_button.connect_clicked(clone!(@strong self as gui, @strong self.accounts_window.filter as filter => move |_| { if WidgetExt::is_visible(&filter) { @@ -73,11 +74,13 @@ impl Menus for MainWindow { } fn build_system_menu(&mut self, connection: Arc>) -> MenuButton { - let builder = gtk::Builder::from_resource(format!("{}/{}", NAMESPACE_PREFIX, "system_menu.ui").as_str()); + let builder = Builder::from_resource(format!("{}/{}", NAMESPACE_PREFIX, "system_menu.ui").as_str()); - get_widget!(builder, gtk::PopoverMenu, popover); - get_widget!(builder, gtk::Button, about_button); - get_widget!(builder, gtk::Button, export_button); + get_widget!(builder, PopoverMenu, popover); + get_widget!(builder, Button, about_button); + get_widget!(builder, Button, export_button); + get_widget!(builder, Button, import_button_yaml); + get_widget!(builder, Button, import_button_ga); let dark_mode_slider: gtk::Switch = { let switch: gtk::Switch = builder.object("dark_mode_slider").unwrap(); @@ -100,17 +103,16 @@ impl Menus for MainWindow { export_button.connect_clicked(self.export_accounts(popover.clone(), connection.clone())); - let import_button: gtk::Button = builder.object("import_button").unwrap(); - - import_button.connect_clicked(self.import_accounts(popover.clone(), connection)); + import_button_yaml.connect_clicked(self.import_accounts(ImportType::Internal, popover.clone(), connection.clone())); + import_button_ga.connect_clicked(self.import_accounts(ImportType::GoogleAuthenticator, popover.clone(), connection)); - let system_menu: gtk::MenuButton = builder.object("system_menu").unwrap(); + let system_menu: MenuButton = builder.object("system_menu").unwrap(); system_menu.connect_clicked(clone!(@strong popover => move |_| { popover.show_all(); })); - let titlebar = gtk::HeaderBar::builder().decoration_layout(":").title("About").build(); + let titlebar = gtk::HeaderBar::builder().decoration_layout(":").title(gettext("About")).build(); self.about_popup.set_titlebar(Some(&titlebar));