fedimint_client/
backup.rs

1use std::cmp::Reverse;
2use std::collections::{BTreeMap, BTreeSet};
3use std::io;
4use std::io::{Cursor, Write};
5
6use anyhow::{Context, Result, bail, ensure};
7use bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SignOnly};
8use fedimint_api_client::api::DynGlobalApi;
9use fedimint_client_module::module::recovery::DynModuleBackup;
10use fedimint_core::core::ModuleInstanceId;
11use fedimint_core::core::backup::{
12    BACKUP_REQUEST_MAX_PAYLOAD_SIZE_BYTES, BackupRequest, SignedBackupRequest,
13};
14use fedimint_core::db::IDatabaseTransactionOpsCoreTyped;
15use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
16use fedimint_core::module::registry::ModuleDecoderRegistry;
17use fedimint_core::module::serde_json;
18use fedimint_derive_secret::DerivableSecret;
19use fedimint_eventlog::{Event, EventKind, EventPersistence};
20use fedimint_logging::{LOG_CLIENT, LOG_CLIENT_BACKUP, LOG_CLIENT_RECOVERY};
21use serde::{Deserialize, Serialize};
22use tracing::{debug, info, warn};
23
24use super::Client;
25use crate::db::LastBackupKey;
26use crate::secret::DeriveableSecretClientExt;
27
28/// Backup metadata
29///
30/// A backup can have a blob of extra data encoded in it. We provide methods to
31/// use json encoding, but clients are free to use their own encoding.
32#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Encodable, Decodable, Clone)]
33pub struct Metadata(Vec<u8>);
34
35impl Metadata {
36    /// Create empty metadata
37    pub fn empty() -> Self {
38        Self(vec![])
39    }
40
41    pub fn from_raw(bytes: Vec<u8>) -> Self {
42        Self(bytes)
43    }
44
45    pub fn into_raw(self) -> Vec<u8> {
46        self.0
47    }
48
49    /// Is metadata empty
50    pub fn is_empty(&self) -> bool {
51        self.0.is_empty()
52    }
53
54    /// Create metadata as json from typed `val`
55    pub fn from_json_serialized<T: Serialize>(val: T) -> Self {
56        Self(serde_json::to_vec(&val).expect("serializing to vec can't fail"))
57    }
58
59    /// Attempt to deserialize metadata as typed json
60    pub fn to_json_deserialized<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
61        Ok(serde_json::from_slice(&self.0)?)
62    }
63
64    /// Attempt to deserialize metadata as untyped json (`serde_json::Value`)
65    pub fn to_json_value(&self) -> Result<serde_json::Value> {
66        Ok(serde_json::from_slice(&self.0)?)
67    }
68}
69
70/// Client state backup
71#[derive(PartialEq, Eq, Debug, Clone)]
72pub struct ClientBackup {
73    /// Session count taken right before taking the backup
74    /// used to timestamp the backup file. Used for finding the
75    /// most recent backup from all available ones.
76    ///
77    /// Warning: Each particular module backup for each instance
78    /// in `Self::modules` could have been taken earlier than
79    /// that (e.g. older one used due to size limits), so modules
80    /// MUST maintain their own `session_count`s.
81    pub session_count: u64,
82    /// Application metadata
83    pub metadata: Metadata,
84    // TODO: remove redundant ModuleInstanceId
85    /// Module specific-backup (if supported)
86    pub modules: BTreeMap<ModuleInstanceId, DynModuleBackup>,
87}
88
89impl Encodable for ClientBackup {
90    fn consensus_encode<W: io::Write>(&self, writer: &mut W) -> std::result::Result<(), io::Error> {
91        self.session_count.consensus_encode(writer)?;
92        self.metadata.consensus_encode(writer)?;
93        self.modules.consensus_encode(writer)?;
94
95        // Old-style padding.
96        //
97        // Older version of the client used a custom zero-filled vec to
98        // pad `ClientBackup` to alignment-size here. This has a problem
99        // that the size of encoding of the padding can have different sizes
100        // (due to var-ints).
101        //
102        // Conceptually serialization is also a the wrong place to do padding
103        // anyway, so we moved padding to encrypt/decryption. But for abundance of
104        // caution, we'll keep the old padding around, and always fill it
105        // with an empty Vec.
106        Vec::<u8>::new().consensus_encode(writer)?;
107
108        Ok(())
109    }
110}
111
112impl Decodable for ClientBackup {
113    fn consensus_decode_partial<R: io::Read>(
114        r: &mut R,
115        modules: &ModuleDecoderRegistry,
116    ) -> std::result::Result<Self, DecodeError> {
117        let session_count = u64::consensus_decode_partial(r, modules).context("session_count")?;
118        let metadata = Metadata::consensus_decode_partial(r, modules).context("metadata")?;
119        let module_backups =
120            BTreeMap::<ModuleInstanceId, DynModuleBackup>::consensus_decode_partial(r, modules)
121                .context("module_backups")?;
122        let _padding = Vec::<u8>::consensus_decode_partial(r, modules).context("padding")?;
123
124        Ok(Self {
125            session_count,
126            metadata,
127            modules: module_backups,
128        })
129    }
130}
131
132impl ClientBackup {
133    pub const PADDING_ALIGNMENT: usize = 4 * 1024;
134
135    /// "32kiB is enough for any module backup" --dpc
136    ///
137    /// Federation storage is scarce, and since we can take older versions of
138    /// the backup, temporarily going over the limit is not a big problem.
139    pub const PER_MODULE_SIZE_LIMIT_BYTES: usize = 32 * 1024;
140
141    /// Align an ecoded message size up for better privacy
142    fn get_alignment_size(len: usize) -> usize {
143        let padding_alignment = Self::PADDING_ALIGNMENT;
144        ((len.saturating_sub(1) / padding_alignment) + 1) * padding_alignment
145    }
146
147    /// Encrypt with a key and turn into [`EncryptedClientBackup`]
148    pub fn encrypt_to(&self, key: &fedimint_aead::LessSafeKey) -> Result<EncryptedClientBackup> {
149        let mut encoded = Encodable::consensus_encode_to_vec(self);
150
151        let alignment_size = Self::get_alignment_size(encoded.len());
152        let padding_size = alignment_size - encoded.len();
153        encoded.write_all(&vec![0u8; padding_size])?;
154
155        let encrypted = fedimint_aead::encrypt(encoded, key)?;
156        Ok(EncryptedClientBackup(encrypted))
157    }
158
159    /// Validate and fallback invalid parts of the backup
160    ///
161    /// Given the size constraints and possible 3rd party modules,
162    /// it seems to use older, but smaller versions of backups when
163    /// current ones do not fit (either globally or in per-module limit).
164    fn validate_and_fallback_module_backups(
165        self,
166        last_backup: Option<&ClientBackup>,
167    ) -> ClientBackup {
168        // take all module ids from both backup and add them together
169        let all_ids: BTreeSet<_> = self
170            .modules
171            .keys()
172            .chain(last_backup.iter().flat_map(|b| b.modules.keys()))
173            .copied()
174            .collect();
175
176        let mut modules = BTreeMap::new();
177        for module_id in all_ids {
178            if let Some(module_backup) = self
179                .modules
180                .get(&module_id)
181                .or_else(|| last_backup.and_then(|lb| lb.modules.get(&module_id)))
182            {
183                let size = module_backup.consensus_encode_to_len();
184                let limit = Self::PER_MODULE_SIZE_LIMIT_BYTES;
185                if size < u64::try_from(limit).expect("Can't fail") {
186                    modules.insert(module_id, module_backup.clone());
187                } else if let Some(last_module_backup) =
188                    last_backup.and_then(|lb| lb.modules.get(&module_id))
189                {
190                    let size_previous = last_module_backup.consensus_encode_to_len();
191                    warn!(
192                        target: LOG_CLIENT_BACKUP,
193                        size,
194                        limit,
195                        %module_id,
196                        size_previous,
197                        "Module backup too large, will use previous version"
198                    );
199                    modules.insert(module_id, last_module_backup.clone());
200                } else {
201                    warn!(
202                        target: LOG_CLIENT_BACKUP,
203                        size,
204                        limit,
205                        %module_id,
206                        "Module backup too large, no previous version available to fall-back to"
207                    );
208                }
209            }
210        }
211        ClientBackup {
212            session_count: self.session_count,
213            metadata: self.metadata,
214            modules,
215        }
216    }
217}
218
219/// Encrypted version of [`ClientBackup`].
220#[derive(Clone)]
221pub struct EncryptedClientBackup(Vec<u8>);
222
223impl EncryptedClientBackup {
224    pub fn decrypt_with(
225        mut self,
226        key: &fedimint_aead::LessSafeKey,
227        decoders: &ModuleDecoderRegistry,
228    ) -> Result<ClientBackup> {
229        let decrypted = fedimint_aead::decrypt(&mut self.0, key)?;
230        let mut cursor = Cursor::new(decrypted);
231        // We specifically want to ignore the padding in the backup here.
232        let client_backup = ClientBackup::consensus_decode_partial(&mut cursor, decoders)?;
233        debug!(
234            target: LOG_CLIENT_BACKUP,
235            len = decrypted.len(),
236            padding = u64::try_from(decrypted.len()).expect("Can't fail") - cursor.position(),
237            "Decrypted client backup"
238        );
239        Ok(client_backup)
240    }
241
242    pub fn into_backup_request(self, keypair: &Keypair) -> Result<SignedBackupRequest> {
243        let request = BackupRequest {
244            id: keypair.public_key(),
245            timestamp: fedimint_core::time::now(),
246            payload: self.0,
247        };
248
249        request.sign(keypair)
250    }
251
252    pub fn len(&self) -> usize {
253        self.0.len()
254    }
255
256    #[must_use]
257    pub fn is_empty(&self) -> bool {
258        self.len() == 0
259    }
260}
261
262#[derive(Serialize, Deserialize)]
263pub struct EventBackupDone;
264
265impl Event for EventBackupDone {
266    const MODULE: Option<fedimint_core::core::ModuleKind> = None;
267
268    const KIND: EventKind = EventKind::from_static("backup-done");
269    const PERSISTENCE: EventPersistence = EventPersistence::Persistent;
270}
271
272impl Client {
273    /// Create a backup, include provided `metadata`
274    #[deprecated(
275        note = "Recovery is now efficient enough that backups are no longer necessary. Backups will be removed in v0.13.0 due to backups being inherently complicated and brittle."
276    )]
277    pub async fn create_backup(&self, metadata: Metadata) -> anyhow::Result<ClientBackup> {
278        let session_count = self.api.session_count().await?;
279        let mut modules = BTreeMap::new();
280        for (id, kind, module) in self.modules.iter_modules() {
281            debug!(target: LOG_CLIENT_BACKUP, module_id=id, module_kind=%kind, "Preparing module backup");
282            if module.supports_backup() {
283                let backup = module.backup(id).await?;
284
285                debug!(target: LOG_CLIENT_BACKUP, module_id=id, module_kind=%kind, "Prepared module backup");
286                modules.insert(id, backup);
287            } else {
288                debug!(target: LOG_CLIENT_BACKUP, module_id=id, module_kind=%kind, "Module does not support backup");
289            }
290        }
291
292        Ok(ClientBackup {
293            session_count,
294            metadata,
295            modules,
296        })
297    }
298
299    async fn load_previous_backup(&self) -> Option<ClientBackup> {
300        let mut dbtx = self.db().begin_transaction_nc().await;
301        dbtx.get_value(&LastBackupKey).await
302    }
303
304    async fn store_last_backup(&self, backup: &ClientBackup) {
305        let mut dbtx = self.db().begin_transaction().await;
306        dbtx.insert_entry(&LastBackupKey, backup).await;
307        dbtx.commit_tx().await;
308    }
309
310    /// Prepare an encrypted backup and send it to federation for storing
311    #[deprecated(
312        note = "Recovery is now efficient enough that backups are no longer necessary. Backups will be removed in v0.13.0 due to backups being inherently complicated and brittle."
313    )]
314    #[allow(deprecated)]
315    pub async fn backup_to_federation(&self, metadata: Metadata) -> Result<()> {
316        ensure!(
317            !self.has_pending_recoveries(),
318            "Cannot backup while there are pending recoveries"
319        );
320
321        let last_backup = self.load_previous_backup().await;
322        let new_backup = self.create_backup(metadata).await?;
323
324        let new_backup = new_backup.validate_and_fallback_module_backups(last_backup.as_ref());
325
326        let encrypted = new_backup.encrypt_to(&self.get_derived_backup_encryption_key())?;
327
328        self.validate_backup(&encrypted)?;
329
330        self.store_last_backup(&new_backup).await;
331
332        self.upload_backup(&encrypted).await?;
333
334        self.log_event(None, EventBackupDone).await;
335
336        Ok(())
337    }
338
339    /// Validate backup before sending it to federation
340    #[deprecated(
341        note = "Recovery is now efficient enough that backups are no longer necessary. Backups will be removed in v0.13.0 due to backups being inherently complicated and brittle."
342    )]
343    pub fn validate_backup(&self, backup: &EncryptedClientBackup) -> Result<()> {
344        if BACKUP_REQUEST_MAX_PAYLOAD_SIZE_BYTES < backup.len() {
345            bail!("Backup payload too large");
346        }
347        Ok(())
348    }
349
350    /// Upload `backup` to federation
351    #[deprecated(
352        note = "Recovery is now efficient enough that backups are no longer necessary. Backups will be removed in v0.13.0 due to backups being inherently complicated and brittle."
353    )]
354    #[allow(deprecated)]
355    pub async fn upload_backup(&self, backup: &EncryptedClientBackup) -> Result<()> {
356        self.validate_backup(backup)?;
357        let size = backup.len();
358        info!(
359            target: LOG_CLIENT_BACKUP,
360            size, "Uploading backup to federation"
361        );
362        let backup_request = backup
363            .clone()
364            .into_backup_request(&self.get_derived_backup_signing_key())?;
365        self.api.upload_backup(&backup_request).await?;
366        info!(
367            target: LOG_CLIENT_BACKUP,
368            size, "Uploaded backup to federation"
369        );
370        Ok(())
371    }
372
373    #[deprecated(
374        note = "Recovery is now efficient enough that backups are no longer necessary. Backups will be removed in v0.13.0 due to backups being inherently complicated and brittle."
375    )]
376    #[allow(deprecated)]
377    pub async fn download_backup_from_federation(&self) -> Result<Option<ClientBackup>> {
378        Self::download_backup_from_federation_static(
379            &self.api,
380            &self.root_secret(),
381            self.decoders(),
382        )
383        .await
384    }
385
386    /// Download most recent valid backup found from the Federation
387    #[deprecated(
388        note = "Recovery is now efficient enough that backups are no longer necessary. Backups will be removed in v0.13.0 due to backups being inherently complicated and brittle."
389    )]
390    #[allow(deprecated)]
391    pub async fn download_backup_from_federation_static(
392        api: &DynGlobalApi,
393        root_secret: &DerivableSecret,
394        decoders: &ModuleDecoderRegistry,
395    ) -> Result<Option<ClientBackup>> {
396        debug!(target: LOG_CLIENT, "Downloading backup from the federation");
397        let mut responses: Vec<_> = api
398            .download_backup(&Client::get_backup_id_static(root_secret))
399            .await?
400            .into_iter()
401            .filter_map(|(peer, backup)| {
402                match EncryptedClientBackup(backup?.data).decrypt_with(
403                    &Self::get_derived_backup_encryption_key_static(root_secret),
404                    decoders,
405                ) {
406                    Ok(valid) => Some(valid),
407                    Err(e) => {
408                        warn!(
409                            target: LOG_CLIENT_RECOVERY,
410                            "Invalid backup returned by {peer}: {e}"
411                        );
412                        None
413                    }
414                }
415            })
416            .collect();
417
418        debug!(
419            target: LOG_CLIENT_RECOVERY,
420            "Received {} valid responses",
421            responses.len()
422        );
423        // Use the newest (highest epoch)
424        responses.sort_by_key(|backup| Reverse(backup.session_count));
425
426        Ok(responses.into_iter().next())
427    }
428
429    /// Backup id derived from the root secret key (public key used to self-sign
430    /// backup requests)
431    #[deprecated(
432        note = "Recovery is now efficient enough that backups are no longer necessary. Backups will be removed in v0.13.0 due to backups being inherently complicated and brittle."
433    )]
434    pub fn get_backup_id(&self) -> PublicKey {
435        self.get_derived_backup_signing_key().public_key()
436    }
437
438    #[deprecated(
439        note = "Recovery is now efficient enough that backups are no longer necessary. Backups will be removed in v0.13.0 due to backups being inherently complicated and brittle."
440    )]
441    pub fn get_backup_id_static(root_secret: &DerivableSecret) -> PublicKey {
442        Self::get_derived_backup_signing_key_static(root_secret).public_key()
443    }
444    /// Static version of [`Self::get_derived_backup_encryption_key`] for
445    /// testing without creating whole `MintClient`
446    fn get_derived_backup_encryption_key_static(
447        secret: &DerivableSecret,
448    ) -> fedimint_aead::LessSafeKey {
449        fedimint_aead::LessSafeKey::new(secret.derive_backup_secret().to_chacha20_poly1305_key())
450    }
451
452    /// Static version of [`Self::get_derived_backup_signing_key`] for testing
453    /// without creating whole `MintClient`
454    fn get_derived_backup_signing_key_static(secret: &DerivableSecret) -> Keypair {
455        secret
456            .derive_backup_secret()
457            .to_secp_key(&Secp256k1::<SignOnly>::gen_new())
458    }
459
460    fn get_derived_backup_encryption_key(&self) -> fedimint_aead::LessSafeKey {
461        Self::get_derived_backup_encryption_key_static(&self.root_secret())
462    }
463
464    fn get_derived_backup_signing_key(&self) -> Keypair {
465        Self::get_derived_backup_signing_key_static(&self.root_secret())
466    }
467
468    pub async fn get_decoded_client_secret<T: Decodable>(&self) -> anyhow::Result<T> {
469        crate::db::get_decoded_client_secret::<T>(self.db()).await
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use std::collections::BTreeMap;
476
477    use anyhow::Result;
478    use fedimint_core::encoding::{Decodable, Encodable};
479    use fedimint_core::module::registry::ModuleRegistry;
480    use fedimint_derive_secret::DerivableSecret;
481
482    use crate::Client;
483    use crate::backup::{ClientBackup, Metadata};
484
485    #[test]
486    fn sanity_ecash_backup_align() {
487        assert_eq!(
488            ClientBackup::get_alignment_size(1),
489            ClientBackup::PADDING_ALIGNMENT
490        );
491        assert_eq!(
492            ClientBackup::get_alignment_size(ClientBackup::PADDING_ALIGNMENT),
493            ClientBackup::PADDING_ALIGNMENT
494        );
495        assert_eq!(
496            ClientBackup::get_alignment_size(ClientBackup::PADDING_ALIGNMENT + 1),
497            ClientBackup::PADDING_ALIGNMENT * 2
498        );
499    }
500
501    #[test]
502    fn sanity_ecash_backup_decode_encode() -> Result<()> {
503        let orig = ClientBackup {
504            session_count: 0,
505            metadata: Metadata::from_raw(vec![1, 2, 3]),
506            modules: BTreeMap::new(),
507        };
508
509        let encoded = orig.consensus_encode_to_vec();
510        assert_eq!(
511            orig,
512            ClientBackup::consensus_decode_whole(&encoded, &ModuleRegistry::default())?
513        );
514
515        Ok(())
516    }
517
518    #[test]
519    fn sanity_ecash_backup_encrypt_decrypt() -> Result<()> {
520        const ENCRYPTION_HEADER_LEN: usize = 28;
521
522        let orig = ClientBackup {
523            modules: BTreeMap::new(),
524            session_count: 1,
525            metadata: Metadata::from_raw(vec![1, 2, 3]),
526        };
527
528        let secret = DerivableSecret::new_root(&[1; 32], &[1, 32]);
529        let key = Client::get_derived_backup_encryption_key_static(&secret);
530
531        let empty_encrypted = fedimint_aead::encrypt(vec![], &key)?;
532        assert_eq!(empty_encrypted.len(), ENCRYPTION_HEADER_LEN);
533
534        let encrypted = orig.encrypt_to(&key)?;
535        assert_eq!(
536            encrypted.len(),
537            ClientBackup::PADDING_ALIGNMENT + ENCRYPTION_HEADER_LEN
538        );
539
540        let decrypted = encrypted.decrypt_with(&key, &ModuleRegistry::default())?;
541
542        assert_eq!(orig, decrypted);
543
544        Ok(())
545    }
546}