fedimint_client/
backup.rs

1use std::cmp::Reverse;
2use std::collections::{BTreeMap, BTreeSet};
3use std::io::{Cursor, Error, Read, Write};
4
5use anyhow::{bail, ensure, Context, Result};
6use bitcoin::secp256k1::{Keypair, PublicKey, Secp256k1, SignOnly};
7use fedimint_api_client::api::DynGlobalApi;
8use fedimint_core::core::backup::{
9    BackupRequest, SignedBackupRequest, BACKUP_REQUEST_MAX_PAYLOAD_SIZE_BYTES,
10};
11use fedimint_core::core::ModuleInstanceId;
12use fedimint_core::db::IDatabaseTransactionOpsCoreTyped;
13use fedimint_core::encoding::{Decodable, DecodeError, Encodable};
14use fedimint_core::module::registry::ModuleDecoderRegistry;
15use fedimint_derive_secret::DerivableSecret;
16use fedimint_eventlog::{Event, EventKind};
17use fedimint_logging::{LOG_CLIENT, LOG_CLIENT_BACKUP, LOG_CLIENT_RECOVERY};
18use serde::{Deserialize, Serialize};
19use tracing::{debug, info, warn};
20
21use super::Client;
22use crate::db::LastBackupKey;
23use crate::get_decoded_client_secret;
24use crate::module::recovery::DynModuleBackup;
25use crate::secret::DeriveableSecretClientExt;
26
27/// Backup metadata
28///
29/// A backup can have a blob of extra data encoded in it. We provide methods to
30/// use json encoding, but clients are free to use their own encoding.
31#[derive(Serialize, Deserialize, PartialEq, Eq, Debug, Encodable, Decodable, Clone)]
32pub struct Metadata(Vec<u8>);
33
34impl Metadata {
35    /// Create empty metadata
36    pub fn empty() -> Self {
37        Self(vec![])
38    }
39
40    pub fn from_raw(bytes: Vec<u8>) -> Self {
41        Self(bytes)
42    }
43
44    pub fn into_raw(self) -> Vec<u8> {
45        self.0
46    }
47
48    /// Is metadata empty
49    pub fn is_empty(&self) -> bool {
50        self.0.is_empty()
51    }
52
53    /// Create metadata as json from typed `val`
54    pub fn from_json_serialized<T: Serialize>(val: T) -> Self {
55        Self(serde_json::to_vec(&val).expect("serializing to vec can't fail"))
56    }
57
58    /// Attempt to deserialize metadata as typed json
59    pub fn to_json_deserialized<T: serde::de::DeserializeOwned>(&self) -> Result<T> {
60        Ok(serde_json::from_slice(&self.0)?)
61    }
62
63    /// Attempt to deserialize metadata as untyped json (`serde_json::Value`)
64    pub fn to_json_value(&self) -> Result<serde_json::Value> {
65        Ok(serde_json::from_slice(&self.0)?)
66    }
67}
68
69/// Client state backup
70#[derive(PartialEq, Eq, Debug, Clone)]
71pub struct ClientBackup {
72    /// Session count taken right before taking the backup
73    /// used to timestamp the backup file. Used for finding the
74    /// most recent backup from all available ones.
75    ///
76    /// Warning: Each particular module backup for each instance
77    /// in `Self::modules` could have been taken earlier than
78    /// that (e.g. older one used due to size limits), so modules
79    /// MUST maintain their own `session_count`s.
80    pub session_count: u64,
81    /// Application metadata
82    pub metadata: Metadata,
83    // TODO: remove redundant ModuleInstanceId
84    /// Module specific-backup (if supported)
85    pub modules: BTreeMap<ModuleInstanceId, DynModuleBackup>,
86}
87
88impl ClientBackup {
89    pub const PADDING_ALIGNMENT: usize = 4 * 1024;
90
91    /// "32kiB is enough for any module backup" --dpc
92    ///
93    /// Federation storage is scarce, and since we can take older versions of
94    /// the backup, temporarily going over the limit is not a big problem.
95    pub const PER_MODULE_SIZE_LIMIT_BYTES: usize = 32 * 1024;
96
97    /// Align an ecoded message size up for better privacy
98    fn get_alignment_size(len: usize) -> usize {
99        let padding_alignment = Self::PADDING_ALIGNMENT;
100        ((len.saturating_sub(1) / padding_alignment) + 1) * padding_alignment
101    }
102
103    /// Encrypt with a key and turn into [`EncryptedClientBackup`]
104    pub fn encrypt_to(&self, key: &fedimint_aead::LessSafeKey) -> Result<EncryptedClientBackup> {
105        let mut encoded = Encodable::consensus_encode_to_vec(self);
106
107        let alignment_size = Self::get_alignment_size(encoded.len());
108        let padding_size = alignment_size - encoded.len();
109        encoded.write_all(&vec![0u8; padding_size])?;
110
111        let encrypted = fedimint_aead::encrypt(encoded, key)?;
112        Ok(EncryptedClientBackup(encrypted))
113    }
114
115    /// Validate and fallback invalid parts of the backup
116    ///
117    /// Given the size constraints and possible 3rd party modules,
118    /// it seems to use older, but smaller versions of backups when
119    /// current ones do not fit (either globally or in per-module limit).
120    fn validate_and_fallback_module_backups(
121        self,
122        last_backup: Option<&ClientBackup>,
123    ) -> ClientBackup {
124        // take all module ids from both backup and add them together
125        let all_ids: BTreeSet<_> = self
126            .modules
127            .keys()
128            .chain(last_backup.iter().flat_map(|b| b.modules.keys()))
129            .copied()
130            .collect();
131
132        let mut modules = BTreeMap::new();
133        for module_id in all_ids {
134            if let Some(module_backup) = self
135                .modules
136                .get(&module_id)
137                .or_else(|| last_backup.and_then(|lb| lb.modules.get(&module_id)))
138            {
139                let size = module_backup.consensus_encode_to_len();
140                let limit = Self::PER_MODULE_SIZE_LIMIT_BYTES;
141                if size < limit {
142                    modules.insert(module_id, module_backup.clone());
143                } else if let Some(last_module_backup) =
144                    last_backup.and_then(|lb| lb.modules.get(&module_id))
145                {
146                    let size_previous = last_module_backup.consensus_encode_to_len();
147                    warn!(
148                        target: LOG_CLIENT_BACKUP,
149                        size,
150                        limit,
151                        %module_id,
152                        size_previous,
153                        "Module backup too large, will use previous version"
154                    );
155                    modules.insert(module_id, last_module_backup.clone());
156                } else {
157                    warn!(
158                        target: LOG_CLIENT_BACKUP,
159                        size,
160                        limit,
161                        %module_id,
162                        "Module backup too large, no previous version available to fall-back to"
163                    );
164                }
165            }
166        }
167        ClientBackup {
168            session_count: self.session_count,
169            metadata: self.metadata,
170            modules,
171        }
172    }
173}
174
175impl Encodable for ClientBackup {
176    fn consensus_encode<W: Write>(&self, writer: &mut W) -> std::result::Result<usize, Error> {
177        let mut len = 0;
178        len += self.session_count.consensus_encode(writer)?;
179        len += self.metadata.consensus_encode(writer)?;
180        len += self.modules.consensus_encode(writer)?;
181
182        // Old-style padding.
183        //
184        // Older version of the client used a custom zero-filled vec to
185        // pad `ClientBackup` to alignment-size here. This has a problem
186        // that the size of encoding of the padding can have different sizes
187        // (due to var-ints).
188        //
189        // Conceptually serialization is also a the wrong place to do padding
190        // anyway, so we moved padding to encrypt/decryption. But for abundance of
191        // caution, we'll keep the old padding around, and always fill it
192        // with an empty Vec.
193        len += Vec::<u8>::new().consensus_encode(writer)?;
194
195        Ok(len)
196    }
197}
198
199impl Decodable for ClientBackup {
200    fn consensus_decode_partial<R: Read>(
201        r: &mut R,
202        modules: &ModuleDecoderRegistry,
203    ) -> std::result::Result<Self, DecodeError> {
204        let session_count = u64::consensus_decode_partial(r, modules).context("session_count")?;
205        let metadata = Metadata::consensus_decode_partial(r, modules).context("metadata")?;
206        let module_backups =
207            BTreeMap::<ModuleInstanceId, DynModuleBackup>::consensus_decode_partial(r, modules)
208                .context("module_backups")?;
209        let _padding = Vec::<u8>::consensus_decode_partial(r, modules).context("padding")?;
210
211        Ok(Self {
212            session_count,
213            metadata,
214            modules: module_backups,
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(&self.api, &self.root_secret(), &self.decoders)
360            .await
361    }
362
363    /// Download most recent valid backup found from the Federation
364    pub async fn download_backup_from_federation_static(
365        api: &DynGlobalApi,
366        root_secret: &DerivableSecret,
367        decoders: &ModuleDecoderRegistry,
368    ) -> Result<Option<ClientBackup>> {
369        debug!(target: LOG_CLIENT, "Downloading backup from the federation");
370        let mut responses: Vec<_> = api
371            .download_backup(&Client::get_backup_id_static(root_secret))
372            .await?
373            .into_iter()
374            .filter_map(|(peer, backup)| {
375                match EncryptedClientBackup(backup?.data).decrypt_with(
376                    &Self::get_derived_backup_encryption_key_static(root_secret),
377                    decoders,
378                ) {
379                    Ok(valid) => Some(valid),
380                    Err(e) => {
381                        warn!(
382                            target: LOG_CLIENT_RECOVERY,
383                            "Invalid backup returned by {peer}: {e}"
384                        );
385                        None
386                    }
387                }
388            })
389            .collect();
390
391        debug!(
392            target: LOG_CLIENT_RECOVERY,
393            "Received {} valid responses",
394            responses.len()
395        );
396        // Use the newest (highest epoch)
397        responses.sort_by_key(|backup| Reverse(backup.session_count));
398
399        Ok(responses.into_iter().next())
400    }
401
402    /// Backup id derived from the root secret key (public key used to self-sign
403    /// backup requests)
404    pub fn get_backup_id(&self) -> PublicKey {
405        self.get_derived_backup_signing_key().public_key()
406    }
407
408    pub fn get_backup_id_static(root_secret: &DerivableSecret) -> PublicKey {
409        Self::get_derived_backup_signing_key_static(root_secret).public_key()
410    }
411    /// Static version of [`Self::get_derived_backup_encryption_key`] for
412    /// testing without creating whole `MintClient`
413    fn get_derived_backup_encryption_key_static(
414        secret: &DerivableSecret,
415    ) -> fedimint_aead::LessSafeKey {
416        fedimint_aead::LessSafeKey::new(secret.derive_backup_secret().to_chacha20_poly1305_key())
417    }
418
419    /// Static version of [`Self::get_derived_backup_signing_key`] for testing
420    /// without creating whole `MintClient`
421    fn get_derived_backup_signing_key_static(secret: &DerivableSecret) -> Keypair {
422        secret
423            .derive_backup_secret()
424            .to_secp_key(&Secp256k1::<SignOnly>::gen_new())
425    }
426
427    fn get_derived_backup_encryption_key(&self) -> fedimint_aead::LessSafeKey {
428        Self::get_derived_backup_encryption_key_static(&self.root_secret())
429    }
430
431    fn get_derived_backup_signing_key(&self) -> Keypair {
432        Self::get_derived_backup_signing_key_static(&self.root_secret())
433    }
434
435    pub async fn get_decoded_client_secret<T: Decodable>(&self) -> anyhow::Result<T> {
436        get_decoded_client_secret::<T>(self.db()).await
437    }
438}
439
440#[cfg(test)]
441mod tests {
442    use std::collections::BTreeMap;
443
444    use anyhow::Result;
445    use fedimint_core::encoding::{Decodable, Encodable};
446    use fedimint_core::module::registry::ModuleRegistry;
447    use fedimint_derive_secret::DerivableSecret;
448
449    use crate::backup::{ClientBackup, Metadata};
450    use crate::Client;
451
452    #[test]
453    fn sanity_ecash_backup_align() {
454        assert_eq!(
455            ClientBackup::get_alignment_size(1),
456            ClientBackup::PADDING_ALIGNMENT
457        );
458        assert_eq!(
459            ClientBackup::get_alignment_size(ClientBackup::PADDING_ALIGNMENT),
460            ClientBackup::PADDING_ALIGNMENT
461        );
462        assert_eq!(
463            ClientBackup::get_alignment_size(ClientBackup::PADDING_ALIGNMENT + 1),
464            ClientBackup::PADDING_ALIGNMENT * 2
465        );
466    }
467
468    #[test]
469    fn sanity_ecash_backup_decode_encode() -> Result<()> {
470        let orig = ClientBackup {
471            session_count: 0,
472            metadata: Metadata::from_raw(vec![1, 2, 3]),
473            modules: BTreeMap::new(),
474        };
475
476        let encoded = orig.consensus_encode_to_vec();
477        assert_eq!(
478            orig,
479            ClientBackup::consensus_decode_whole(&encoded, &ModuleRegistry::default())?
480        );
481
482        Ok(())
483    }
484
485    #[test]
486    fn sanity_ecash_backup_encrypt_decrypt() -> Result<()> {
487        const ENCRYPTION_HEADER_LEN: usize = 28;
488
489        let orig = ClientBackup {
490            modules: BTreeMap::new(),
491            session_count: 1,
492            metadata: Metadata::from_raw(vec![1, 2, 3]),
493        };
494
495        let secret = DerivableSecret::new_root(&[1; 32], &[1, 32]);
496        let key = Client::get_derived_backup_encryption_key_static(&secret);
497
498        let empty_encrypted = fedimint_aead::encrypt(vec![], &key)?;
499        assert_eq!(empty_encrypted.len(), ENCRYPTION_HEADER_LEN);
500
501        let encrypted = orig.encrypt_to(&key)?;
502        assert_eq!(
503            encrypted.len(),
504            ClientBackup::PADDING_ALIGNMENT + ENCRYPTION_HEADER_LEN
505        );
506
507        let decrypted = encrypted.decrypt_with(&key, &ModuleRegistry::default())?;
508
509        assert_eq!(orig, decrypted);
510
511        Ok(())
512    }
513}