//! Backup container — encrypted, compressed, single-file archive of a vault. //! //! ## Format (v1) //! //! ```text //! [magic "RBAK" 4 bytes][version 0x01 1 byte][salt 32 bytes][nonce 24 bytes][ciphertext+tag] //! ``` //! //! After AEAD decryption, the plaintext is zstd-compressed bytes whose //! decompressed form is a UTF-8 JSON document — see [`Envelope`]. //! //! The backup container key is **independent** of any vault master key. //! The user picks a backup passphrase at export and types it at restore. //! Argon2id parameters are pinned to v1-of-this-format (m=64MiB, t=3, p=4) //! so a v1 reader does not need to negotiate them. use argon2::{Algorithm, Argon2, Params, Version}; use base64::Engine; use chacha20poly1305::{ aead::{Aead, KeyInit}, XChaCha20Poly1305, XNonce, }; use rand::{rngs::OsRng, RngCore}; use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::error::{RelicarioError, Result}; /// File-level magic. Four bytes so a `file(1)` rule can identify it. pub const MAGIC: [u8; 4] = *b"RBAK"; /// Container format version. Bumped if the on-disk layout of the /// salt/nonce/ciphertext header or the AEAD primitive changes. pub const FORMAT_VERSION: u8 = 0x01; /// JSON envelope schema version. Bumped if the JSON shape changes /// without an underlying-format change (e.g. new optional fields whose /// absence v1 readers can tolerate would NOT bump this; renames or /// removals would). pub const SCHEMA_VERSION: u32 = 1; const SALT_LEN: usize = 32; const NONCE_LEN: usize = 24; const TAG_LEN: usize = 16; const HEADER_LEN: usize = 4 + 1 + SALT_LEN + NONCE_LEN; // magic + version + salt + nonce const ARGON2_M_KIB: u32 = 65_536; // 64 MiB const ARGON2_T: u32 = 3; const ARGON2_P: u32 = 4; /// Zstd compression level. 3 is the speed/size sweet spot. const ZSTD_LEVEL: i32 = 3; /// Inputs to [`pack_backup`]. Borrow-only — the caller retains ownership of /// every byte slice. pub struct BackupInput<'a> { /// Raw 32-byte vault salt (`.relicario/salt` contents). pub salt: &'a [u8], /// Verbatim string contents of `.relicario/params.json`. pub params_json: &'a str, /// Verbatim string contents of `.relicario/devices.json`. pub devices_json: &'a str, /// Encrypted manifest bytes (verbatim `manifest.enc`). pub manifest_enc: &'a [u8], /// Encrypted vault settings bytes (verbatim `settings.enc`). pub settings_enc: &'a [u8], /// One entry per item file (verbatim ciphertext). pub items: Vec>, /// One entry per attachment blob (verbatim ciphertext). pub attachments: Vec>, /// Reference JPEG bytes — included iff caller wants to bundle the /// second factor. pub reference_jpg: Option<&'a [u8]>, /// Tarred `.git/` directory — included iff caller wants the audit log. /// The caller (CLI) does the actual tarring; core just transports the /// opaque bytes. pub git_archive: Option<&'a [u8]>, } /// One vault item ciphertext, keyed by the item id (16-char hex). pub struct BackupItem<'a> { pub id: String, pub ciphertext: &'a [u8], } /// One attachment blob, keyed by `/` so the /// per-item directory layout round-trips. pub struct BackupAttachment<'a> { pub item_id: String, pub attachment_id: String, pub ciphertext: &'a [u8], } /// Output of [`unpack_backup`]. Owned bytes — the caller decides where to /// persist them. #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackupOutput { pub salt: [u8; 32], pub params_json: String, pub devices_json: String, pub manifest_enc: Vec, pub settings_enc: Vec, pub items: Vec, pub attachments: Vec, pub reference_jpg: Option>, pub git_archive: Option>, pub created_at: i64, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnpackedItem { pub id: String, pub ciphertext: Vec, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct UnpackedAttachment { pub item_id: String, pub attachment_id: String, pub ciphertext: Vec, } #[derive(Serialize, Deserialize)] struct Envelope { schema_version: u32, created_at: i64, vault: VaultEnvelope, } #[derive(Serialize, Deserialize)] struct VaultEnvelope { /// base64-encoded 32-byte vault salt. salt: String, /// Verbatim params.json contents (string, not nested object — keeps /// forward-compat with future params.json schema changes opaque to /// the backup format). params: String, /// Verbatim devices.json contents (string for the same reason). devices: String, /// base64-encoded ciphertext of `manifest.enc`. manifest: String, /// base64-encoded ciphertext of `settings.enc`. settings: String, /// Map of `item_id` → base64-encoded item ciphertext. items: std::collections::BTreeMap, /// Map of `/` → base64-encoded ciphertext. attachments: std::collections::BTreeMap, #[serde(default, skip_serializing_if = "Option::is_none")] reference_jpg: Option, #[serde(default, skip_serializing_if = "Option::is_none")] git_archive: Option, } /// Pack a vault into the `.relbak` container. /// /// Generates fresh 32-byte salt + 24-byte nonce via OsRng. Derives a /// 32-byte key via Argon2id with the format-pinned parameters, then /// XChaCha20-Poly1305 encrypts the zstd-compressed JSON envelope. pub fn pack_backup(input: BackupInput<'_>, passphrase: &str) -> Result> { let mut salt = [0u8; SALT_LEN]; OsRng.fill_bytes(&mut salt); let mut nonce_bytes = [0u8; NONCE_LEN]; OsRng.fill_bytes(&mut nonce_bytes); let key = derive_backup_key(passphrase.as_bytes(), &salt)?; let envelope = build_envelope(input, crate::time::now_unix())?; let json = serde_json::to_vec(&envelope)?; let compressed = zstd::encode_all(&json[..], ZSTD_LEVEL) .map_err(|e| RelicarioError::Format(format!("zstd compress: {e}")))?; let cipher = XChaCha20Poly1305::new((&*key).into()); let nonce = XNonce::from(nonce_bytes); let ciphertext = cipher .encrypt(&nonce, compressed.as_slice()) .map_err(|e| RelicarioError::Encrypt(e.to_string()))?; let mut out = Vec::with_capacity(HEADER_LEN + ciphertext.len()); out.extend_from_slice(&MAGIC); out.push(FORMAT_VERSION); out.extend_from_slice(&salt); out.extend_from_slice(&nonce_bytes); out.extend_from_slice(&ciphertext); Ok(out) } /// Unpack a `.relbak` container, verifying magic + version, decrypting, /// decompressing, and parsing the JSON envelope. pub fn unpack_backup(data: &[u8], passphrase: &str) -> Result { if data.len() < HEADER_LEN + TAG_LEN { return Err(RelicarioError::Format( "backup file truncated".into(), )); } if data[0..4] != MAGIC { return Err(RelicarioError::BackupBadMagic); } let version = data[4]; if version != FORMAT_VERSION { return Err(RelicarioError::BackupUnsupportedVersion { found: version, expected: FORMAT_VERSION, }); } let mut salt = [0u8; SALT_LEN]; salt.copy_from_slice(&data[5..5 + SALT_LEN]); let nonce_start = 5 + SALT_LEN; let nonce_bytes: &[u8] = &data[nonce_start..nonce_start + NONCE_LEN]; let ciphertext = &data[HEADER_LEN..]; let key = derive_backup_key(passphrase.as_bytes(), &salt)?; let cipher = XChaCha20Poly1305::new((&*key).into()); let nonce = XNonce::from_slice(nonce_bytes); let compressed = cipher .decrypt(nonce, ciphertext) .map_err(|_| RelicarioError::Decrypt)?; let json_bytes = zstd::decode_all(compressed.as_slice()) .map_err(|e| RelicarioError::Format(format!("zstd decompress: {e}")))?; let env: Envelope = serde_json::from_slice(&json_bytes)?; if env.schema_version != SCHEMA_VERSION { return Err(RelicarioError::BackupSchemaMismatch { found: env.schema_version, expected: SCHEMA_VERSION, }); } let b64 = base64::engine::general_purpose::STANDARD; let mut salt_out = [0u8; 32]; let salt_decoded = b64 .decode(&env.vault.salt) .map_err(|e| RelicarioError::Format(format!("base64 salt: {e}")))?; if salt_decoded.len() != 32 { return Err(RelicarioError::Format(format!( "salt length: expected 32, got {}", salt_decoded.len() ))); } salt_out.copy_from_slice(&salt_decoded); let manifest_enc = b64 .decode(&env.vault.manifest) .map_err(|e| RelicarioError::Format(format!("base64 manifest: {e}")))?; let settings_enc = b64 .decode(&env.vault.settings) .map_err(|e| RelicarioError::Format(format!("base64 settings: {e}")))?; let mut items = Vec::with_capacity(env.vault.items.len()); for (id, b64_ct) in env.vault.items { let ct = b64 .decode(&b64_ct) .map_err(|e| RelicarioError::Format(format!("base64 item {id}: {e}")))?; items.push(UnpackedItem { id, ciphertext: ct }); } let mut attachments = Vec::with_capacity(env.vault.attachments.len()); for (combined, b64_ct) in env.vault.attachments { let (item_id, attachment_id) = combined .split_once('/') .map(|(a, b)| (a.to_string(), b.to_string())) .ok_or_else(|| { RelicarioError::Format(format!("bad attachment key '{combined}'")) })?; let ct = b64 .decode(&b64_ct) .map_err(|e| RelicarioError::Format(format!("base64 attachment {combined}: {e}")))?; attachments.push(UnpackedAttachment { item_id, attachment_id, ciphertext: ct }); } let reference_jpg = env .vault .reference_jpg .as_deref() .map(|s| b64.decode(s)) .transpose() .map_err(|e| RelicarioError::Format(format!("base64 reference_jpg: {e}")))?; let git_archive = env .vault .git_archive .as_deref() .map(|s| b64.decode(s)) .transpose() .map_err(|e| RelicarioError::Format(format!("base64 git_archive: {e}")))?; Ok(BackupOutput { salt: salt_out, params_json: env.vault.params, devices_json: env.vault.devices, manifest_enc, settings_enc, items, attachments, reference_jpg, git_archive, created_at: env.created_at, }) } fn derive_backup_key(passphrase: &[u8], salt: &[u8]) -> Result> { use unicode_normalization::UnicodeNormalization; // NFC normalize passphrase (matches derive_master_key in crypto.rs) let nfc_passphrase: Vec = match std::str::from_utf8(passphrase) { Ok(s) => s.nfc().collect::().into_bytes(), Err(_) => passphrase.to_vec(), }; let params = Params::new(ARGON2_M_KIB, ARGON2_T, ARGON2_P, Some(32)) .map_err(|e| RelicarioError::Kdf(format!("argon2 params: {e}")))?; let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params); let mut key = Zeroizing::new([0u8; 32]); argon .hash_password_into(&nfc_passphrase, salt, key.as_mut_slice()) .map_err(|e| RelicarioError::Kdf(format!("argon2 hash: {e}")))?; Ok(key) } fn build_envelope(input: BackupInput<'_>, created_at: i64) -> Result { let b64 = base64::engine::general_purpose::STANDARD; let mut items = std::collections::BTreeMap::new(); for it in input.items { items.insert(it.id, b64.encode(it.ciphertext)); } let mut attachments = std::collections::BTreeMap::new(); for a in input.attachments { let key = format!("{}/{}", a.item_id, a.attachment_id); attachments.insert(key, b64.encode(a.ciphertext)); } Ok(Envelope { schema_version: SCHEMA_VERSION, created_at, vault: VaultEnvelope { salt: b64.encode(input.salt), params: input.params_json.to_string(), devices: input.devices_json.to_string(), manifest: b64.encode(input.manifest_enc), settings: b64.encode(input.settings_enc), items, attachments, reference_jpg: input.reference_jpg.map(|b| b64.encode(b)), git_archive: input.git_archive.map(|b| b64.encode(b)), }, }) }