From 57dd186bab85e1bb9c5a8ebf3f8cdabe629063ea Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 22:22:04 -0400 Subject: [PATCH 01/23] feat(core): add backup deps + error variants Adds zstd, tar, base64 to relicario-core; introduces BackupBadMagic / BackupUnsupportedVersion / BackupSchemaMismatch. Foundation for the backup module landing in Task 2. --- Cargo.lock | 110 ++++++++++++++++++++++++++++- crates/relicario-core/Cargo.toml | 3 + crates/relicario-core/src/error.rs | 26 +++++++ 3 files changed, 137 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a0ddfc0..db37ed1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -275,6 +275,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -651,6 +653,17 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +dependencies = [ + "cfg-if", + "libc", + "libredox", +] + [[package]] name = "find-msvc-tools" version = "0.1.9" @@ -748,6 +761,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + [[package]] name = "getrandom" version = "0.4.2" @@ -756,7 +781,7 @@ checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", "wasip2", "wasip3", ] @@ -1008,6 +1033,16 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.95" @@ -1050,7 +1085,10 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ + "bitflags", "libc", + "plain", + "redox_syscall 0.7.4", ] [[package]] @@ -1268,7 +1306,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall", + "redox_syscall 0.5.18", "smallvec", "windows-link", ] @@ -1306,6 +1344,18 @@ dependencies = [ "spki", ] +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plain" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" + [[package]] name = "png" version = "0.18.1" @@ -1424,6 +1474,12 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + [[package]] name = "r-efi" version = "6.0.0" @@ -1469,6 +1525,15 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_syscall" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +dependencies = [ + "bitflags", +] + [[package]] name = "redox_users" version = "0.4.6" @@ -1539,6 +1604,7 @@ name = "relicario-core" version = "0.2.0" dependencies = [ "argon2", + "base64", "bip39", "chacha20poly1305", "chrono", @@ -1552,10 +1618,12 @@ dependencies = [ "serde_json", "sha1", "sha2", + "tar", "thiserror 2.0.18", "unicode-normalization", "url", "zeroize", + "zstd", "zxcvbn", ] @@ -1807,6 +1875,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tar" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -2710,6 +2788,34 @@ version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "zune-core" version = "0.5.1" diff --git a/crates/relicario-core/Cargo.toml b/crates/relicario-core/Cargo.toml index 81b68dd..8456073 100644 --- a/crates/relicario-core/Cargo.toml +++ b/crates/relicario-core/Cargo.toml @@ -26,5 +26,8 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc hex = "0.4" url = { version = "2", features = ["serde"] } getrandom = "0.2" +zstd = { version = "0.13", default-features = false } +tar = { version = "0.4", default-features = false } +base64 = "0.22" [dev-dependencies] diff --git a/crates/relicario-core/src/error.rs b/crates/relicario-core/src/error.rs index c5b1f8c..5d75a82 100644 --- a/crates/relicario-core/src/error.rs +++ b/crates/relicario-core/src/error.rs @@ -39,6 +39,18 @@ pub enum RelicarioError { #[error("unsupported vault format version: found 0x{found:02x}, expected 0x{expected:02x}")] UnsupportedFormatVersion { found: u8, expected: u8 }, + /// Backup file's first 4 bytes don't match the "RBAK" magic. + #[error("not a relicario backup file")] + BackupBadMagic, + + /// Backup format version is newer than this binary supports. + #[error("backup created by a newer relicario; upgrade required")] + BackupUnsupportedVersion { found: u8, expected: u8 }, + + /// Backup envelope schema version doesn't match. + #[error("backup envelope schema v{found}; this relicario reads v{expected}")] + BackupSchemaMismatch { found: u32, expected: u32 }, + /// An item was looked up by ID but does not exist in the manifest. #[error("item not found: {0}")] ItemNotFound(String), @@ -130,4 +142,18 @@ mod tests { assert!(s.contains("01") || s.contains("1")); assert!(s.contains("02") || s.contains("2")); } + + #[test] + fn backup_errors_carry_useful_messages() { + let bad = RelicarioError::BackupBadMagic; + assert!(format!("{}", bad).contains("not a relicario backup file")); + + let ver = RelicarioError::BackupUnsupportedVersion { found: 0x02, expected: 0x01 }; + let s = format!("{}", ver); + assert!(s.contains("newer")); + + let schema = RelicarioError::BackupSchemaMismatch { found: 2, expected: 1 }; + let s = format!("{}", schema); + assert!(s.contains("v2") && s.contains("v1")); + } } From 08086b9a9e7c0ea93f0a11eb976c796b7114b0e7 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 22:29:10 -0400 Subject: [PATCH 02/23] =?UTF-8?q?feat(core):=20backup=20module=20=E2=80=94?= =?UTF-8?q?=20empty-vault=20round-trip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pack_backup / unpack_backup ship the magic header, format version, Argon2id KDF, XChaCha20-Poly1305 AEAD, and zstd-compressed JSON envelope. Empty-vault round-trip is the foundation; later tasks add items, attachments, image, and git history. --- crates/relicario-core/src/backup.rs | 340 ++++++++++++++++++++++++++ crates/relicario-core/src/lib.rs | 3 + crates/relicario-core/tests/backup.rs | 32 +++ 3 files changed, 375 insertions(+) create mode 100644 crates/relicario-core/src/backup.rs create mode 100644 crates/relicario-core/tests/backup.rs 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()); +} From 0b59b94a0b9917af04ad231cc91a24f240945a2f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 22:34:36 -0400 Subject: [PATCH 03/23] test(core): populated-vault round-trip for backup --- crates/relicario-core/tests/backup.rs | 61 +++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/relicario-core/tests/backup.rs b/crates/relicario-core/tests/backup.rs index 9dabdcb..d424e89 100644 --- a/crates/relicario-core/tests/backup.rs +++ b/crates/relicario-core/tests/backup.rs @@ -30,3 +30,64 @@ fn empty_vault_round_trip() { assert!(unpacked.reference_jpg.is_none()); assert!(unpacked.git_archive.is_none()); } + +use relicario_core::backup::{BackupAttachment, BackupItem}; + +#[test] +fn populated_vault_round_trip() { + let manifest_enc = vec![0xDE, 0xAD, 0xBE, 0xEF, 0x42]; + let settings_enc = vec![0x01, 0x02, 0x03]; + let item_a_ct = vec![0xAA; 100]; + let item_b_ct = vec![0xBB; 200]; + let attach_x_ct = vec![0xCC; 4096]; + let attach_y_ct = vec![0xDD; 8192]; + + let input = BackupInput { + salt: &[0x77u8; 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: r#"[{"name":"laptop","public_key":"deadbeef"}]"#, + manifest_enc: &manifest_enc, + settings_enc: &settings_enc, + items: vec![ + BackupItem { id: "1111111111111111".to_string(), ciphertext: &item_a_ct }, + BackupItem { id: "2222222222222222".to_string(), ciphertext: &item_b_ct }, + ], + attachments: vec![ + BackupAttachment { + item_id: "1111111111111111".to_string(), + attachment_id: "aaaa1111".to_string(), + ciphertext: &attach_x_ct, + }, + BackupAttachment { + item_id: "2222222222222222".to_string(), + attachment_id: "bbbb2222".to_string(), + ciphertext: &attach_y_ct, + }, + ], + reference_jpg: None, + git_archive: None, + }; + + let out = pack_backup(input, "another-strong-passphrase").unwrap(); + let unpacked = unpack_backup(&out, "another-strong-passphrase").unwrap(); + + assert_eq!(unpacked.salt, [0x77u8; 32]); + assert!(unpacked.devices_json.contains("laptop")); + assert_eq!(unpacked.manifest_enc, manifest_enc); + assert_eq!(unpacked.settings_enc, settings_enc); + + assert_eq!(unpacked.items.len(), 2); + let by_id: std::collections::HashMap<_, _> = + unpacked.items.iter().map(|i| (i.id.as_str(), &i.ciphertext)).collect(); + assert_eq!(by_id.get("1111111111111111").unwrap(), &&item_a_ct); + assert_eq!(by_id.get("2222222222222222").unwrap(), &&item_b_ct); + + assert_eq!(unpacked.attachments.len(), 2); + let by_aid: std::collections::HashMap<_, _> = unpacked + .attachments + .iter() + .map(|a| ((a.item_id.as_str(), a.attachment_id.as_str()), &a.ciphertext)) + .collect(); + assert_eq!(by_aid.get(&("1111111111111111", "aaaa1111")).unwrap(), &&attach_x_ct); + assert_eq!(by_aid.get(&("2222222222222222", "bbbb2222")).unwrap(), &&attach_y_ct); +} From e4949c4c06a80d0c036ec17445f81543fa61863f Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 22:37:38 -0400 Subject: [PATCH 04/23] test(core): backup round-trips reference image bytes --- crates/relicario-core/tests/backup.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/crates/relicario-core/tests/backup.rs b/crates/relicario-core/tests/backup.rs index d424e89..8274dd9 100644 --- a/crates/relicario-core/tests/backup.rs +++ b/crates/relicario-core/tests/backup.rs @@ -91,3 +91,16 @@ fn populated_vault_round_trip() { assert_eq!(by_aid.get(&("1111111111111111", "aaaa1111")).unwrap(), &&attach_x_ct); assert_eq!(by_aid.get(&("2222222222222222", "bbbb2222")).unwrap(), &&attach_y_ct); } + +#[test] +fn round_trip_with_reference_image() { + let jpg_bytes: Vec = (0u8..=255).cycle().take(1024 * 64).collect(); // 64 KiB + let mut input = empty_input(); + input.reference_jpg = Some(&jpg_bytes); + + let out = pack_backup(input, "p").unwrap(); + let unpacked = unpack_backup(&out, "p").unwrap(); + + assert_eq!(unpacked.reference_jpg.as_deref(), Some(jpg_bytes.as_slice())); + assert!(unpacked.git_archive.is_none()); +} From 1ffe3336973d3152f1065e70b07113fe10355c82 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 22:39:55 -0400 Subject: [PATCH 05/23] test(core): backup round-trips git archive + size check --- crates/relicario-core/tests/backup.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/crates/relicario-core/tests/backup.rs b/crates/relicario-core/tests/backup.rs index 8274dd9..449d127 100644 --- a/crates/relicario-core/tests/backup.rs +++ b/crates/relicario-core/tests/backup.rs @@ -104,3 +104,30 @@ fn round_trip_with_reference_image() { assert_eq!(unpacked.reference_jpg.as_deref(), Some(jpg_bytes.as_slice())); assert!(unpacked.git_archive.is_none()); } + +#[test] +fn round_trip_with_git_archive() { + let tar_bytes: Vec = b"FAKE TAR BYTES; core treats opaquely".repeat(50); + let mut input = empty_input(); + input.git_archive = Some(&tar_bytes); + + let out = pack_backup(input, "p").unwrap(); + let unpacked = unpack_backup(&out, "p").unwrap(); + + assert_eq!(unpacked.git_archive.as_deref(), Some(tar_bytes.as_slice())); +} + +#[test] +fn no_history_produces_strict_subset() { + let mut a = empty_input(); + a.git_archive = Some(b"some-tar-bytes"); + let with = pack_backup(a, "p").unwrap(); + + let without = pack_backup(empty_input(), "p").unwrap(); + + // The "without" file is strictly smaller (one fewer base64-encoded blob in JSON). + assert!(without.len() < with.len(), + "no-history backup should be smaller: with={}, without={}", + with.len(), without.len() + ); +} From e02f62f961f2b9ea5919cc93ed144b3822ea43a8 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Mon, 27 Apr 2026 22:42:44 -0400 Subject: [PATCH 06/23] test(core): backup error paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers bad magic, unsupported version, wrong passphrase, truncation, and tampered ciphertext. The wrong-passphrase / tampered-tag pair both collapse to RelicarioError::Decrypt — same opaque-failure contract as the live vault. --- crates/relicario-core/tests/backup.rs | 57 ++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/crates/relicario-core/tests/backup.rs b/crates/relicario-core/tests/backup.rs index 449d127..1ca3ec6 100644 --- a/crates/relicario-core/tests/backup.rs +++ b/crates/relicario-core/tests/backup.rs @@ -1,6 +1,6 @@ //! Backup container round-trip + error-path coverage. -use relicario_core::backup::{pack_backup, unpack_backup, BackupInput, BackupOutput}; +use relicario_core::backup::{pack_backup, unpack_backup, BackupInput}; fn empty_input() -> BackupInput<'static> { BackupInput { @@ -131,3 +131,58 @@ fn no_history_produces_strict_subset() { with.len(), without.len() ); } + +use relicario_core::RelicarioError; + +#[test] +fn bad_magic_rejected() { + let mut bytes = pack_backup(empty_input(), "p").unwrap(); + bytes[0] = b'X'; + match unpack_backup(&bytes, "p") { + Err(RelicarioError::BackupBadMagic) => {} + other => panic!("expected BackupBadMagic, got {other:?}"), + } +} + +#[test] +fn unsupported_version_rejected() { + let mut bytes = pack_backup(empty_input(), "p").unwrap(); + bytes[4] = 0xFF; + match unpack_backup(&bytes, "p") { + Err(RelicarioError::BackupUnsupportedVersion { found, expected }) => { + assert_eq!(found, 0xFF); + assert_eq!(expected, 0x01); + } + other => panic!("expected BackupUnsupportedVersion, got {other:?}"), + } +} + +#[test] +fn wrong_passphrase_rejected_as_decrypt_error() { + let bytes = pack_backup(empty_input(), "right-passphrase").unwrap(); + match unpack_backup(&bytes, "wrong-passphrase") { + Err(RelicarioError::Decrypt) => {} + other => panic!("expected Decrypt (opaque), got {other:?}"), + } +} + +#[test] +fn truncated_file_rejected() { + let bytes = pack_backup(empty_input(), "p").unwrap(); + let truncated = &bytes[..bytes.len().min(60)]; // shorter than HEADER_LEN + TAG_LEN + match unpack_backup(truncated, "p") { + Err(RelicarioError::Format(_)) => {} + other => panic!("expected Format(truncated), got {other:?}"), + } +} + +#[test] +fn tampered_ciphertext_rejected_as_decrypt_error() { + let mut bytes = pack_backup(empty_input(), "p").unwrap(); + let last = bytes.len() - 1; + bytes[last] ^= 0xFF; // flip a byte in the auth-tag region + match unpack_backup(&bytes, "p") { + Err(RelicarioError::Decrypt) => {} + other => panic!("expected Decrypt for tampered tag, got {other:?}"), + } +} From b8dfcd0e9702c4c852114ee68634c741e0fc131c Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 19:16:05 -0400 Subject: [PATCH 07/23] feat(cli): clap surface for backup export/restore (handlers stubbed) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds 'relicario backup' as a subcommand wrapping export and restore. Stubs return 'not yet implemented' — handlers land in Tasks 8 and 9. The existing top-level 'relicario restore ' (un-trash) is untouched. --- crates/relicario-cli/src/main.rs | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 7526882..9323a5b 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -94,6 +94,12 @@ enum Commands { action: TrashAction, }, + /// Backup operations: pack and unpack `.relbak` archives. + Backup { + #[command(subcommand)] + action: BackupAction, + }, + /// Attach a file to an item. Attach { query: String, file: PathBuf }, @@ -274,6 +280,35 @@ enum DeviceAction { Revoke { name: String }, } +#[derive(Subcommand)] +enum BackupAction { + /// Pack the local vault into a single encrypted `.relbak` file. + /// Backup passphrase is independent of the vault passphrase. + Export { + /// Output `.relbak` path. + out: PathBuf, + /// Bundle the reference JPEG into the encrypted envelope. + #[arg(long)] + include_image: bool, + /// Override the reference image path (defaults to the vault's + /// `reference.jpg` or `RELICARIO_IMAGE`). + #[arg(long)] + image: Option, + /// Skip bundling `.git/` history. + #[arg(long)] + no_history: bool, + }, + /// Unpack a `.relbak` file into a fresh vault directory. + Restore { + /// Input `.relbak` path. + input: PathBuf, + /// Target directory (must NOT already contain `.relicario/`). + /// Defaults to the current directory. + #[arg(default_value = ".")] + target: PathBuf, + }, +} + fn main() -> Result<()> { let cli = Cli::parse(); match cli.command { @@ -287,6 +322,7 @@ fn main() -> Result<()> { Commands::Restore { query } => cmd_restore(query), Commands::Purge { query } => cmd_purge(query), Commands::Trash { action } => cmd_trash(action), + Commands::Backup { action } => cmd_backup(action), Commands::Attach { query, file } => cmd_attach(query, file), Commands::Attachments { query } => cmd_attachments(query), Commands::Extract { query, aid, out } => cmd_extract(query, aid, out), @@ -1243,6 +1279,28 @@ fn cmd_trash(action: TrashAction) -> Result<()> { } } +fn cmd_backup(action: BackupAction) -> Result<()> { + match action { + BackupAction::Export { out, include_image, image, no_history } => { + cmd_backup_export(out, include_image, image, no_history) + } + BackupAction::Restore { input, target } => cmd_backup_restore(input, target), + } +} + +fn cmd_backup_export( + _out: PathBuf, + _include_image: bool, + _image: Option, + _no_history: bool, +) -> Result<()> { + anyhow::bail!("cmd_backup_export not yet implemented") +} + +fn cmd_backup_restore(_input: PathBuf, _target: PathBuf) -> Result<()> { + anyhow::bail!("cmd_backup_restore not yet implemented") +} + fn cmd_trash_empty() -> Result<()> { use relicario_core::time::now_unix; From 7ce57353f243923f0e13f07c72f2087f14d818ca Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 19:21:02 -0400 Subject: [PATCH 08/23] =?UTF-8?q?feat(cli):=20cmd=5Fbackup=5Fexport=20?= =?UTF-8?q?=E2=80=94=20pack=20vault=20into=20.relbak?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads the vault layout from disk, prompts for backup passphrase (zxcvbn-gated, independent of the live vault key), tars .git/ unless --no-history, optionally bundles the reference JPEG, and atomic-writes the .relbak. Leaves .relicario/last_backup marker for cmd_status. --- Cargo.lock | 1 + crates/relicario-cli/Cargo.toml | 1 + crates/relicario-cli/src/main.rs | 158 ++++++++++++++++++++++++++++++- 3 files changed, 155 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index db37ed1..2d9d24e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1594,6 +1594,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "tar", "tempfile", "url", "zeroize", diff --git a/crates/relicario-cli/Cargo.toml b/crates/relicario-cli/Cargo.toml index 933cd50..e56ab64 100644 --- a/crates/relicario-cli/Cargo.toml +++ b/crates/relicario-cli/Cargo.toml @@ -24,6 +24,7 @@ serde_json = "1" zeroize = "1" url = "2" data-encoding = "2" +tar = { version = "0.4", default-features = false } [dev-dependencies] assert_cmd = "2" diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 9323a5b..f0700e1 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1289,12 +1289,160 @@ fn cmd_backup(action: BackupAction) -> Result<()> { } fn cmd_backup_export( - _out: PathBuf, - _include_image: bool, - _image: Option, - _no_history: bool, + out: PathBuf, + include_image: bool, + image: Option, + no_history: bool, ) -> Result<()> { - anyhow::bail!("cmd_backup_export not yet implemented") + use std::fs; + use relicario_core::{backup, validate_passphrase_strength}; + use zeroize::Zeroizing; + + let root = crate::helpers::vault_dir()?; + + // Backup passphrase — prompt twice, gate on zxcvbn (audit H3). + let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + let confirm = if std::env::var_os("RELICARIO_TEST_BACKUP_PASSPHRASE").is_some() { + passphrase.clone() + } else { + Zeroizing::new(rpassword::prompt_password("Confirm passphrase: ")?) + }; + if passphrase.as_str() != confirm.as_str() { + anyhow::bail!("passphrases do not match"); + } + if let Err(e) = validate_passphrase_strength(&passphrase) { + anyhow::bail!("backup {}. Choose a longer or more entropic phrase.", e); + } + + // Read everything from disk that the envelope needs. + let salt = fs::read(root.join(".relicario").join("salt")) + .with_context(|| "failed to read .relicario/salt")?; + let params_json = fs::read_to_string(root.join(".relicario").join("params.json")) + .with_context(|| "failed to read .relicario/params.json")?; + let devices_json = fs::read_to_string(root.join(".relicario").join("devices.json")) + .with_context(|| "failed to read .relicario/devices.json")?; + let manifest_enc = fs::read(root.join("manifest.enc")) + .with_context(|| "failed to read manifest.enc")?; + let settings_enc = fs::read(root.join("settings.enc")) + .with_context(|| "failed to read settings.enc")?; + + // Items. + let mut item_files = Vec::new(); + let items_dir = root.join("items"); + if items_dir.is_dir() { + for entry in fs::read_dir(&items_dir)? { + let p = entry?.path(); + if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } + let id = p.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad item filename: {}", p.display()))? + .to_string(); + let bytes = fs::read(&p)?; + item_files.push((id, bytes)); + } + } + + // Attachments. Layout: attachments//.enc + let mut attach_files = Vec::new(); + let attach_dir = root.join("attachments"); + if attach_dir.is_dir() { + for entry in fs::read_dir(&attach_dir)? { + let item_dir = entry?.path(); + if !item_dir.is_dir() { continue; } + let item_id = item_dir.file_name() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad attachment dir: {}", item_dir.display()))? + .to_string(); + for sub in fs::read_dir(&item_dir)? { + let p = sub?.path(); + if p.extension().and_then(|s| s.to_str()) != Some("enc") { continue; } + let aid = p.file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow::anyhow!("bad attachment filename: {}", p.display()))? + .to_string(); + let bytes = fs::read(&p)?; + attach_files.push((item_id.clone(), aid, bytes)); + } + } + } + + // Optional reference image. + let image_bytes = if include_image { + let path = match image { + Some(p) => p, + None => crate::session::get_image_path()?, + }; + Some(fs::read(&path) + .with_context(|| format!("failed to read reference image {}", path.display()))?) + } else { + None + }; + + // Optional .git/ tar. + let git_archive = if no_history { None } else { Some(tar_directory(&root.join(".git"))?) }; + + let items_refs: Vec = item_files.iter() + .map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes }) + .collect(); + let attach_refs: Vec = attach_files.iter() + .map(|(iid, aid, bytes)| backup::BackupAttachment { + item_id: iid.clone(), + attachment_id: aid.clone(), + ciphertext: bytes, + }) + .collect(); + + let input = backup::BackupInput { + salt: &salt, + params_json: ¶ms_json, + devices_json: &devices_json, + manifest_enc: &manifest_enc, + settings_enc: &settings_enc, + items: items_refs, + attachments: attach_refs, + reference_jpg: image_bytes.as_deref(), + git_archive: git_archive.as_deref(), + }; + + let bytes = backup::pack_backup(input, &passphrase)?; + + // atomic_write via the existing pattern: write `.tmp`, rename. + let tmp = { + let mut t = out.as_os_str().to_owned(); + t.push(".tmp"); + PathBuf::from(t) + }; + fs::write(&tmp, &bytes) + .with_context(|| format!("failed to write {}", tmp.display()))?; + fs::rename(&tmp, &out) + .with_context(|| format!("failed to rename {}", out.display()))?; + + // Marker file for `cmd_status`. Format: ISO-8601 UTC line. + let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); + fs::write(root.join(".relicario").join("last_backup"), format!("{now_iso}\n"))?; + + let mib = (bytes.len() as f64) / (1024.0 * 1024.0); + eprintln!( + "Wrote {} ({:.2} MiB). Delete after restore is verified.", + out.display(), mib + ); + Ok(()) +} + +/// Tar a directory into an in-memory `Vec`. Used for `.git/` bundling. +fn tar_directory(dir: &std::path::Path) -> Result> { + let mut buf = Vec::new(); + { + let mut builder = tar::Builder::new(&mut buf); + builder.append_dir_all(".", dir) + .with_context(|| format!("failed to tar {}", dir.display()))?; + builder.finish()?; + } + Ok(buf) } fn cmd_backup_restore(_input: PathBuf, _target: PathBuf) -> Result<()> { From 734325a31f99a45a74d95220d07c7329ec0cee6a Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 19:25:45 -0400 Subject: [PATCH 09/23] =?UTF-8?q?feat(cli):=20cmd=5Fbackup=5Frestore=20?= =?UTF-8?q?=E2=80=94=20unpack=20.relbak=20into=20target=20dir?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refuses non-empty target, prompts for backup passphrase, writes the full vault layout, untars .git/ when bundled or git-inits a fresh 'restore from backup ' commit otherwise. Also tightens error context on tar_directory's builder.finish(). --- crates/relicario-cli/src/main.rs | 96 +++++++++++++++++++++++++++++++- 1 file changed, 93 insertions(+), 3 deletions(-) diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index f0700e1..4b9db4c 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1440,13 +1440,103 @@ fn tar_directory(dir: &std::path::Path) -> Result> { let mut builder = tar::Builder::new(&mut buf); builder.append_dir_all(".", dir) .with_context(|| format!("failed to tar {}", dir.display()))?; - builder.finish()?; + builder.finish().with_context(|| "failed to finalize git tar")?; } Ok(buf) } -fn cmd_backup_restore(_input: PathBuf, _target: PathBuf) -> Result<()> { - anyhow::bail!("cmd_backup_restore not yet implemented") +fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> { + use std::fs; + use relicario_core::backup; + use zeroize::Zeroizing; + + let target = if target.is_absolute() { + target + } else { + std::env::current_dir()?.join(&target) + }; + + if target.join(".relicario").exists() { + anyhow::bail!( + "target dir already contains a relicario vault; restore refuses to overwrite — use an empty directory: {}", + target.display() + ); + } + fs::create_dir_all(&target) + .with_context(|| format!("failed to create target {}", target.display()))?; + + // Read input file. + let bytes = fs::read(&input) + .with_context(|| format!("failed to read backup file {}", input.display()))?; + + // Backup passphrase prompt. + let passphrase = if let Ok(p) = std::env::var("RELICARIO_TEST_BACKUP_PASSPHRASE") { + Zeroizing::new(p) + } else { + Zeroizing::new(rpassword::prompt_password("Backup passphrase: ")?) + }; + + let unpacked = backup::unpack_backup(&bytes, &passphrase) + .map_err(|e| match e { + relicario_core::RelicarioError::Decrypt => + anyhow::anyhow!("wrong backup passphrase, or the file is corrupt"), + other => anyhow::anyhow!(other), + })?; + + // Write vault layout. + let relicario_dir = target.join(".relicario"); + fs::create_dir_all(&relicario_dir)?; + fs::create_dir_all(target.join("items"))?; + fs::create_dir_all(target.join("attachments"))?; + + fs::write(relicario_dir.join("salt"), unpacked.salt)?; + fs::write(relicario_dir.join("params.json"), &unpacked.params_json)?; + fs::write(relicario_dir.join("devices.json"), &unpacked.devices_json)?; + fs::write(target.join("manifest.enc"), &unpacked.manifest_enc)?; + fs::write(target.join("settings.enc"), &unpacked.settings_enc)?; + + for item in &unpacked.items { + fs::write(target.join("items").join(format!("{}.enc", item.id)), &item.ciphertext)?; + } + for a in &unpacked.attachments { + let dir = target.join("attachments").join(&a.item_id); + fs::create_dir_all(&dir)?; + fs::write(dir.join(format!("{}.enc", a.attachment_id)), &a.ciphertext)?; + } + + // Reference image (if present). + if let Some(jpg) = &unpacked.reference_jpg { + let path = target.join("reference.jpg"); + fs::write(&path, jpg) + .with_context(|| format!("failed to write reference image {}", path.display()))?; + } + + // .git/ history. + if let Some(tar_bytes) = &unpacked.git_archive { + let mut archive = tar::Archive::new(tar_bytes.as_slice()); + archive.unpack(target.join(".git")) + .with_context(|| "failed to untar .git/")?; + } else { + // No history bundled — start a fresh git repo. + let status = crate::helpers::git_command(&target, &["init"]).status()?; + if !status.success() { anyhow::bail!("git init failed"); } + + // .gitignore — exclude reference image if present. + if target.join("reference.jpg").exists() { + fs::write(target.join(".gitignore"), "reference.jpg\n")?; + } + + let _ = crate::helpers::git_command(&target, &["add", "."]).status()?; + let now_iso = crate::helpers::iso8601(relicario_core::now_unix()); + let msg = format!("restore from backup {now_iso}"); + let _ = crate::helpers::git_command(&target, &["commit", "-m", &msg]).status()?; + } + + eprintln!( + "Restored vault to {}. Unlock with your passphrase + reference image.", + target.display() + ); + Ok(()) } fn cmd_trash_empty() -> Result<()> { From bd7bef7ce43679048bbedab0ce27f1787664e1f9 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 19:32:58 -0400 Subject: [PATCH 10/23] test(cli): export/restore round-trip + error paths --- crates/relicario-cli/tests/backup.rs | 142 +++++++++++++++++++++++ crates/relicario-cli/tests/common/mod.rs | 13 +++ 2 files changed, 155 insertions(+) create mode 100644 crates/relicario-cli/tests/backup.rs diff --git a/crates/relicario-cli/tests/backup.rs b/crates/relicario-cli/tests/backup.rs new file mode 100644 index 0000000..2f5d028 --- /dev/null +++ b/crates/relicario-cli/tests/backup.rs @@ -0,0 +1,142 @@ +mod common; +use common::TestVault; +use std::process::Command; +use assert_cmd::cargo::CommandCargoExt; + +const BACKUP_PASS: &str = "strong-backup-pass-test-2026"; + +#[test] +fn export_then_restore_round_trip() { + let v = TestVault::init(); + + v.run(&["add", "login", "--title", "GitHub", "--username", "alice", "--password", "p"]); + v.run(&["add", "login", "--title", "Email", "--username", "bob", "--password", "q"]); + + let backup_path = v.path().join("vault.relbak"); + let out = v.run_with_backup_pass( + &["backup", "export", backup_path.to_str().unwrap()], + BACKUP_PASS, + ); + assert!(out.status.success(), "export failed: {:?}", String::from_utf8_lossy(&out.stderr)); + assert!(backup_path.exists()); + assert!(v.path().join(".relicario/last_backup").exists()); + + // Restore into a fresh dir. + let restore_dir = tempfile::TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS) + .args(["backup", "restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(out.status.success(), "restore failed: {:?}", String::from_utf8_lossy(&out.stderr)); + + // Vault should be unlockable in the restore dir using the same passphrase + image. + // Since the original vault didn't include the image, we copy it in manually + // (the standard restore-without-image flow expects the user to keep their + // reference image separately). + std::fs::copy(&v.reference_image, restore_dir.path().join("reference.jpg")).unwrap(); + + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_PASSPHRASE", &v.passphrase) + .env("RELICARIO_IMAGE", restore_dir.path().join("reference.jpg")) + .args(["list"]) + .output() + .unwrap(); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("GitHub")); + assert!(stdout.contains("Email")); +} + +#[test] +fn restore_refuses_non_empty_target() { + let v = TestVault::init(); + let backup_path = v.path().join("vault.relbak"); + v.run_with_backup_pass(&["backup", "export", backup_path.to_str().unwrap()], BACKUP_PASS); + + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(v.path()) // already has a .relicario/ + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS) + .args(["backup", "restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(!out.status.success()); + let err = String::from_utf8(out.stderr).unwrap(); + assert!(err.contains("already contains a relicario vault"), "stderr: {err}"); +} + +#[test] +fn export_with_include_image_round_trips_the_image() { + let v = TestVault::init(); + let backup_path = v.path().join("vault.relbak"); + v.run_with_backup_pass( + &["backup", "export", backup_path.to_str().unwrap(), "--include-image"], + BACKUP_PASS, + ); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS) + .args(["backup", "restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr)); + assert!(restore_dir.path().join("reference.jpg").exists(), + "image should be restored when --include-image was used"); +} + +#[test] +fn export_with_no_history_skips_git_dir() { + let v = TestVault::init(); + let backup_path = v.path().join("vault.relbak"); + v.run_with_backup_pass( + &["backup", "export", backup_path.to_str().unwrap(), "--no-history"], + BACKUP_PASS, + ); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", BACKUP_PASS) + .args(["backup", "restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(out.status.success(), "{:?}", String::from_utf8_lossy(&out.stderr)); + + // .git/ should exist but contain only the "restore from backup ..." commit. + assert!(restore_dir.path().join(".git").is_dir()); + let out = std::process::Command::new("git") + .current_dir(restore_dir.path()) + .args(["log", "--oneline"]) + .output() + .unwrap(); + let log = String::from_utf8(out.stdout).unwrap(); + assert_eq!(log.lines().count(), 1, "expected one commit, got: {log}"); + assert!(log.contains("restore from backup")); +} + +#[test] +fn wrong_backup_passphrase_fails() { + let v = TestVault::init(); + let backup_path = v.path().join("vault.relbak"); + v.run_with_backup_pass(&["backup", "export", backup_path.to_str().unwrap()], BACKUP_PASS); + + let restore_dir = tempfile::TempDir::new().unwrap(); + let out = Command::cargo_bin("relicario") + .unwrap() + .current_dir(restore_dir.path()) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", "definitely-wrong") + .args(["backup", "restore", backup_path.to_str().unwrap(), "."]) + .output() + .unwrap(); + assert!(!out.status.success()); + let err = String::from_utf8(out.stderr).unwrap(); + assert!(err.contains("wrong backup passphrase"), "stderr: {err}"); +} diff --git a/crates/relicario-cli/tests/common/mod.rs b/crates/relicario-cli/tests/common/mod.rs index b77ce7e..1e5ed10 100644 --- a/crates/relicario-cli/tests/common/mod.rs +++ b/crates/relicario-cli/tests/common/mod.rs @@ -78,6 +78,19 @@ impl TestVault { cmd.output().unwrap() } + pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output { + let mut cmd = Command::cargo_bin("relicario").unwrap(); + cmd.current_dir(self.dir.path()) + .env("RELICARIO_IMAGE", &self.reference_image) + .env("RELICARIO_TEST_PASSPHRASE", &self.passphrase) + .env("RELICARIO_TEST_BACKUP_PASSPHRASE", backup_pass) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + cmd.output().unwrap() + } + pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output { let mut cmd = Command::cargo_bin("relicario").unwrap(); cmd.current_dir(self.dir.path()) From a32f13b63ae2ffbb9f364bed8910155254b1b6e1 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 19:42:10 -0400 Subject: [PATCH 11/23] feat(cli): status shows last export age Reads .relicario/last_backup (written by cmd_backup_export). Format: 'never' for fresh vaults, '4 days ago' otherwise. Closes the 'is my backup stale?' question without leaving the terminal. --- crates/relicario-cli/src/helpers.rs | 20 ++++++++++++++++++++ crates/relicario-cli/src/main.rs | 21 +++++++++++++++++++++ crates/relicario-cli/tests/settings.rs | 26 ++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index 28d89f5..a864f95 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -63,6 +63,26 @@ pub fn iso8601(unix_seconds: i64) -> String { .unwrap_or_else(|| format!("invalid-timestamp:{unix_seconds}")) } +/// Format a duration (in seconds) as a coarse human-readable string: +/// "just now" / "5 minutes ago" / "4 days ago" / "3 months ago". +pub fn humanize_age(seconds: i64) -> String { + if seconds < 60 { return "just now".to_string(); } + if seconds < 3600 { return format!("{} minute{} ago", seconds / 60, plural(seconds / 60)); } + if seconds < 86_400 { return format!("{} hour{} ago", seconds / 3600, plural(seconds / 3600)); } + if seconds < 86_400 * 30 { + let d = seconds / 86_400; + return format!("{d} day{} ago", plural(d)); + } + if seconds < 86_400 * 365 { + let m = seconds / (86_400 * 30); + return format!("{m} month{} ago", plural(m)); + } + let y = seconds / (86_400 * 365); + format!("{y} year{} ago", plural(y)) +} + +fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } } + #[cfg(test)] mod tests { use super::*; diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 4b9db4c..54009ed 100644 --- a/crates/relicario-cli/src/main.rs +++ b/crates/relicario-cli/src/main.rs @@ -1918,11 +1918,32 @@ fn cmd_status() -> Result<()> { .map(|s| s.trim().to_string()) .unwrap_or_else(|| "(no commits)".into()); + // Last backup age (read from marker written by cmd_backup_export). + let last_backup_path = vault.root().join(".relicario").join("last_backup"); + let last_backup_str = if last_backup_path.exists() { + let line = std::fs::read_to_string(&last_backup_path) + .unwrap_or_default() + .trim() + .to_string(); + // Parse the ISO-8601 we wrote in cmd_backup_export. + match chrono::DateTime::parse_from_rfc3339(&line) { + Ok(then) => { + let now = relicario_core::now_unix(); + let age = now - then.timestamp(); + crate::helpers::humanize_age(age.max(0)) + } + Err(_) => "unknown".to_string(), + } + } else { + "never".to_string() + }; + println!("Vault: {}", root.display()); println!("Items: {total_items} total ({active_items} active, {trashed_items} trashed)"); println!("Attachments: {attachment_count} ({attachment_bytes} bytes)"); println!("Devices: {device_count}"); println!("Last commit: {last_commit}"); + println!("Last export: {last_backup_str}"); Ok(()) } fn cmd_device(action: DeviceAction) -> Result<()> { diff --git a/crates/relicario-cli/tests/settings.rs b/crates/relicario-cli/tests/settings.rs index d01ee68..506ccec 100644 --- a/crates/relicario-cli/tests/settings.rs +++ b/crates/relicario-cli/tests/settings.rs @@ -109,6 +109,32 @@ fn status_reports_item_attachment_and_device_counts() { ); } +#[test] +fn status_shows_last_backup_line() { + let v = TestVault::init(); + let out = v.run(&["status"]); + assert!(out.status.success()); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("last export:") || stdout.to_lowercase().contains("last export:"), + "missing last export line: {stdout}"); + assert!(stdout.contains("never"), "fresh vault should report 'never': {stdout}"); +} + +#[test] +fn status_shows_recent_backup_after_export() { + let v = TestVault::init(); + let backup_path = v.path().join("v.relbak"); + v.run_with_backup_pass( + &["backup", "export", backup_path.to_str().unwrap()], + "test-backup-pass-2026", + ); + let out = v.run(&["status"]); + let stdout = String::from_utf8(out.stdout).unwrap(); + assert!(stdout.contains("last export:") || stdout.to_lowercase().contains("last export:"), + "{stdout}"); + assert!(!stdout.contains("never"), "should NOT say 'never' after export: {stdout}"); +} + #[test] fn generate_works_outside_vault() { use assert_cmd::cargo::CommandCargoExt; From 536ef2464b2ab68b34595e12c2fe25d16d141842 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 19:46:03 -0400 Subject: [PATCH 12/23] test(cli): tighten last-export label assertions to exact match Drop the dead `stdout.contains("last export:")` + `.to_lowercase()` fallback in status_shows_last_backup_line and status_shows_recent_backup_after_export; assert `stdout.contains("Last export:")` verbatim instead. Co-Authored-By: Claude Sonnet 4.6 --- crates/relicario-cli/tests/settings.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/crates/relicario-cli/tests/settings.rs b/crates/relicario-cli/tests/settings.rs index 506ccec..b79c7b1 100644 --- a/crates/relicario-cli/tests/settings.rs +++ b/crates/relicario-cli/tests/settings.rs @@ -115,8 +115,7 @@ fn status_shows_last_backup_line() { let out = v.run(&["status"]); assert!(out.status.success()); let stdout = String::from_utf8(out.stdout).unwrap(); - assert!(stdout.contains("last export:") || stdout.to_lowercase().contains("last export:"), - "missing last export line: {stdout}"); + assert!(stdout.contains("Last export:"), "missing last export line: {stdout}"); assert!(stdout.contains("never"), "fresh vault should report 'never': {stdout}"); } @@ -130,8 +129,7 @@ fn status_shows_recent_backup_after_export() { ); let out = v.run(&["status"]); let stdout = String::from_utf8(out.stdout).unwrap(); - assert!(stdout.contains("last export:") || stdout.to_lowercase().contains("last export:"), - "{stdout}"); + assert!(stdout.contains("Last export:"), "{stdout}"); assert!(!stdout.contains("never"), "should NOT say 'never' after export: {stdout}"); } From 6d96ca828842e88b01e4c8d2d4883163251ca395 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 19:48:50 -0400 Subject: [PATCH 13/23] test(cli): humanize_age bucket boundaries + plural transitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Locks the singular vs plural transition (1 minute ago vs 2 minutes ago) and each bucket boundary (59→60s minutes, 3599→3600s hours, 86400→86400×2 days, etc.) so future tweaks can't silently regress the user-facing labels. --- crates/relicario-cli/src/helpers.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/crates/relicario-cli/src/helpers.rs b/crates/relicario-cli/src/helpers.rs index a864f95..2e7b43b 100644 --- a/crates/relicario-cli/src/helpers.rs +++ b/crates/relicario-cli/src/helpers.rs @@ -118,4 +118,21 @@ mod tests { // 2026-04-19T00:00:00Z = 1776556800 assert_eq!(iso8601(1_776_556_800), "2026-04-19T00:00:00Z"); } + + #[test] + fn humanize_age_buckets() { + assert_eq!(humanize_age(0), "just now"); + assert_eq!(humanize_age(59), "just now"); + assert_eq!(humanize_age(60), "1 minute ago"); + assert_eq!(humanize_age(120), "2 minutes ago"); + assert_eq!(humanize_age(3_599), "59 minutes ago"); + assert_eq!(humanize_age(3_600), "1 hour ago"); + assert_eq!(humanize_age(7_200), "2 hours ago"); + assert_eq!(humanize_age(86_400), "1 day ago"); + assert_eq!(humanize_age(86_400 * 2), "2 days ago"); + assert_eq!(humanize_age(86_400 * 30), "1 month ago"); + assert_eq!(humanize_age(86_400 * 60), "2 months ago"); + assert_eq!(humanize_age(86_400 * 365), "1 year ago"); + assert_eq!(humanize_age(86_400 * 365 * 3), "3 years ago"); + } } From 7407fe512f7086165541a4baf3cc9243371d8435 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 19:52:36 -0400 Subject: [PATCH 14/23] feat(wasm): pack_backup_json / unpack_backup_json JSON bridge for the SW. Binary fields are base64 in the JSON wrapper; core gets borrowed byte slices. --- crates/relicario-wasm/src/lib.rs | 127 +++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) diff --git a/crates/relicario-wasm/src/lib.rs b/crates/relicario-wasm/src/lib.rs index 872beb4..4657e58 100644 --- a/crates/relicario-wasm/src/lib.rs +++ b/crates/relicario-wasm/src/lib.rs @@ -305,6 +305,133 @@ pub fn totp_compute( Ok(TotpCode { code, expires_at }) } +// ── Backup container bridge ───────────────────────────────────────────────── + +use relicario_core::backup::{ + pack_backup as core_pack_backup, + unpack_backup as core_unpack_backup, + BackupInput, BackupItem, BackupAttachment, +}; + +/// Pack a vault into a `.relbak` byte vector. +/// +/// `input_json` shape: +/// ```json +/// { +/// "salt": "", +/// "params_json": "...", +/// "devices_json": "...", +/// "manifest_enc": "", +/// "settings_enc": "", +/// "items": [{"id": "", "ciphertext": ""}, ...], +/// "attachments": [{"item_id": "", "attachment_id": "", "ciphertext": ""}, ...], +/// "reference_jpg": "" | null, +/// "git_archive": "" | null +/// } +/// ``` +#[wasm_bindgen] +pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result, JsError> { + #[derive(serde::Deserialize)] + struct InJson { + salt: String, + params_json: String, + devices_json: String, + manifest_enc: String, + settings_enc: String, + items: Vec, + attachments: Vec, + reference_jpg: Option, + git_archive: Option, + } + #[derive(serde::Deserialize)] + struct InItem { id: String, ciphertext: String } + #[derive(serde::Deserialize)] + struct InAttachment { item_id: String, attachment_id: String, ciphertext: String } + + let parsed: InJson = serde_json::from_str(input_json) + .map_err(|e| JsError::new(&format!("backup input: {e}")))?; + + let b64 = base64::engine::general_purpose::STANDARD; + let salt = b64.decode(&parsed.salt).map_err(|e| JsError::new(&e.to_string()))?; + let manifest = b64.decode(&parsed.manifest_enc).map_err(|e| JsError::new(&e.to_string()))?; + let settings = b64.decode(&parsed.settings_enc).map_err(|e| JsError::new(&e.to_string()))?; + let items_bytes: Vec<(String, Vec)> = parsed.items.iter() + .map(|i| { + let ct = b64.decode(&i.ciphertext).map_err(|e| JsError::new(&e.to_string()))?; + Ok((i.id.clone(), ct)) + }) + .collect::, JsError>>()?; + let attach_bytes: Vec<(String, String, Vec)> = parsed.attachments.iter() + .map(|a| { + let ct = b64.decode(&a.ciphertext).map_err(|e| JsError::new(&e.to_string()))?; + Ok((a.item_id.clone(), a.attachment_id.clone(), ct)) + }) + .collect::, JsError>>()?; + + let ref_bytes = parsed.reference_jpg.as_deref() + .map(|s| b64.decode(s)) + .transpose() + .map_err(|e| JsError::new(&e.to_string()))?; + let git_bytes = parsed.git_archive.as_deref() + .map(|s| b64.decode(s)) + .transpose() + .map_err(|e| JsError::new(&e.to_string()))?; + + let items_refs: Vec = items_bytes.iter() + .map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct }) + .collect(); + let attach_refs: Vec = attach_bytes.iter() + .map(|(iid, aid, ct)| BackupAttachment { + item_id: iid.clone(), + attachment_id: aid.clone(), + ciphertext: ct, + }) + .collect(); + + let input = BackupInput { + salt: &salt, + params_json: &parsed.params_json, + devices_json: &parsed.devices_json, + manifest_enc: &manifest, + settings_enc: &settings, + items: items_refs, + attachments: attach_refs, + reference_jpg: ref_bytes.as_deref(), + git_archive: git_bytes.as_deref(), + }; + core_pack_backup(input, passphrase).map_err(|e| JsError::new(&e.to_string())) +} + +/// Unpack `.relbak` bytes; returns the JSON shape that mirrors `BackupOutput`, +/// with binary fields base64-encoded. +#[wasm_bindgen] +pub fn unpack_backup_json(bytes: &[u8], passphrase: &str) -> Result { + let out = core_unpack_backup(bytes, passphrase) + .map_err(|e| JsError::new(&e.to_string()))?; + + let b64 = base64::engine::general_purpose::STANDARD; + let json = serde_json::json!({ + "salt": b64.encode(out.salt), + "params_json": out.params_json, + "devices_json": out.devices_json, + "manifest_enc": b64.encode(&out.manifest_enc), + "settings_enc": b64.encode(&out.settings_enc), + "items": out.items.iter().map(|i| serde_json::json!({ + "id": i.id, + "ciphertext": b64.encode(&i.ciphertext), + })).collect::>(), + "attachments": out.attachments.iter().map(|a| serde_json::json!({ + "item_id": a.item_id, + "attachment_id": a.attachment_id, + "ciphertext": b64.encode(&a.ciphertext), + })).collect::>(), + "reference_jpg": out.reference_jpg.as_ref().map(|b| b64.encode(b)), + "git_archive": out.git_archive.as_ref().map(|b| b64.encode(b)), + "created_at": out.created_at, + }); + Ok(json.to_string()) +} + #[cfg(test)] mod session_tests { use super::*; From f32c14f9396c7b32b3478eec03227cf9b1428c13 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 20:12:07 -0400 Subject: [PATCH 15/23] feat(ext/sw): export_backup / restore_backup message types --- .../src/service-worker/router/popup-only.ts | 6 ++++++ extension/src/shared/messages.ts | 20 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 16f45b3..1f1e63a 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -382,6 +382,12 @@ export async function handle( await chrome.storage.local.set({ session_timeout: msg.config }); return { ok: true }; } + + case 'export_backup': + return { ok: false, error: 'export_backup not yet implemented' }; + + case 'restore_backup': + return { ok: false, error: 'restore_backup not yet implemented' }; } } diff --git a/extension/src/shared/messages.ts b/extension/src/shared/messages.ts index a669523..1788701 100644 --- a/extension/src/shared/messages.ts +++ b/extension/src/shared/messages.ts @@ -48,7 +48,14 @@ export type PopupMessage = | { type: 'purge_all_trash' } | { type: 'get_field_history'; id: ItemId } | { type: 'get_session_config' } - | { type: 'update_session_config'; config: SessionTimeoutConfig }; + | { type: 'update_session_config'; config: SessionTimeoutConfig } + | { type: 'export_backup'; passphrase: string; includeImage: boolean } + | { + type: 'restore_backup'; + bytes: ArrayBuffer; + passphrase: string; + newRemote: { hostType: 'gitea' | 'github'; hostUrl: string; repoPath: string; apiToken: string }; + }; // --- Messages a content script may send --- @@ -153,8 +160,19 @@ export const POPUP_ONLY_TYPES: ReadonlySet = new Set([ 'list_trashed', 'restore_item', 'purge_item', 'purge_all_trash', 'get_field_history', 'get_session_config', 'update_session_config', + 'export_backup', 'restore_backup', ] as PopupMessage['type'][]); +export interface ExportBackupResponse extends Extract { + data: { bytes: ArrayBuffer }; +} + +export interface RestoreBackupResponse extends Extract { + data: { + summary: { itemCount: number; attachmentCount: number; hasImage: boolean }; + }; +} + export const CONTENT_CALLABLE_TYPES: ReadonlySet = new Set([ 'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site', 'capture_save_login', From 5d9ea37b7f0d98a1f87defbe295a109b6b801d09 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 20:16:52 -0400 Subject: [PATCH 16/23] feat(ext/sw): export_backup handler Reads vault state via GitHost, calls pack_backup_json in WASM, returns the .relbak bytes back to the panel for chrome.downloads.download. Reference image inclusion comes from chrome.storage.local.imageBase64. Git history is never bundled from the extension (CLI is the source of full backups). --- .../src/service-worker/router/popup-only.ts | 36 ++++++++- extension/src/service-worker/vault.ts | 80 +++++++++++++++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index 1f1e63a..ad6958a 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -383,8 +383,40 @@ export async function handle( return { ok: true }; } - case 'export_backup': - return { ok: false, error: 'export_backup not yet implemented' }; + case 'export_backup': { + if (!state.gitHost || !state.manifest) return { ok: false, error: 'vault_locked' }; + + try { + const blob = await vault.fetchVaultStateForBackup(state.gitHost, state.manifest); + + let reference_jpg: string | null = null; + if (msg.includeImage) { + const stored = await chrome.storage.local.get('imageBase64'); + const b64 = stored.imageBase64 as string | undefined; + if (!b64) return { ok: false, error: 'no reference image stored locally' }; + reference_jpg = b64; + } + + const inputJson = JSON.stringify({ + salt: blob.salt_b64, + params_json: blob.params_json, + devices_json: blob.devices_json, + manifest_enc: blob.manifest_enc_b64, + settings_enc: blob.settings_enc_b64, + items: blob.items.map(i => ({ id: i.id, ciphertext: i.ciphertext_b64 })), + attachments: blob.attachments.map(a => ({ + item_id: a.item_id, attachment_id: a.attachment_id, ciphertext: a.ciphertext_b64 + })), + reference_jpg, + git_archive: null, // Extension never bundles git history. + }); + + const bytes: Uint8Array = state.wasm.pack_backup_json(inputJson, msg.passphrase); + return { ok: true, data: { bytes: bytes.buffer } }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } case 'restore_backup': return { ok: false, error: 'restore_backup not yet implemented' }; diff --git a/extension/src/service-worker/vault.ts b/extension/src/service-worker/vault.ts index 2b8c3b2..a252554 100644 --- a/extension/src/service-worker/vault.ts +++ b/extension/src/service-worker/vault.ts @@ -3,6 +3,7 @@ import type { SessionHandle } from '../../wasm/relicario_wasm'; import type { GitHost } from './git-host'; +import { uint8ArrayToBase64 } from './git-host'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -39,6 +40,85 @@ export async function fetchAndDecryptManifest( return w.manifest_decrypt(handle, ciphertext) as Manifest; } +/** + * Read every byte the .relbak envelope needs from the remote vault repo. + * Returns base64 strings for binary blobs (matching the WASM JSON shape). + * + * Translates the extension's flat `attachments/.bin` layout to the + * canonical `/` envelope-key form by walking the decrypted + * manifest. Attachments referenced by the manifest but missing on-disk + * are skipped with a console warning (the user already lost them; the + * backup just records what's there). + */ +export async function fetchVaultStateForBackup( + gitHost: GitHost, + manifest: Manifest, +): Promise<{ + salt_b64: string; + params_json: string; + devices_json: string; + manifest_enc_b64: string; + settings_enc_b64: string; + items: Array<{ id: string; ciphertext_b64: string }>; + attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }>; +}> { + const meta = await fetchVaultMeta(gitHost); + const devicesBytes = await gitHost.readFile('.relicario/devices.json'); + const devicesText = new TextDecoder().decode(devicesBytes); + const manifestEnc = await gitHost.readFile('manifest.enc'); + const settingsEnc = await gitHost.readFile('settings.enc'); + + // Items: items/.enc, flat directory. + const itemNames = await gitHost.listDir('items'); + const items = await Promise.all(itemNames + .filter((name) => name.endsWith('.enc')) + .map(async (name) => { + const id = name.replace(/\.enc$/, ''); + const ct = await gitHost.readFile(`items/${name}`); + return { id, ciphertext_b64: uint8ArrayToBase64(ct) }; + })); + + // Attachments live at `attachments/.bin`. Map aid -> item_id via the + // manifest's attachment_summaries. + const aidToItem: Record = {}; + for (const [itemId, entry] of Object.entries(manifest.items)) { + for (const summary of entry.attachment_summaries ?? []) { + aidToItem[summary.id] = itemId; + } + } + + let attachments: Array<{ item_id: string; attachment_id: string; ciphertext_b64: string }> = []; + try { + const blobNames = await gitHost.listDir('attachments'); + for (const name of blobNames.filter((n) => n.endsWith('.bin'))) { + const aid = name.replace(/\.bin$/, ''); + const item_id = aidToItem[aid]; + if (!item_id) { + console.warn('[relicario] backup: attachment', aid, 'is orphan (no manifest entry); skipping'); + continue; + } + const ct = await gitHost.getBlob(`attachments/${name}`); + attachments.push({ + item_id, + attachment_id: aid, + ciphertext_b64: uint8ArrayToBase64(ct), + }); + } + } catch { + // attachments/ may not exist yet — fine. + } + + return { + salt_b64: uint8ArrayToBase64(meta.salt), + params_json: meta.paramsJson, + devices_json: devicesText, + manifest_enc_b64: uint8ArrayToBase64(manifestEnc), + settings_enc_b64: uint8ArrayToBase64(settingsEnc), + items, + attachments, + }; +} + export async function encryptAndWriteManifest( git: GitHost, handle: SessionHandle, From 2e825a9d3317bcfbd0cbd0d1d37e0bafa059e8b0 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 21:58:14 -0400 Subject: [PATCH 17/23] feat(ext/sw): restore_backup handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unpacks .relbak via WASM, writes every vault artifact to the user-specified fresh remote via writeFileCreateOnly (refuses to clobber), and updates chrome.storage.local so subsequent unlocks hit the restored vault. The reference image — when bundled — is restored to imageBase64; otherwise the user keeps using their existing reference.jpg. --- .../src/service-worker/router/popup-only.ts | 90 ++++++++++++++++++- 1 file changed, 88 insertions(+), 2 deletions(-) diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index ad6958a..b7ca4a4 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -418,8 +418,94 @@ export async function handle( } } - case 'restore_backup': - return { ok: false, error: 'restore_backup not yet implemented' }; + case 'restore_backup': { + try { + const bytes = new Uint8Array(msg.bytes); + const outJson: string = state.wasm.unpack_backup_json(bytes, msg.passphrase); + const out = JSON.parse(outJson) as { + salt: string; + params_json: string; + devices_json: string; + manifest_enc: string; + settings_enc: string; + items: Array<{ id: string; ciphertext: string }>; + attachments: Array<{ item_id: string; attachment_id: string; ciphertext: string }>; + reference_jpg: string | null; + }; + + // Build a GitHost for the new remote. + const newHost = createGitHost( + msg.newRemote.hostType, + msg.newRemote.hostUrl, + msg.newRemote.repoPath, + msg.newRemote.apiToken, + ); + + // Refuse if the remote already has a vault. + try { + const meta = await vault.fetchVaultMeta(newHost); + if (meta.salt && meta.paramsJson) { + return { ok: false, error: 'remote already contains a relicario vault' }; + } + } catch { + // No vault present — expected for a fresh remote. + } + + // Write the layout via writeFileCreateOnly. Refuses to clobber. + const b64 = (s: string) => Uint8Array.from(atob(s), c => c.charCodeAt(0)); + await newHost.writeFileCreateOnly('.relicario/salt', b64(out.salt), 'restore: salt'); + await newHost.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(out.params_json), 'restore: params.json'); + await newHost.writeFileCreateOnly('.relicario/devices.json', new TextEncoder().encode(out.devices_json), 'restore: devices.json'); + await newHost.writeFileCreateOnly('manifest.enc', b64(out.manifest_enc), 'restore: manifest.enc'); + await newHost.writeFileCreateOnly('settings.enc', b64(out.settings_enc), 'restore: settings.enc'); + + for (const it of out.items) { + await newHost.writeFileCreateOnly( + `items/${it.id}.enc`, b64(it.ciphertext), `restore: item ${it.id}`, + ); + } + // Translate canonical envelope keys (/) back to the + // extension's flat layout (attachments/.bin). The aid is + // already content-addressed and globally unique; the item_id segment + // is recorded only in the manifest's attachment_summaries. + for (const a of out.attachments) { + await newHost.writeFileCreateOnly( + `attachments/${a.attachment_id}.bin`, + b64(a.ciphertext), + `restore: attachment ${a.attachment_id}`, + ); + } + + // Update local config so subsequent unlocks work. + const cfg = { + hostType: msg.newRemote.hostType, + hostUrl: msg.newRemote.hostUrl, + repoPath: msg.newRemote.repoPath, + apiToken: msg.newRemote.apiToken, + }; + await chrome.storage.local.set({ vaultConfig: cfg }); + if (out.reference_jpg) { + await chrome.storage.local.set({ imageBase64: out.reference_jpg }); + } + + // Make sure the SW's gitHost cache picks up the new config. + state.gitHost = newHost; + state.manifest = null; // user must unlock to populate + + return { + ok: true, + data: { + summary: { + itemCount: out.items.length, + attachmentCount: out.attachments.length, + hasImage: out.reference_jpg != null, + }, + }, + }; + } catch (e) { + return { ok: false, error: (e as Error).message }; + } + } } } From 9ec5e9b4e1b9a1a520d134c3909e1c14bc612317 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 22:01:56 -0400 Subject: [PATCH 18/23] fix(ext/sw): atomic chrome.storage update in restore_backup Single set({vaultConfig, imageBase64?}) instead of two sequential sets, so a partial-write window can't leave vaultConfig pointing to the new remote while imageBase64 still references the old vault. --- extension/src/service-worker/router/popup-only.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/extension/src/service-worker/router/popup-only.ts b/extension/src/service-worker/router/popup-only.ts index b7ca4a4..83a027d 100644 --- a/extension/src/service-worker/router/popup-only.ts +++ b/extension/src/service-worker/router/popup-only.ts @@ -483,10 +483,11 @@ export async function handle( repoPath: msg.newRemote.repoPath, apiToken: msg.newRemote.apiToken, }; - await chrome.storage.local.set({ vaultConfig: cfg }); + const storageUpdate: Record = { vaultConfig: cfg }; if (out.reference_jpg) { - await chrome.storage.local.set({ imageBase64: out.reference_jpg }); + storageUpdate.imageBase64 = out.reference_jpg; } + await chrome.storage.local.set(storageUpdate); // Make sure the SW's gitHost cache picks up the new config. state.gitHost = newHost; From 06913a0aeda9acb71292cec530cac85dc852f6fb Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 22:03:02 -0400 Subject: [PATCH 19/23] test(ext/sw): router accepts/rejects backup messages per sender --- .../router/__tests__/router.test.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/extension/src/service-worker/router/__tests__/router.test.ts b/extension/src/service-worker/router/__tests__/router.test.ts index 8b0b01d..bc07b33 100644 --- a/extension/src/service-worker/router/__tests__/router.test.ts +++ b/extension/src/service-worker/router/__tests__/router.test.ts @@ -786,3 +786,54 @@ describe('upload_attachment / download_attachment', () => { expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); }); }); + +// --- export_backup / restore_backup sender check --- + +describe('export_backup / restore_backup sender check', () => { + it('accepts vault tab for export_backup', async () => { + const state = makeState(); + const result = await route( + { type: 'export_backup', passphrase: 'p', includeImage: false }, + state, + makeVaultSender(), + ); + // The handler may return ok: false (vault_locked / missing state) but the + // router must NOT reject it as unauthorized_sender. + expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('accepts popup for export_backup', async () => { + const state = makeState(); + const result = await route( + { type: 'export_backup', passphrase: 'p', includeImage: false }, + state, + makePopupSender(), + ); + expect(result).not.toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('rejects setup tab for export_backup', async () => { + const state = makeState(); + const result = await route( + { type: 'export_backup', passphrase: 'p', includeImage: false }, + state, + makeSetupSender(), + ); + expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); + + it('rejects content top frame for restore_backup', async () => { + const state = makeState(); + const result = await route( + { + type: 'restore_backup', + bytes: new ArrayBuffer(8), + passphrase: 'p', + newRemote: { hostType: 'gitea', hostUrl: 'https://x', repoPath: 'a/b', apiToken: 't' }, + }, + state, + makeContentSender('https://example.com'), + ); + expect(result).toEqual({ ok: false, error: 'unauthorized_sender' }); + }); +}); From 419408bbadd034cc9f716f3bfa3cdddc0226da72 Mon Sep 17 00:00:00 2001 From: adlee-was-taken Date: Tue, 28 Apr 2026 22:11:51 -0400 Subject: [PATCH 20/23] feat(ext): vault-tab Backup & Restore panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two cards — Export (passphrase + include-image checkbox → download) and Restore (file picker + passphrase + new-remote form). Deep-linked from settings-vault > 'Backup & restore →'. --- .../src/popup/components/settings-vault.ts | 10 +- .../src/vault/components/backup-panel.ts | 152 ++++++++++++++++++ extension/src/vault/vault.ts | 8 +- 3 files changed, 168 insertions(+), 2 deletions(-) create mode 100644 extension/src/vault/components/backup-panel.ts diff --git a/extension/src/popup/components/settings-vault.ts b/extension/src/popup/components/settings-vault.ts index 17598aa..a5fc0e4 100644 --- a/extension/src/popup/components/settings-vault.ts +++ b/extension/src/popup/components/settings-vault.ts @@ -2,7 +2,7 @@ /// generator defaults (preview + "configure" → opens popover), and /// autofill origin-ack revocation. -import { getState, setState, sendMessage, navigate, escapeHtml } from '../../shared/state'; +import { getState, setState, sendMessage, navigate, escapeHtml, openVaultTab } from '../../shared/state'; import type { VaultSettings, TrashRetention, HistoryRetention, GeneratorRequest, } from '../../shared/types'; @@ -158,6 +158,13 @@ export function renderVaultSettings(app: HTMLElement): void { +
+
backup & restore
+
+ +
+
+