Skip to main content

xmtp_archive/
lib.rs

1//! The XMTP archive container: a versioned header, then AES-256-GCM frames of `BackupElement`s
2//! inside one zstd stream.
3//!
4//! `xmtp_mls`, `xmtp_sdk`, and the bindings use [`exporter::export`], or the
5//! native `exporter::ArchiveExporter` adapters, which write a file or a buffer off
6//! the async workers, to write an archive from the database and [`ArchiveImporter`] to
7//! read one back. [`check_key`] validates a key up front. [`ArchiveError`]
8//! implements `RetryableError`. The exporter streams one consistent snapshot of every selected element (see
9//! `snapshot`) and fails with [`ArchiveError`] if selected data cannot be read. The importer rejects
10//! any container version above [`BACKUP_VERSION`] and ends with an error on any incomplete or
11//! malformed framing.
12use crate::archive_options::BackupElementSelection;
13use aes_gcm::{Aes256Gcm, KeyInit};
14pub use importer::ArchiveImporter;
15use thiserror::Error;
16use xmtp_common::{ErrorCode, RetryableError};
17use xmtp_db::{ConnectionError, StorageError, diesel, sql_key_store::SqlKeyStoreError};
18use xmtp_mls_common::{
19    group_metadata::GroupMetadataError, group_mutable_metadata::GroupMutableMetadataError,
20};
21use xmtp_proto::{types::GroupId, xmtp::device_sync::BackupMetadataSave};
22
23pub const ENC_KEY_SIZE: usize = 32; // 256-bit key
24pub const NONCE_SIZE: usize = 12; // 96-bit nonce
25pub const TAG_SIZE: usize = 16; // 128-bit AES-GCM tag
26
27// Increment on breaking changes
28pub const BACKUP_VERSION: u16 = 0;
29
30pub mod archive_options;
31pub mod exporter;
32pub mod importer;
33mod snapshot;
34mod util;
35
36/// Archive export or import failure.
37#[derive(Debug, Error, ErrorCode)]
38pub enum ArchiveError {
39    /// Unsupported archive version.
40    ///
41    /// The archive was written by a newer client. Not retryable.
42    #[error("Unsupported archive version {0}; this client reads version {BACKUP_VERSION}")]
43    UnsupportedVersion(u16),
44    /// Missing metadata.
45    ///
46    /// The archive has no metadata frame. Not retryable.
47    #[error("Missing metadata")]
48    MissingMetadata,
49    /// Invalid archive frame.
50    ///
51    /// The archive framing is incomplete or malformed. Not retryable.
52    #[error("Invalid archive frame: {0}")]
53    InvalidFrame(&'static str),
54    /// AES-GCM error.
55    ///
56    /// Encryption or decryption failed; on import, usually a wrong key. Not retryable.
57    #[error("AES-GCM encryption error")]
58    AesGcm(#[from] aes_gcm::Error),
59    /// I/O error.
60    ///
61    /// Reading or writing the archive failed. May be retryable.
62    #[error("IO error: {0}")]
63    IO(#[from] std::io::Error),
64    /// Decode error.
65    ///
66    /// An archive element is not valid protobuf. Not retryable.
67    #[error(transparent)]
68    Decode(#[from] prost::DecodeError),
69    #[error(transparent)]
70    #[error_code(inherit)]
71    Storage(#[from] StorageError),
72    /// Invalid key length.
73    ///
74    /// The archive key is not [`ENC_KEY_SIZE`] bytes. Rejected before any archive byte is
75    /// read or written. Not retryable.
76    #[error("archive key must be {ENC_KEY_SIZE} bytes, got {0}")]
77    InvalidKeyLength(usize),
78    /// Unreadable group.
79    ///
80    /// A selected group's MLS state or metadata cannot be read, so export
81    /// fails rather than omit it. Retryable only when reading its MLS state
82    /// failed transiently.
83    #[error("group {group_id} cannot be exported: {source}")]
84    UnreadableGroup {
85        group_id: GroupId,
86        #[source]
87        source: UnreadableGroup,
88    },
89}
90
91impl RetryableError for ArchiveError {
92    /// Only interrupted or timed-out I/O and transient storage failures,
93    /// including those reading a group's MLS state. Every other I/O kind, such
94    /// as the `UnexpectedEof` of a truncated archive, and every format, key,
95    /// missing-state and metadata error recurs on each attempt.
96    fn is_retryable(&self) -> bool {
97        use std::io::ErrorKind::*;
98        match self {
99            Self::IO(e) => matches!(e.kind(), Interrupted | WouldBlock | TimedOut),
100            Self::Storage(e) => e.is_retryable(),
101            Self::UnreadableGroup {
102                source: UnreadableGroup::State(e),
103                ..
104            } => e.is_retryable(),
105            _ => false,
106        }
107    }
108}
109
110impl From<ConnectionError> for ArchiveError {
111    fn from(e: ConnectionError) -> Self {
112        Self::Storage(e.into())
113    }
114}
115
116impl From<diesel::result::Error> for ArchiveError {
117    fn from(e: diesel::result::Error) -> Self {
118        Self::Storage(e.into())
119    }
120}
121
122/// Why an eligible group could not be exported.
123#[derive(Debug, Error)]
124pub enum UnreadableGroup {
125    #[error("no MLS group state")]
126    MissingState,
127    #[error(transparent)]
128    State(#[from] SqlKeyStoreError),
129    #[error(transparent)]
130    Metadata(#[from] GroupMetadataError),
131    #[error(transparent)]
132    MutableMetadata(#[from] GroupMutableMetadataError),
133}
134
135/// Rejects a key that is not [`ENC_KEY_SIZE`] bytes. Export and import call
136/// it first; callers holding a large input may call it before copying that
137/// input.
138// implements: ARCH-012
139pub fn check_key(key: &[u8]) -> Result<(), ArchiveError> {
140    match key.len() {
141        ENC_KEY_SIZE => Ok(()),
142        len => Err(ArchiveError::InvalidKeyLength(len)),
143    }
144}
145
146/// The archive cipher for `key`, which must be [`ENC_KEY_SIZE`] bytes.
147fn cipher(key: &[u8]) -> Result<Aes256Gcm, ArchiveError> {
148    Aes256Gcm::new_from_slice(key).map_err(|_| ArchiveError::InvalidKeyLength(key.len()))
149}
150
151#[derive(Default)]
152pub struct BackupMetadata {
153    pub backup_version: u16,
154    pub elements: Vec<BackupElementSelection>,
155    pub exported_at_ns: i64,
156    pub start_ns: Option<i64>,
157    pub end_ns: Option<i64>,
158}
159
160impl BackupMetadata {
161    pub fn from_metadata_save(save: BackupMetadataSave, backup_version: u16) -> Self {
162        Self {
163            elements: save.elements().map(Into::into).collect(),
164            end_ns: save.end_ns,
165            start_ns: save.start_ns,
166            exported_at_ns: save.exported_at_ns,
167            backup_version,
168        }
169    }
170}