Skip to main content

fractal/components/crypto/
recovery_setup_view.rs

1use adw::{prelude::*, subclass::prelude::*};
2use gettextrs::gettext;
3use gtk::{glib, glib::closure_local};
4use matrix_sdk::encryption::{
5    recovery::{RecoveryError, RecoveryState as SdkRecoveryState},
6    secret_storage::SecretStorageError,
7};
8use tracing::{debug, error, warn};
9
10use crate::{
11    components::{AuthDialog, AuthError, LoadingButton, SwitchLoadingRow},
12    session::{RecoveryState, Session},
13    spawn_tokio, toast,
14};
15
16/// A page of the [`CryptoRecoverySetupView`] navigation stack.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18enum CryptoRecoverySetupPage {
19    /// Use account recovery.
20    Recover,
21    /// Reset the recovery and optionally the cross-signing.
22    Reset,
23    /// Enable recovery.
24    Enable,
25    /// The recovery was successfully enabled.
26    Success,
27    /// The recovery was successful but is still incomplete.
28    Incomplete,
29}
30
31impl CryptoRecoverySetupPage {
32    /// Get the tag for this page.
33    const fn tag(self) -> &'static str {
34        match self {
35            Self::Recover => "recover",
36            Self::Reset => "reset",
37            Self::Enable => "enable",
38            Self::Success => "success",
39            Self::Incomplete => "incomplete",
40        }
41    }
42
43    /// Get the page matching the given tag.
44    ///
45    /// Panics if the tag does not match any variant.
46    fn from_tag(tag: &str) -> Self {
47        match tag {
48            "recover" => Self::Recover,
49            "reset" => Self::Reset,
50            "enable" => Self::Enable,
51            "success" => Self::Success,
52            "incomplete" => Self::Incomplete,
53            _ => panic!("Unknown CryptoRecoverySetupPage: {tag}"),
54        }
55    }
56}
57
58/// The initial page of the [`CryptoRecoverySetupView`].
59#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
60pub enum CryptoRecoverySetupInitialPage {
61    /// Use account recovery.
62    #[default]
63    Recover,
64    /// Reset the account recovery recovery.
65    Reset,
66    /// Enable recovery.
67    Enable,
68}
69
70impl From<CryptoRecoverySetupInitialPage> for CryptoRecoverySetupPage {
71    fn from(value: CryptoRecoverySetupInitialPage) -> Self {
72        match value {
73            CryptoRecoverySetupInitialPage::Recover => Self::Recover,
74            CryptoRecoverySetupInitialPage::Reset => Self::Reset,
75            CryptoRecoverySetupInitialPage::Enable => Self::Enable,
76        }
77    }
78}
79
80mod imp {
81    use std::sync::LazyLock;
82
83    use glib::subclass::{InitializingObject, Signal};
84
85    use super::*;
86
87    #[derive(Debug, Default, gtk::CompositeTemplate, glib::Properties)]
88    #[template(resource = "/org/gnome/Fractal/ui/components/crypto/recovery_setup_view.ui")]
89    #[properties(wrapper_type = super::CryptoRecoverySetupView)]
90    pub struct CryptoRecoverySetupView {
91        #[template_child]
92        navigation: TemplateChild<adw::NavigationView>,
93        #[template_child]
94        recover_entry: TemplateChild<adw::PasswordEntryRow>,
95        #[template_child]
96        recover_btn: TemplateChild<LoadingButton>,
97        #[template_child]
98        reset_page: TemplateChild<adw::NavigationPage>,
99        #[template_child]
100        reset_identity_row: TemplateChild<SwitchLoadingRow>,
101        #[template_child]
102        reset_backup_row: TemplateChild<SwitchLoadingRow>,
103        #[template_child]
104        reset_entry: TemplateChild<adw::PasswordEntryRow>,
105        #[template_child]
106        reset_btn: TemplateChild<LoadingButton>,
107        #[template_child]
108        enable_entry: TemplateChild<adw::PasswordEntryRow>,
109        #[template_child]
110        enable_btn: TemplateChild<LoadingButton>,
111        #[template_child]
112        success_description: TemplateChild<gtk::Label>,
113        #[template_child]
114        success_key_box: TemplateChild<gtk::Box>,
115        #[template_child]
116        success_key_label: TemplateChild<gtk::Label>,
117        #[template_child]
118        success_key_copy_btn: TemplateChild<gtk::Button>,
119        #[template_child]
120        success_confirm_btn: TemplateChild<gtk::Button>,
121        #[template_child]
122        incomplete_confirm_btn: TemplateChild<gtk::Button>,
123        /// The current session.
124        #[property(get, set = Self::set_session, construct_only)]
125        session: glib::WeakRef<Session>,
126    }
127
128    #[glib::object_subclass]
129    impl ObjectSubclass for CryptoRecoverySetupView {
130        const NAME: &'static str = "CryptoRecoverySetupView";
131        type Type = super::CryptoRecoverySetupView;
132        type ParentType = adw::Bin;
133
134        fn class_init(klass: &mut Self::Class) {
135            Self::bind_template(klass);
136            Self::bind_template_callbacks(klass);
137
138            klass.set_css_name("setup-view");
139        }
140
141        fn instance_init(obj: &InitializingObject<Self>) {
142            obj.init_template();
143        }
144    }
145
146    #[glib::derived_properties]
147    impl ObjectImpl for CryptoRecoverySetupView {
148        fn signals() -> &'static [Signal] {
149            static SIGNALS: LazyLock<Vec<Signal>> = LazyLock::new(|| {
150                vec![
151                    // Recovery is enabled.
152                    Signal::builder("completed").build(),
153                ]
154            });
155            SIGNALS.as_ref()
156        }
157    }
158
159    impl WidgetImpl for CryptoRecoverySetupView {
160        fn grab_focus(&self) -> bool {
161            match self.visible_page() {
162                CryptoRecoverySetupPage::Recover => self.recover_entry.grab_focus(),
163                CryptoRecoverySetupPage::Reset => self.reset_entry.grab_focus(),
164                CryptoRecoverySetupPage::Enable => self.enable_entry.grab_focus(),
165                CryptoRecoverySetupPage::Success => self.success_confirm_btn.grab_focus(),
166                CryptoRecoverySetupPage::Incomplete => self.incomplete_confirm_btn.grab_focus(),
167            }
168        }
169    }
170
171    impl BinImpl for CryptoRecoverySetupView {}
172
173    #[gtk::template_callbacks]
174    impl CryptoRecoverySetupView {
175        /// The visible page of the view.
176        fn visible_page(&self) -> CryptoRecoverySetupPage {
177            CryptoRecoverySetupPage::from_tag(
178                &self
179                    .navigation
180                    .visible_page()
181                    .expect(
182                        "CryptoRecoverySetupView navigation view should always have a visible page",
183                    )
184                    .tag()
185                    .expect("CryptoRecoverySetupView navigation page should always have a tag"),
186            )
187        }
188
189        /// Set the current session.
190        fn set_session(&self, session: &Session) {
191            self.session.set(Some(session));
192
193            let security = session.security();
194            let recovery_state = security.recovery_state();
195            let initial_page = match recovery_state {
196                RecoveryState::Unknown | RecoveryState::Disabled
197                    if !security.backup_exists_on_server() =>
198                {
199                    CryptoRecoverySetupInitialPage::Enable
200                }
201                RecoveryState::Unknown | RecoveryState::Disabled | RecoveryState::Enabled => {
202                    CryptoRecoverySetupInitialPage::Reset
203                }
204                RecoveryState::Incomplete => CryptoRecoverySetupInitialPage::Recover,
205            };
206
207            self.update_reset();
208            self.set_initial_page(initial_page);
209        }
210
211        /// Update the reset page for the current state.
212        fn update_reset(&self) {
213            let Some(session) = self.session.upgrade() else {
214                return;
215            };
216
217            let security = session.security();
218            let (required, description) = if security.cross_signing_keys_available() {
219                (
220                    false,
221                    gettext("Invalidates the verifications of all users and sessions"),
222                )
223            } else {
224                (
225                    true,
226                    gettext(
227                        "Required because the crypto identity in the recovery data is incomplete. Invalidates the verifications of all users and sessions.",
228                    ),
229                )
230            };
231            self.reset_identity_row.set_read_only(required);
232            self.reset_identity_row.set_is_active(required);
233            self.reset_identity_row.set_subtitle(&description);
234
235            let (required, description) = if security.backup_enabled() {
236                (
237                    false,
238                    gettext("You might not be able to read your past encrypted messages anymore"),
239                )
240            } else {
241                (
242                    true,
243                    gettext(
244                        "Required because the backup is not set up properly. You might not be able to read your past encrypted messages anymore.",
245                    ),
246                )
247            };
248            self.reset_backup_row.set_read_only(required);
249            self.reset_backup_row.set_is_active(required);
250            self.reset_backup_row.set_subtitle(&description);
251        }
252
253        /// Set the initial page of this view.
254        pub(super) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
255            self.navigation
256                .replace_with_tags(&[CryptoRecoverySetupPage::from(initial_page).tag()]);
257        }
258
259        /// Update the success page for the given recovery key.
260        fn update_success(&self, key: Option<String>) {
261            let has_key = key.is_some();
262
263            let description = if has_key {
264                gettext(
265                    "Make sure to store this recovery key in a safe place. You will need it to recover your account if you lose access to all your sessions.",
266                )
267            } else {
268                gettext(
269                    "Make sure to remember your passphrase or to store it in a safe place. You will need it to recover your account if you lose access to all your sessions.",
270                )
271            };
272            self.success_description.set_label(&description);
273
274            if let Some(key) = key {
275                self.success_key_label.set_label(&key);
276            }
277            self.success_key_box.set_visible(has_key);
278        }
279
280        /// Focus the proper widget for the current page.
281        #[template_callback]
282        fn grab_focus(&self) {
283            <Self as WidgetImpl>::grab_focus(self);
284        }
285
286        /// The content of the recover entry changed.
287        #[template_callback]
288        fn recover_entry_changed(&self) {
289            let can_recover = !self.recover_entry.text().is_empty();
290            self.recover_btn.set_sensitive(can_recover);
291        }
292
293        /// Recover the data.
294        #[template_callback]
295        async fn recover(&self) {
296            let Some(session) = self.session.upgrade() else {
297                return;
298            };
299
300            let key = self.recover_entry.text();
301
302            if key.is_empty() {
303                return;
304            }
305
306            self.recover_btn.set_is_loading(true);
307
308            let encryption = session.client().encryption();
309            let recovery = encryption.recovery();
310            let handle = spawn_tokio!(async move { recovery.recover(&key).await });
311
312            match handle.await.unwrap() {
313                Ok(()) => {
314                    // Even if recovery was successful, the recovery data may not have been
315                    // complete. Because the SDK uses multiple threads, we are only
316                    // sure of the SDK's recovery state at this point, not the Session's.
317                    if encryption.recovery().state() == SdkRecoveryState::Incomplete {
318                        self.navigation
319                            .push_by_tag(CryptoRecoverySetupPage::Incomplete.tag());
320                    } else {
321                        self.emit_completed();
322                    }
323                }
324                Err(error) => {
325                    error!("Could not recover account: {error}");
326                    let obj = self.obj();
327
328                    match error {
329                        RecoveryError::SecretStorage(SecretStorageError::SecretStorageKey(_)) => {
330                            toast!(obj, gettext("The recovery passphrase or key is invalid"));
331                        }
332                        _ => {
333                            toast!(obj, gettext("Could not access recovery data"));
334                        }
335                    }
336                }
337            }
338
339            self.recover_btn.set_is_loading(false);
340        }
341
342        /// Reset recovery and optionally cross-signing and room keys backup.
343        #[template_callback]
344        async fn reset(&self) {
345            self.reset_btn.set_is_loading(true);
346
347            let reset_identity = self.reset_identity_row.is_active();
348            if reset_identity && self.reset_cross_signing().await.is_err() {
349                self.reset_btn.set_is_loading(false);
350                return;
351            }
352
353            let passphrase = self.reset_entry.text();
354
355            let reset_backup = self.reset_backup_row.is_active();
356            if reset_backup {
357                self.reset_backup_and_recovery(passphrase).await;
358            } else {
359                self.reset_recovery(passphrase).await;
360            }
361
362            self.reset_btn.set_is_loading(false);
363        }
364
365        /// Reset the cross-signing identity.
366        async fn reset_cross_signing(&self) -> Result<(), ()> {
367            let Some(session) = self.session.upgrade() else {
368                return Err(());
369            };
370
371            let dialog = AuthDialog::new(&session);
372            let obj = self.obj();
373
374            let result = dialog.reset_cross_signing(&*obj).await;
375
376            match result {
377                Ok(()) => Ok(()),
378                Err(AuthError::UserCancelled) => {
379                    debug!("User cancelled authentication for cross-signing bootstrap");
380                    Err(())
381                }
382                Err(error) => {
383                    error!("Could not bootstrap cross-signing: {error}");
384                    toast!(obj, gettext("Could not reset the crypto identity"));
385                    Err(())
386                }
387            }
388        }
389
390        /// Reset the room keys backup and the account recovery key.
391        async fn reset_backup_and_recovery(&self, passphrase: glib::GString) {
392            let Some(session) = self.session.upgrade() else {
393                return;
394            };
395
396            let passphrase = Some(passphrase).filter(|s| !s.is_empty());
397            let has_passphrase = passphrase.is_some();
398
399            let obj = self.obj();
400            let encryption = session.client().encryption();
401
402            // There is no method to reset the room keys backup, so we need to disable
403            // recovery and re-enable it.
404            // If backups are not enabled locally, we cannot disable recovery, the API will
405            // return an error. If a backup exists on the homeserver but backups are not
406            // enabled locally, we need to delete the backup manually.
407            // In any case, `Recovery::enable` will reset the secret storage.
408            let backups = encryption.backups();
409            let (backups_are_enabled, backup_exists_on_server) = spawn_tokio!(async move {
410            let backups_are_enabled = backups.are_enabled().await;
411
412            let backup_exists_on_server = if backups_are_enabled {
413                true
414            } else {
415                // Let's use up-to-date data instead of relying on the last time that we updated it.
416                match backups.exists_on_server().await {
417                    Ok(exists) => exists,
418                    Err(error) => {
419                        warn!("Could not request whether recovery backup exists on homeserver: {error}");
420                        // If the request failed, we have to try to delete the backup to avoid unsolvable errors.
421                        true
422                    }
423                }
424            };
425
426            (backups_are_enabled, backup_exists_on_server)
427        })
428        .await
429        .expect("task was not aborted");
430
431            if !backups_are_enabled && backup_exists_on_server {
432                let backups = encryption.backups();
433                let handle = spawn_tokio!(async move { backups.disable_and_delete().await });
434
435                if let Err(error) = handle.await.expect("task was not aborted") {
436                    error!("Could not disable backups: {error}");
437                    toast!(obj, gettext("Could not reset account recovery"));
438                    return;
439                }
440            } else if backups_are_enabled {
441                let recovery = encryption.recovery();
442                let handle = spawn_tokio!(async move { recovery.disable().await });
443
444                if let Err(error) = handle.await.expect("task was not aborted") {
445                    error!("Could not disable recovery: {error}");
446                    toast!(obj, gettext("Could not reset account recovery"));
447                    return;
448                }
449            }
450
451            let recovery = encryption.recovery();
452            let handle = spawn_tokio!(async move {
453                let mut enable = recovery.enable();
454                if let Some(passphrase) = passphrase.as_deref() {
455                    enable = enable.with_passphrase(passphrase);
456                }
457
458                enable.await
459            });
460
461            match handle.await.unwrap() {
462                Ok(key) => {
463                    let key = (!has_passphrase).then_some(key);
464
465                    self.update_success(key);
466                    self.navigation
467                        .push_by_tag(CryptoRecoverySetupPage::Success.tag());
468                }
469                Err(error) => {
470                    error!("Could not re-enable account recovery: {error}");
471                    toast!(obj, gettext("Could not reset account recovery"));
472                }
473            }
474        }
475
476        /// Reset the account recovery key.
477        async fn reset_recovery(&self, passphrase: glib::GString) {
478            let Some(session) = self.session.upgrade() else {
479                return;
480            };
481
482            let passphrase = Some(passphrase).filter(|s| !s.is_empty());
483            let has_passphrase = passphrase.is_some();
484
485            let recovery = session.client().encryption().recovery();
486            let handle = spawn_tokio!(async move {
487                let mut reset = recovery.reset_key();
488                if let Some(passphrase) = passphrase.as_deref() {
489                    reset = reset.with_passphrase(passphrase);
490                }
491
492                reset.await
493            });
494
495            match handle.await.unwrap() {
496                Ok(key) => {
497                    let key = (!has_passphrase).then_some(key);
498
499                    self.update_success(key);
500                    self.navigation
501                        .push_by_tag(CryptoRecoverySetupPage::Success.tag());
502                }
503                Err(error) => {
504                    error!("Could not reset account recovery key: {error}");
505                    let obj = self.obj();
506                    toast!(obj, gettext("Could not reset account recovery key"));
507                }
508            }
509        }
510
511        /// Enable recovery.
512        #[template_callback]
513        async fn enable(&self) {
514            let Some(session) = self.session.upgrade() else {
515                return;
516            };
517
518            self.enable_btn.set_is_loading(true);
519
520            let passphrase = Some(self.enable_entry.text()).filter(|s| !s.is_empty());
521            let has_passphrase = passphrase.is_some();
522
523            let recovery = session.client().encryption().recovery();
524            let handle = spawn_tokio!(async move {
525                let mut enable = recovery.enable();
526                if let Some(passphrase) = passphrase.as_deref() {
527                    enable = enable.with_passphrase(passphrase);
528                }
529
530                enable.await
531            });
532
533            match handle.await.unwrap() {
534                Ok(key) => {
535                    let key = if has_passphrase { None } else { Some(key) };
536
537                    self.update_success(key);
538                    self.navigation
539                        .push_by_tag(CryptoRecoverySetupPage::Success.tag());
540                }
541                Err(error) => {
542                    error!("Could not enable account recovery: {error}");
543                    let obj = self.obj();
544                    toast!(obj, gettext("Could not enable account recovery"));
545                }
546            }
547
548            self.enable_btn.set_is_loading(false);
549        }
550
551        /// Copy the recovery key to the clipboard.
552        #[template_callback]
553        fn copy_key(&self) {
554            let obj = self.obj();
555            let key = self.success_key_label.label();
556
557            let clipboard = obj.clipboard();
558            clipboard.set_text(&key);
559
560            toast!(obj, "Recovery key copied to clipboard");
561        }
562
563        // Emit the `completed` signal.
564        #[template_callback]
565        fn emit_completed(&self) {
566            self.obj().emit_by_name::<()>("completed", &[]);
567        }
568
569        // Show the reset page, after updating it.
570        #[template_callback]
571        fn show_reset(&self) {
572            self.update_reset();
573            self.navigation
574                .push_by_tag(CryptoRecoverySetupPage::Reset.tag());
575        }
576    }
577}
578
579glib::wrapper! {
580    /// A view with the different flows to use or set up account recovery.
581    pub struct CryptoRecoverySetupView(ObjectSubclass<imp::CryptoRecoverySetupView>)
582        @extends gtk::Widget, adw::Bin,
583        @implements gtk::Accessible, gtk::Buildable, gtk::ConstraintTarget;
584}
585
586impl CryptoRecoverySetupView {
587    pub fn new(session: &Session) -> Self {
588        glib::Object::builder().property("session", session).build()
589    }
590
591    /// Set the initial page of this view.
592    pub(crate) fn set_initial_page(&self, initial_page: CryptoRecoverySetupInitialPage) {
593        self.imp().set_initial_page(initial_page);
594    }
595
596    /// Connect to the signal emitted when the recovery was successfully
597    /// enabled.
598    pub fn connect_completed<F: Fn(&Self) + 'static>(&self, f: F) -> glib::SignalHandlerId {
599        self.connect_closure(
600            "completed",
601            true,
602            closure_local!(move |obj: Self| {
603                f(&obj);
604            }),
605        )
606    }
607}