diff --git a/crates/relicario-core/src/backup.rs b/crates/relicario-core/src/backup.rs new file mode 100644 index 0000000..7e86368 --- /dev/null +++ b/crates/relicario-core/src/backup.rs @@ -0,0 +1,340 @@ +//! 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> { + 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(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)), + }, + }) +} diff --git a/crates/relicario-core/src/lib.rs b/crates/relicario-core/src/lib.rs index 4bc41fd..1fedf8d 100644 --- a/crates/relicario-core/src/lib.rs +++ b/crates/relicario-core/src/lib.rs @@ -77,3 +77,6 @@ pub use vault::{ }; pub mod imgsecret; + +pub mod backup; +pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment}; diff --git a/crates/relicario-core/tests/backup.rs b/crates/relicario-core/tests/backup.rs new file mode 100644 index 0000000..9dabdcb --- /dev/null +++ b/crates/relicario-core/tests/backup.rs @@ -0,0 +1,32 @@ +//! Backup container round-trip + error-path coverage. + +use relicario_core::backup::{pack_backup, unpack_backup, BackupInput, BackupOutput}; + +fn empty_input() -> BackupInput<'static> { + BackupInput { + salt: &[0u8; 32], + params_json: r#"{"format_version":2,"kdf":{"argon2_m":256,"argon2_t":1,"argon2_p":1},"aead":"xchacha20poly1305","salt_path":".relicario/salt"}"#, + devices_json: "[]", + manifest_enc: &[], + settings_enc: &[], + items: vec![], + attachments: vec![], + reference_jpg: None, + git_archive: None, + } +} + +#[test] +fn empty_vault_round_trip() { + let out = pack_backup(empty_input(), "test-passphrase-1234").unwrap(); + assert_eq!(&out[..4], b"RBAK", "magic header"); + assert_eq!(out[4], 0x01, "format version"); + + let unpacked = unpack_backup(&out, "test-passphrase-1234").unwrap(); + assert_eq!(unpacked.salt, [0u8; 32]); + assert!(unpacked.devices_json.contains("[]")); + assert!(unpacked.items.is_empty()); + assert!(unpacked.attachments.is_empty()); + assert!(unpacked.reference_jpg.is_none()); + assert!(unpacked.git_archive.is_none()); +}