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