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};
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}
270
271impl Client {
272    /// Create a backup, include provided `metadata`
273    pub async fn create_backup(&self, metadata: Metadata) -> anyhow::Result<ClientBackup> {
274        let session_count = self.api.session_count().await?;
275        let mut modules = BTreeMap::new();
276        for (id, kind, module) in self.modules.iter_modules() {
277            debug!(target: LOG_CLIENT_BACKUP, module_id=id, module_kind=%kind, "Preparing module backup");
278            if module.supports_backup() {
279                let backup = module.backup(id).await?;
280
281                debug!(target: LOG_CLIENT_BACKUP, module_id=id, module_kind=%kind, "Prepared module backup");
282                modules.insert(id, backup);
283            } else {
284                debug!(target: LOG_CLIENT_BACKUP, module_id=id, module_kind=%kind, "Module does not support backup");
285            }
286        }
287
288        Ok(ClientBackup {
289            session_count,
290            metadata,
291            modules,
292        })
293    }
294
295    async fn load_previous_backup(&self) -> Option<ClientBackup> {
296        let mut dbtx = self.db().begin_transaction_nc().await;
297        dbtx.get_value(&LastBackupKey).await
298    }
299
300    async fn store_last_backup(&self, backup: &ClientBackup) {
301        let mut dbtx = self.db().begin_transaction().await;
302        dbtx.insert_entry(&LastBackupKey, backup).await;
303        dbtx.commit_tx().await;
304    }
305
306    /// Prepare an encrypted backup and send it to federation for storing
307    pub async fn backup_to_federation(&self, metadata: Metadata) -> Result<()> {
308        ensure!(
309            !self.has_pending_recoveries(),
310            "Cannot backup while there are pending recoveries"
311        );
312
313        let last_backup = self.load_previous_backup().await;
314        let new_backup = self.create_backup(metadata).await?;
315
316        let new_backup = new_backup.validate_and_fallback_module_backups(last_backup.as_ref());
317
318        let encrypted = new_backup.encrypt_to(&self.get_derived_backup_encryption_key())?;
319
320        self.validate_backup(&encrypted)?;
321
322        self.store_last_backup(&new_backup).await;
323
324        self.upload_backup(&encrypted).await?;
325
326        self.log_event(None, EventBackupDone).await;
327
328        Ok(())
329    }
330
331    /// Validate backup before sending it to federation
332    pub fn validate_backup(&self, backup: &EncryptedClientBackup) -> Result<()> {
333        if BACKUP_REQUEST_MAX_PAYLOAD_SIZE_BYTES < backup.len() {
334            bail!("Backup payload too large");
335        }
336        Ok(())
337    }
338
339    /// Upload `backup` to federation
340    pub async fn upload_backup(&self, backup: &EncryptedClientBackup) -> Result<()> {
341        self.validate_backup(backup)?;
342        let size = backup.len();
343        info!(
344            target: LOG_CLIENT_BACKUP,
345            size, "Uploading backup to federation"
346        );
347        let backup_request = backup
348            .clone()
349            .into_backup_request(&self.get_derived_backup_signing_key())?;
350        self.api.upload_backup(&backup_request).await?;
351        info!(
352            target: LOG_CLIENT_BACKUP,
353            size, "Uploaded backup to federation"
354        );
355        Ok(())
356    }
357
358    pub async fn download_backup_from_federation(&self) -> Result<Option<ClientBackup>> {
359        Self::download_backup_from_federation_static(
360            &self.api,
361            &self.root_secret(),
362            self.decoders(),
363        )
364        .await
365    }
366
367    /// Download most recent valid backup found from the Federation
368    pub async fn download_backup_from_federation_static(
369        api: &DynGlobalApi,
370        root_secret: &DerivableSecret,
371        decoders: &ModuleDecoderRegistry,
372    ) -> Result<Option<ClientBackup>> {
373        debug!(target: LOG_CLIENT, "Downloading backup from the federation");
374        let mut responses: Vec<_> = api
375            .download_backup(&Client::get_backup_id_static(root_secret))
376            .await?
377            .into_iter()
378            .filter_map(|(peer, backup)| {
379                match EncryptedClientBackup(backup?.data).decrypt_with(
380                    &Self::get_derived_backup_encryption_key_static(root_secret),
381                    decoders,
382                ) {
383                    Ok(valid) => Some(valid),
384                    Err(e) => {
385                        warn!(
386                            target: LOG_CLIENT_RECOVERY,
387                            "Invalid backup returned by {peer}: {e}"
388                        );
389                        None
390                    }
391                }
392            })
393            .collect();
394
395        debug!(
396            target: LOG_CLIENT_RECOVERY,
397            "Received {} valid responses",
398            responses.len()
399        );
400        // Use the newest (highest epoch)
401        responses.sort_by_key(|backup| Reverse(backup.session_count));
402
403        Ok(responses.into_iter().next())
404    }
405
406    /// Backup id derived from the root secret key (public key used to self-sign
407    /// backup requests)
408    pub fn get_backup_id(&self) -> PublicKey {
409        self.get_derived_backup_signing_key().public_key()
410    }
411
412    pub fn get_backup_id_static(root_secret: &DerivableSecret) -> PublicKey {
413        Self::get_derived_backup_signing_key_static(root_secret).public_key()
414    }
415    /// Static version of [`Self::get_derived_backup_encryption_key`] for
416    /// testing without creating whole `MintClient`
417    fn get_derived_backup_encryption_key_static(
418        secret: &DerivableSecret,
419    ) -> fedimint_aead::LessSafeKey {
420        fedimint_aead::LessSafeKey::new(secret.derive_backup_secret().to_chacha20_poly1305_key())
421    }
422
423    /// Static version of [`Self::get_derived_backup_signing_key`] for testing
424    /// without creating whole `MintClient`
425    fn get_derived_backup_signing_key_static(secret: &DerivableSecret) -> Keypair {
426        secret
427            .derive_backup_secret()
428            .to_secp_key(&Secp256k1::<SignOnly>::gen_new())
429    }
430
431    fn get_derived_backup_encryption_key(&self) -> fedimint_aead::LessSafeKey {
432        Self::get_derived_backup_encryption_key_static(&self.root_secret())
433    }
434
435    fn get_derived_backup_signing_key(&self) -> Keypair {
436        Self::get_derived_backup_signing_key_static(&self.root_secret())
437    }
438
439    pub async fn get_decoded_client_secret<T: Decodable>(&self) -> anyhow::Result<T> {
440        crate::db::get_decoded_client_secret::<T>(self.db()).await
441    }
442}
443
444#[cfg(test)]
445mod tests {
446    use std::collections::BTreeMap;
447
448    use anyhow::Result;
449    use fedimint_core::encoding::{Decodable, Encodable};
450    use fedimint_core::module::registry::ModuleRegistry;
451    use fedimint_derive_secret::DerivableSecret;
452
453    use crate::Client;
454    use crate::backup::{ClientBackup, Metadata};
455
456    #[test]
457    fn sanity_ecash_backup_align() {
458        assert_eq!(
459            ClientBackup::get_alignment_size(1),
460            ClientBackup::PADDING_ALIGNMENT
461        );
462        assert_eq!(
463            ClientBackup::get_alignment_size(ClientBackup::PADDING_ALIGNMENT),
464            ClientBackup::PADDING_ALIGNMENT
465        );
466        assert_eq!(
467            ClientBackup::get_alignment_size(ClientBackup::PADDING_ALIGNMENT + 1),
468            ClientBackup::PADDING_ALIGNMENT * 2
469        );
470    }
471
472    #[test]
473    fn sanity_ecash_backup_decode_encode() -> Result<()> {
474        let orig = ClientBackup {
475            session_count: 0,
476            metadata: Metadata::from_raw(vec![1, 2, 3]),
477            modules: BTreeMap::new(),
478        };
479
480        let encoded = orig.consensus_encode_to_vec();
481        assert_eq!(
482            orig,
483            ClientBackup::consensus_decode_whole(&encoded, &ModuleRegistry::default())?
484        );
485
486        Ok(())
487    }
488
489    #[test]
490    fn sanity_ecash_backup_encrypt_decrypt() -> Result<()> {
491        const ENCRYPTION_HEADER_LEN: usize = 28;
492
493        let orig = ClientBackup {
494            modules: BTreeMap::new(),
495            session_count: 1,
496            metadata: Metadata::from_raw(vec![1, 2, 3]),
497        };
498
499        let secret = DerivableSecret::new_root(&[1; 32], &[1, 32]);
500        let key = Client::get_derived_backup_encryption_key_static(&secret);
501
502        let empty_encrypted = fedimint_aead::encrypt(vec![], &key)?;
503        assert_eq!(empty_encrypted.len(), ENCRYPTION_HEADER_LEN);
504
505        let encrypted = orig.encrypt_to(&key)?;
506        assert_eq!(
507            encrypted.len(),
508            ClientBackup::PADDING_ALIGNMENT + ENCRYPTION_HEADER_LEN
509        );
510
511        let decrypted = encrypted.decrypt_with(&key, &ModuleRegistry::default())?;
512
513        assert_eq!(orig, decrypted);
514
515        Ok(())
516    }
517}