atagen

macltok

public
Star0
Watch0

Commit 743123167e29

tarball

atagen <boss@atagen.co>2026-10-26 05:03 UTC

7 files changed+193-29
encryption: set up recovery and hand back its key

create_key_backup alone leaves the backup key in this device's crypto store, so a wiped store or a fresh iOS Home Screen install can't restore anything. enable_recovery wraps Recovery::enable, which puts the cross-signing and backup keys into secret storage, and returns the key in the command reply only. Debug hides it. KeyBackupExists separates an account whose backup this device can't open from a rejected key.
Modified file: src/engine/commands.rs+67 additions−16 deletions
@@ -16,11 +16,11 @@ use matrix_sdk::{
1616 },
1717 send_queue::{LocalEchoContent, SendHandle},
1818 };
19-use tokio::sync::mpsc;
19+use tokio::sync::{mpsc, watch};
2020
2121 use crate::{
22- EncryptionCommand, Error, ErrorKind, InteractiveAuth, InteractiveAuthChallenge,
23- NotificationCommand, RoomNotificationMode, TimelineId,
22+ CommandOutput, EncryptionCommand, Error, ErrorKind, InteractiveAuth, InteractiveAuthChallenge,
23+ NotificationCommand, RecoveryKey, RoomNotificationMode, TimelineId,
2424 encryption::VerificationController,
2525 transport::{EventSender, Responder},
2626 };
@@ -55,14 +55,15 @@ pub(super) async fn run_encryption(
5555 events: EventSender,
5656 mut jobs: mpsc::Receiver<Job<EncryptionCommand>>,
5757 mut incoming: mpsc::UnboundedReceiver<VerificationRequest>,
58+ enabling: watch::Sender<bool>,
5859 ) {
5960 let mut verification = VerificationController::default();
6061 loop {
6162 tokio::select! {
6263 job = jobs.recv() => {
6364 let Some(Job { command, reply }) = job else { return };
64- let work = encryption(&client, &events, &mut verification, command);
65- reply.done(guarded("encryption command", work).await);
65+ let work = encryption(&client, &events, &mut verification, &enabling, command);
66+ reply.send(guarded("encryption command", work).await);
6667 }
6768 Some(request) = incoming.recv() => {
6869 verification.adopt_incoming(events.clone(), request).await;
@@ -75,29 +76,58 @@ async fn encryption(
7576 client: &Client,
7677 events: &EventSender,
7778 verification: &mut VerificationController,
79+ enabling: &watch::Sender<bool>,
7880 command: EncryptionCommand,
79-) -> Result<(), Error> {
81+) -> Result<CommandOutput, Error> {
8082 let encryption = client.encryption();
83+ let recovery = encryption.recovery();
84+ let key = |key: String| CommandOutput::RecoveryKey(RecoveryKey::new(key));
85+ let done = CommandOutput::Done;
8186 match command {
82- EncryptionCommand::Recover(key) => encryption
83- .recovery()
87+ EncryptionCommand::EnableRecovery { passphrase } => {
88+ let _enabling = Enabling::start(enabling);
89+ let enable = recovery.enable();
90+ match passphrase.as_deref() {
91+ Some(passphrase) => enable.with_passphrase(passphrase).await,
92+ None => enable.await,
93+ }
94+ .map(key)
95+ .map_err(|source| Error::from_recovery("enabling recovery failed", source))
96+ }
97+ EncryptionCommand::ResetRecoveryKey { passphrase } => {
98+ let reset = recovery.reset_key();
99+ match passphrase.as_deref() {
100+ Some(passphrase) => reset.with_passphrase(passphrase).await,
101+ None => reset.await,
102+ }
103+ .map(key)
104+ .map_err(|source| Error::from_recovery("resetting the recovery key failed", source))
105+ }
106+ EncryptionCommand::DisableRecovery => recovery
107+ .disable()
108+ .await
109+ .map(|()| done)
110+ .map_err(|source| Error::from_recovery("disabling recovery failed", source)),
111+ EncryptionCommand::Recover(key) => recovery
84112 .recover(&key)
85113 .await
114+ .map(|()| done)
86115 .map_err(|source| Error::from_recovery("account recovery failed", source)),
87- EncryptionCommand::RecoverKeyBackup(key) => encryption
88- .recovery()
116+ EncryptionCommand::RecoverKeyBackup(key) => recovery
89117 .recover_and_fix_backup(&key)
90118 .await
119+ .map(|()| done)
91120 .map_err(|source| Error::from_recovery("recovering key backup failed", source)),
92121 EncryptionCommand::CreateKeyBackup => encryption
93122 .backups()
94123 .create()
95124 .await
125+ .map(|()| done)
96126 .map_err(|source| Error::from_sdk("creating key backup failed", source)),
97- EncryptionCommand::EnableKeyBackup => encryption
98- .recovery()
127+ EncryptionCommand::EnableKeyBackup => recovery
99128 .enable_backup()
100129 .await
130+ .map(|()| done)
101131 .map_err(|source| Error::from_recovery("enabling key backup failed", source)),
102132 EncryptionCommand::DisableKeyBackup { delete_from_server } => {
103133 let backups = encryption.backups();
@@ -106,12 +136,33 @@ async fn encryption(
106136 } else {
107137 backups.disable().await
108138 }
139+ .map(|()| done)
109140 .map_err(|source| Error::from_sdk("disabling key backup failed", source))
110141 }
111- EncryptionCommand::StartVerification(target) => {
112- verification.start(client, events.clone(), target).await
113- }
114- EncryptionCommand::Verify(action) => verification.apply(events.clone(), action).await,
142+ EncryptionCommand::StartVerification(target) => verification
143+ .start(client, events.clone(), target)
144+ .await
145+ .map(|()| done),
146+ EncryptionCommand::Verify(action) => verification
147+ .apply(events.clone(), action)
148+ .await
149+ .map(|()| done),
150+ }
151+}
152+
153+/// a panicking enable must not leave the state saying it is still running
154+struct Enabling<'a>(&'a watch::Sender<bool>);
155+
156+impl<'a> Enabling<'a> {
157+ fn start(sender: &'a watch::Sender<bool>) -> Self {
158+ sender.send_replace(true);
159+ Self(sender)
160+ }
161+}
162+
163+impl Drop for Enabling<'_> {
164+ fn drop(&mut self) {
165+ self.0.send_replace(false);
115166 }
116167 }
117168
Modified file: src/engine/mod.rs+7 additions−1 deletions
@@ -600,6 +600,7 @@ impl Engine {
600600 let client = &self.client;
601601 let events = || self.port.event_sender();
602602 let (notices, notice_queue) = mpsc::unbounded_channel();
603+ let (enabling_recovery, recovery_enabling) = watch::channel(false);
603604 let watchers: [(&'static str, EngineBoxFuture<'static, ()>); 15] = [
604605 (
605606 "upload tracker",
@@ -658,7 +659,11 @@ impl Engine {
658659 ),
659660 (
660661 "encryption state watcher",
661- Box::pin(watchers::encryption(client.clone(), events())),
662+ Box::pin(watchers::encryption(
663+ client.clone(),
664+ events(),
665+ recovery_enabling,
666+ )),
662667 ),
663668 (
664669 "room list watcher",
@@ -702,6 +707,7 @@ impl Engine {
702707 events(),
703708 queues.encryption,
704709 incoming_verifications,
710+ enabling_recovery,
705711 )),
706712 ),
707713 (
Modified file: src/engine/watchers.rs+10 additions−2 deletions
@@ -376,7 +376,11 @@ fn is_attachment_echo(content: &LocalEchoContent) -> bool {
376376 )
377377 }
378378
379-pub(super) async fn encryption(client: Client, events: EventSender) {
379+pub(super) async fn encryption(
380+ client: Client,
381+ events: EventSender,
382+ mut enabling: watch::Receiver<bool>,
383+) {
380384 let Some(device_id) = client.device_id().map(ToOwned::to_owned) else {
381385 tracing::warn!("encryption watcher stopped because the client has no device id");
382386 return;
@@ -390,17 +394,21 @@ pub(super) async fn encryption(client: Client, events: EventSender) {
390394 let mut backup_state = backups.state();
391395 let mut verification_state = verification_stream.get();
392396 loop {
393- let state = encryption_state(
397+ let mut state = encryption_state(
394398 device_id.clone(),
395399 recovery_state,
396400 verification_state,
397401 backup_state,
398402 );
403+ if *enabling.borrow_and_update() {
404+ state.recovery = crate::RecoveryState::Enabling;
405+ }
399406 if events.send(Event::EncryptionChanged(state)).await.is_err() {
400407 return;
401408 }
402409 tokio::select! {
403410 Some(update) = recovery_stream.next() => recovery_state = update,
411+ Ok(()) = enabling.changed() => {}
404412 Some(update) = verification_stream.next() => verification_state = update,
405413 Some(update) = backup_stream.next() => {
406414 backup_state = update.unwrap_or_else(|_| backups.state());
Modified file: src/error.rs+4 additions−3 deletions
@@ -66,6 +66,8 @@ pub enum ErrorKind {
6666 VerificationUnsupported,
6767 NoCrossSigningIdentity,
6868 RecoveryRejected,
69+ /// the server already holds a key backup, which recovery has to open or replace first
70+ KeyBackupExists,
6971 Network,
7072 /// the homeserver wants us to slow down. try again later
7173 RateLimited,
@@ -223,9 +225,8 @@ impl Error {
223225 ErrorKind::RecoveryRejected
224226 }
225227 RecoveryError::SecretStorage(SecretStorageError::Storage(_)) => ErrorKind::Store,
226- RecoveryError::BackupExistsOnServer | RecoveryError::SecretStorage(_) => {
227- ErrorKind::RecoveryRejected
228- }
228+ RecoveryError::BackupExistsOnServer => ErrorKind::KeyBackupExists,
229+ RecoveryError::SecretStorage(_) => ErrorKind::RecoveryRejected,
229230 };
230231 Self::with_source(kind, message, source)
231232 }
Modified file: src/lib.rs+2 additions−2 deletions
@@ -67,8 +67,8 @@ pub use model::{
6767 MessageEditContent, MessageKind, MessageMentions, NotificationCommand, OwnDevice, PackCommand,
6868 PackImage, PackImageInfo, PackUsage, Patch, PowerLevelChanges, Profile, PushNotification,
6969 PushNotificationEvent, PushNotificationRoom, PushNotificationSender, PushNotificationStatus,
70- Reaction, RecentHistoryState, RecoveryState, ReplyDetails, ReplyReference, Retain, RichText,
71- RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,
70+ Reaction, RecentHistoryState, RecoveryKey, RecoveryState, ReplyDetails, ReplyReference, Retain,
71+ RichText, RoomCategory, RoomCommand, RoomHero, RoomListFilter, RoomMatcher, RoomMembership,
7272 RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels, RoomPreset, RoomSummary,
7373 RoomVisibility, SendState, Sender, SpaceChild, Termination, ThreadListCloseReason,
7474 ThreadListCommand, ThreadListEntry, ThreadSummary, ThreadUnread, TimelineCommand,
Modified file: src/model.rs+51 additions−1 deletions
@@ -629,8 +629,19 @@ pub enum EncryptionCommand {
629629 Recover(String),
630630 CreateKeyBackup,
631631 EnableKeyBackup,
632- DisableKeyBackup { delete_from_server: bool },
632+ DisableKeyBackup {
633+ delete_from_server: bool,
634+ },
633635 RecoverKeyBackup(String),
636+ /// creates secret storage holding the cross-signing and backup keys, turns key backup on,
637+ /// and answers with the recovery key. refused while a backup this device can't open exists
638+ EnableRecovery {
639+ passphrase: Option<String>,
640+ },
641+ ResetRecoveryKey {
642+ passphrase: Option<String>,
643+ },
644+ DisableRecovery,
634645 StartVerification(VerificationTarget),
635646 Verify(VerificationAction),
636647 }
@@ -649,6 +660,15 @@ impl fmt::Debug for EncryptionCommand {
649660 .debug_tuple("RecoverKeyBackup")
650661 .field(&"...")
651662 .finish(),
663+ Self::EnableRecovery { passphrase } => formatter
664+ .debug_struct("EnableRecovery")
665+ .field("passphrase", &passphrase.as_ref().map(|_| "..."))
666+ .finish(),
667+ Self::ResetRecoveryKey { passphrase } => formatter
668+ .debug_struct("ResetRecoveryKey")
669+ .field("passphrase", &passphrase.as_ref().map(|_| "..."))
670+ .finish(),
671+ Self::DisableRecovery => formatter.write_str("DisableRecovery"),
652672 Self::StartVerification(target) => formatter
653673 .debug_tuple("StartVerification")
654674 .field(target)
@@ -701,6 +721,7 @@ pub enum CommandOutput {
701721 SearchSession(crate::SearchSession),
702722 SearchPage(crate::SearchPage),
703723 SearchCoverage(crate::SearchCoverage),
724+ RecoveryKey(RecoveryKey),
704725 }
705726
706727 #[derive(Clone, Debug, Eq, PartialEq)]
@@ -2296,10 +2317,39 @@ pub enum KeyBackupState {
22962317 pub enum RecoveryState {
22972318 Unknown,
22982319 Disabled,
2320+ /// `enable_recovery` is running
2321+ Enabling,
22992322 Incomplete,
23002323 Enabled,
23012324 }
23022325
2326+/// shown to a person once. macltok never stores or logs it, and Debug hides it
2327+#[derive(Clone, Eq, PartialEq)]
2328+#[cfg_attr(feature = "serde", derive(Serialize, Deserialize), serde(transparent))]
2329+pub struct RecoveryKey(String);
2330+
2331+impl RecoveryKey {
2332+ pub(crate) fn new(key: String) -> Self {
2333+ Self(key)
2334+ }
2335+
2336+ pub fn as_str(&self) -> &str {
2337+ &self.0
2338+ }
2339+}
2340+
2341+impl fmt::Debug for RecoveryKey {
2342+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2343+ formatter.write_str("RecoveryKey(...)")
2344+ }
2345+}
2346+
2347+impl fmt::Display for RecoveryKey {
2348+ fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2349+ formatter.write_str(&self.0)
2350+ }
2351+}
2352+
23032353 #[derive(Clone, Copy, Debug, Eq, PartialEq)]
23042354 #[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
23052355 #[non_exhaustive]
Modified file: src/transport.rs+52 additions−4 deletions
@@ -35,10 +35,10 @@ use crate::{
3535 MentionsBoundary, MentionsCommand, MentionsPage, MessageContent, MessageEditContent,
3636 NotificationCommand, OwnDevice, PackCommand, PackImageInfo, PackUsage, Patch,
3737 PowerLevelChanges, Profile, PushNotification, PushNotificationEvent, PushNotificationStatus,
38- RoomCommand, RoomListFilter, RoomNotificationMode, RoomNotificationSettings, RoomPowerLevels,
39- RoomSummary, SavedMentionContext, SearchCommand, SearchCoverage, SearchPage, SearchScope,
40- SearchSession, SpaceChild, ThreadListCommand, TimelineCommand, TimelineId, VerificationAction,
41- VerificationTarget,
38+ RecoveryKey, RoomCommand, RoomListFilter, RoomNotificationMode, RoomNotificationSettings,
39+ RoomPowerLevels, RoomSummary, SavedMentionContext, SearchCommand, SearchCoverage, SearchPage,
40+ SearchScope, SearchSession, SpaceChild, ThreadListCommand, TimelineCommand, TimelineId,
41+ VerificationAction, VerificationTarget,
4242 encryption::encryption_state,
4343 engine::{MediaService, StoreLock},
4444 model::{PushNotificationRoom, PushNotificationSender},
@@ -800,6 +800,54 @@ impl ClientHandle {
800800 .await
801801 }
802802
803+ /// the key is in the answer and nowhere else, so show it before dropping it
804+ pub async fn enable_recovery(&self, passphrase: Option<String>) -> Result<RecoveryKey, Error> {
805+ self.recovery_key(EncryptionCommand::EnableRecovery { passphrase })
806+ .await
807+ }
808+
809+ pub async fn reset_recovery_key(
810+ &self,
811+ passphrase: Option<String>,
812+ ) -> Result<RecoveryKey, Error> {
813+ self.recovery_key(EncryptionCommand::ResetRecoveryKey { passphrase })
814+ .await
815+ }
816+
817+ async fn recovery_key(&self, command: EncryptionCommand) -> Result<RecoveryKey, Error> {
818+ match self.execute(Command::Encryption(command)).await? {
819+ CommandOutput::RecoveryKey(key) => Ok(key),
820+ output => Err(unexpected("recovery key", &output)),
821+ }
822+ }
823+
824+ pub async fn disable_recovery(&self) -> Result<(), Error> {
825+ self.run(Command::Encryption(EncryptionCommand::DisableRecovery))
826+ .await
827+ }
828+
829+ /// checks a typed key or passphrase against secret storage without importing anything
830+ pub async fn verify_recovery_key(&self, recovery_key: &str) -> Result<bool, Error> {
831+ use matrix_sdk::encryption::secret_storage::SecretStorageError;
832+ match self
833+ .client
834+ .encryption()
835+ .secret_storage()
836+ .open_secret_store(recovery_key)
837+ .await
838+ {
839+ Ok(_) => Ok(true),
840+ Err(SecretStorageError::SecretStorageKey(_)) => Ok(false),
841+ Err(SecretStorageError::MissingKeyInfo { .. }) => {
842+ Err(Error::new(ErrorKind::Invalid, "recovery is not set up"))
843+ }
844+ Err(source) => Err(Error::from_recovery(
845+ "checking the recovery key failed",
846+ source.into(),
847+ )),
848+ }
849+ }
850+
803851 pub async fn create_key_backup(&self) -> Result<(), Error> {
804852 self.run(Command::Encryption(EncryptionCommand::CreateKeyBackup))
805853 .await