diff --git a/CHANGELOG.md b/CHANGELOG.md index afa1bf0..c3a1137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,35 @@ - **`relicario status`** — vault summary: root path, item count (active / trashed), attachment count + total bytes, registered device count, last commit (`%h %s`). +- **Backup & restore.** New `relicario backup export ` and + `relicario backup restore []` commands. The `.relbak` + format is a single encrypted file: Argon2id-derived key from a + user-chosen backup passphrase (independent of the vault factor), + XChaCha20-Poly1305 ciphertext, zstd-compressed JSON envelope. + Reference image and `.git/` history are opt-in inclusions + (`--include-image`, `--no-history`). +- **Vault-tab Backup & Restore panel.** Export downloads the + `.relbak` via `chrome.downloads`. Restore takes a file + backup + passphrase + new-remote config and writes the vault into a fresh + empty repo (refuses to clobber existing). Git history is never + bundled from the extension — CLI is the source of full backups. +- **`relicario status` shows last export age.** New `Last export: + ` line reading `.relicario/last_backup` (a marker + file `cmd_backup_export` writes on success). Reads "never" for + fresh vaults, "4 days ago" otherwise. + +### Known limitations + +- **Mid-restore failure leaves the target remote in a half-written + state.** `cmd_backup_restore` and the vault-tab Restore panel both + write artifacts sequentially via `writeFileCreateOnly`. If the + process is interrupted partway, a retry against the same remote + refuses to clobber. Workaround: delete the partial repo and retry. +- **Cross-tool backup compatibility.** CLI-exported backups stored + attachments at `/.enc`; extension stores at flat + `.bin`. The `.relbak` envelope canonicalizes to `/` + keys and each tool translates at the boundary. Round-trip works in + both directions. ### Internal diff --git a/Cargo.lock b/Cargo.lock index a0ddfc0..2d9d24e 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" @@ -1529,6 +1594,7 @@ dependencies = [ "rpassword", "serde", "serde_json", + "tar", "tempfile", "url", "zeroize", @@ -1539,6 +1605,7 @@ name = "relicario-core" version = "0.2.0" dependencies = [ "argon2", + "base64", "bip39", "chacha20poly1305", "chrono", @@ -1552,10 +1619,12 @@ dependencies = [ "serde_json", "sha1", "sha2", + "tar", "thiserror 2.0.18", "unicode-normalization", "url", "zeroize", + "zstd", "zxcvbn", ] @@ -1807,6 +1876,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 +2789,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-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/helpers.rs b/crates/relicario-cli/src/helpers.rs index 28d89f5..2e7b43b 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::*; @@ -98,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"); + } } diff --git a/crates/relicario-cli/src/main.rs b/crates/relicario-cli/src/main.rs index 7526882..54009ed 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,266 @@ 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<()> { + 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().with_context(|| "failed to finalize git tar")?; + } + Ok(buf) +} + +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<()> { use relicario_core::time::now_unix; @@ -1622,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/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()) diff --git a/crates/relicario-cli/tests/settings.rs b/crates/relicario-cli/tests/settings.rs index d01ee68..b79c7b1 100644 --- a/crates/relicario-cli/tests/settings.rs +++ b/crates/relicario-cli/tests/settings.rs @@ -109,6 +109,30 @@ 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:"), "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}"); + assert!(!stdout.contains("never"), "should NOT say 'never' after export: {stdout}"); +} + #[test] fn generate_works_outside_vault() { use assert_cmd::cargo::CommandCargoExt; 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/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/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")); + } } 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..1ca3ec6 --- /dev/null +++ b/crates/relicario-core/tests/backup.rs @@ -0,0 +1,188 @@ +//! Backup container round-trip + error-path coverage. + +use relicario_core::backup::{pack_backup, unpack_backup, BackupInput}; + +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()); +} + +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); +} + +#[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()); +} + +#[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() + ); +} + +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:?}"), + } +} 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::*; 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
+
+ +
+
+