Merge feature/backup-restore: Plan 3A — backup & restore (v0.3.0)

23 commits implementing the .relbak format (XChaCha20-Poly1305 +
Argon2id, zstd-compressed JSON envelope, opt-in image and git
history), the CLI 'relicario backup export/restore' commands, the
WASM bridge, the SW handlers, the vault-tab Backup & Restore panel,
and tests at every layer.

Final test sweep: cargo 0 failed (~150 Rust tests); vitest 205
passed (27 files); tsc clean.
This commit is contained in:
adlee-was-taken
2026-04-29 20:29:16 -04:00
23 changed files with 2054 additions and 5 deletions

View File

@@ -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 <out.relbak>` and
`relicario backup restore <in.relbak> [<dir>]` 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:
<human-readable>` 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 `<item_id>/<aid>.enc`; extension stores at flat
`<aid>.bin`. The `.relbak` envelope canonicalizes to `<item_id>/<aid>`
keys and each tool translates at the boundary. Round-trip works in
both directions.
### Internal

111
Cargo.lock generated
View File

@@ -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"

View File

@@ -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"

View File

@@ -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");
}
}

View File

@@ -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<PathBuf>,
/// 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<PathBuf>,
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/<item_id>/<aid>.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<backup::BackupItem> = item_files.iter()
.map(|(id, bytes)| backup::BackupItem { id: id.clone(), ciphertext: bytes })
.collect();
let attach_refs: Vec<backup::BackupAttachment> = 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: &params_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<u8>`. Used for `.git/` bundling.
fn tar_directory(dir: &std::path::Path) -> Result<Vec<u8>> {
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<()> {

View File

@@ -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}");
}

View File

@@ -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())

View File

@@ -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;

View File

@@ -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]

View File

@@ -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<BackupItem<'a>>,
/// One entry per attachment blob (verbatim ciphertext).
pub attachments: Vec<BackupAttachment<'a>>,
/// 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 `<item_id>/<attachment_id>` 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<u8>,
pub settings_enc: Vec<u8>,
pub items: Vec<UnpackedItem>,
pub attachments: Vec<UnpackedAttachment>,
pub reference_jpg: Option<Vec<u8>>,
pub git_archive: Option<Vec<u8>>,
pub created_at: i64,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackedItem {
pub id: String,
pub ciphertext: Vec<u8>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct UnpackedAttachment {
pub item_id: String,
pub attachment_id: String,
pub ciphertext: Vec<u8>,
}
#[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<String, String>,
/// Map of `<item_id>/<attachment_id>` → base64-encoded ciphertext.
attachments: std::collections::BTreeMap<String, String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
reference_jpg: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
git_archive: Option<String>,
}
/// 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<Vec<u8>> {
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<BackupOutput> {
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<Zeroizing<[u8; 32]>> {
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<Envelope> {
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)),
},
})
}

View File

@@ -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"));
}
}

View File

@@ -77,3 +77,6 @@ pub use vault::{
};
pub mod imgsecret;
pub mod backup;
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};

View File

@@ -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<u8> = (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<u8> = 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:?}"),
}
}

View File

@@ -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": "<base64>",
/// "params_json": "...",
/// "devices_json": "...",
/// "manifest_enc": "<base64>",
/// "settings_enc": "<base64>",
/// "items": [{"id": "<hex>", "ciphertext": "<base64>"}, ...],
/// "attachments": [{"item_id": "<hex>", "attachment_id": "<hex>", "ciphertext": "<base64>"}, ...],
/// "reference_jpg": "<base64>" | null,
/// "git_archive": "<base64>" | null
/// }
/// ```
#[wasm_bindgen]
pub fn pack_backup_json(input_json: &str, passphrase: &str) -> Result<Vec<u8>, JsError> {
#[derive(serde::Deserialize)]
struct InJson {
salt: String,
params_json: String,
devices_json: String,
manifest_enc: String,
settings_enc: String,
items: Vec<InItem>,
attachments: Vec<InAttachment>,
reference_jpg: Option<String>,
git_archive: Option<String>,
}
#[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<u8>)> = 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::<Result<Vec<_>, JsError>>()?;
let attach_bytes: Vec<(String, String, Vec<u8>)> = 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::<Result<Vec<_>, 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<BackupItem> = items_bytes.iter()
.map(|(id, ct)| BackupItem { id: id.clone(), ciphertext: ct })
.collect();
let attach_refs: Vec<BackupAttachment> = 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<String, JsError> {
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::<Vec<_>>(),
"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::<Vec<_>>(),
"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::*;

View File

@@ -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 {
</div>
</div>
<div class="settings-section">
<div class="settings-section__title">backup &amp; restore</div>
<div class="settings-row">
<button class="btn" id="open-backup">Backup &amp; restore →</button>
</div>
</div>
<div class="settings-footer">
<button class="btn" id="discard-btn">discard</button>
<button class="btn btn-primary" id="save-btn" disabled>save changes</button>
@@ -187,6 +194,7 @@ export function renderVaultSettings(app: HTMLElement): void {
function wireHandlers(): void {
document.getElementById('back-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('discard-btn')?.addEventListener('click', () => navigate('list'));
document.getElementById('open-backup')?.addEventListener('click', () => openVaultTab('backup'));
document.getElementById('trash-retention')?.addEventListener('change', (e) => {
if (!pendingSettings) return;

View File

@@ -0,0 +1,153 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Top-level mocks: hoisted before the SUT import.
const fakeNewHost = {
readFile: vi.fn().mockRejectedValue(new Error('not-found')),
writeFile: vi.fn().mockResolvedValue(undefined),
writeFileCreateOnly: vi.fn().mockResolvedValue(undefined),
deleteFile: vi.fn(),
listDir: vi.fn().mockResolvedValue([]),
lastCommit: vi.fn().mockResolvedValue(null),
putBlob: vi.fn(),
getBlob: vi.fn(),
deleteBlob: vi.fn(),
};
vi.mock('../git-host', async () => {
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
return {
...actual,
createGitHost: () => fakeNewHost,
};
});
vi.mock('../vault', async () => {
const actual = await vi.importActual<typeof import('../vault')>('../vault');
return {
...actual,
fetchVaultStateForBackup: vi.fn().mockResolvedValue({
salt_b64: 'AAAA',
params_json: '{}',
devices_json: '[]',
manifest_enc_b64: 'bWZzdA==',
settings_enc_b64: 'c3RuZw==',
items: [{ id: 'aaa1', ciphertext_b64: 'aXRlbS1jdA==' }],
attachments: [],
}),
fetchVaultMeta: vi.fn().mockRejectedValue(new Error('no vault')),
};
});
import { handle, type PopupState } from '../router/popup-only';
import type { Manifest } from '../../shared/types';
const FAKE_SENDER = {
url: 'chrome-extension://x/vault.html',
id: 'x',
frameId: 0,
} as unknown as chrome.runtime.MessageSender;
const EMPTY_MANIFEST: Manifest = { schema_version: 2, items: {} } as Manifest;
function fakeWasm() {
return {
pack_backup_json: vi.fn().mockReturnValue(new Uint8Array([0x52, 0x42, 0x41, 0x4b, 0x01])),
unpack_backup_json: vi.fn().mockReturnValue(JSON.stringify({
salt: btoa(String.fromCharCode(...new Uint8Array(32))),
params_json: '{}',
devices_json: '[]',
manifest_enc: btoa('mfst'),
settings_enc: btoa('stng'),
items: [{ id: 'aaa1', ciphertext: btoa('item-ct') }],
attachments: [{ item_id: 'aaa1', attachment_id: 'bbbb', ciphertext: btoa('att-ct') }],
reference_jpg: null,
})),
};
}
describe('export_backup handler', () => {
beforeEach(() => {
(globalThis as { chrome?: unknown }).chrome = {
storage: {
local: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
},
},
};
});
it('returns ArrayBuffer of pack output on success', async () => {
const state: PopupState = {
manifest: EMPTY_MANIFEST,
gitHost: fakeNewHost as never,
wasm: fakeWasm(),
};
const result = await handle(
{ type: 'export_backup', passphrase: 'p', includeImage: false },
state,
FAKE_SENDER,
);
expect(result.ok).toBe(true);
if (result.ok) {
const data = result.data as { bytes: ArrayBuffer };
expect(data.bytes.byteLength).toBe(5);
}
});
it('rejects when manifest is missing (vault_locked)', async () => {
const state: PopupState = {
manifest: null,
gitHost: fakeNewHost as never,
wasm: fakeWasm(),
};
const result = await handle(
{ type: 'export_backup', passphrase: 'p', includeImage: false },
state,
FAKE_SENDER,
);
expect(result).toEqual({ ok: false, error: 'vault_locked' });
});
});
describe('restore_backup handler', () => {
beforeEach(() => {
fakeNewHost.writeFileCreateOnly.mockClear();
(globalThis as { chrome?: unknown }).chrome = {
storage: {
local: {
get: vi.fn().mockResolvedValue({}),
set: vi.fn().mockResolvedValue(undefined),
},
},
};
});
it('writes vault layout via writeFileCreateOnly', async () => {
const state: PopupState = {
manifest: null,
gitHost: null as never,
wasm: fakeWasm(),
};
const result = await handle(
{
type: 'restore_backup',
bytes: new ArrayBuffer(5),
passphrase: 'p',
newRemote: { hostType: 'gitea', hostUrl: 'https://x', repoPath: 'a/b', apiToken: 't' },
},
state,
FAKE_SENDER,
);
expect(result.ok).toBe(true);
// 5 baseline files (salt, params, devices, manifest, settings) +
// 1 item + 1 attachment = 7 writes.
expect(fakeNewHost.writeFileCreateOnly).toHaveBeenCalledTimes(7);
// Confirm flat-layout attachment path (not <item_id>/<aid>).
const attachCall = fakeNewHost.writeFileCreateOnly.mock.calls.find(
(c: unknown[]) => typeof c[0] === 'string' && (c[0] as string).startsWith('attachments/'),
);
expect(attachCall![0]).toBe('attachments/bbbb.bin');
});
});

View File

@@ -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' });
});
});

View File

@@ -382,6 +382,131 @@ export async function handle(
await chrome.storage.local.set({ session_timeout: msg.config });
return { ok: true };
}
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': {
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 (<item_id>/<aid>) back to the
// extension's flat layout (attachments/<aid>.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,
};
const storageUpdate: Record<string, unknown> = { vaultConfig: cfg };
if (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;
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 };
}
}
}
}

View File

@@ -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/<aid>.bin` layout to the
* canonical `<item_id>/<aid>` 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/<id>.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/<aid>.bin`. Map aid -> item_id via the
// manifest's attachment_summaries.
const aidToItem: Record<string, string> = {};
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,

View File

@@ -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<PopupMessage['type']> = 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<Response, { ok: true }> {
data: { bytes: ArrayBuffer };
}
export interface RestoreBackupResponse extends Extract<Response, { ok: true }> {
data: {
summary: { itemCount: number; attachmentCount: number; hasImage: boolean };
};
}
export const CONTENT_CALLABLE_TYPES: ReadonlySet<ContentMessage['type']> = new Set([
'get_autofill_candidates', 'get_credentials', 'check_credential', 'blacklist_site',
'capture_save_login',

View File

@@ -0,0 +1,99 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
// backup-panel.ts only imports { sendMessage } from '../../shared/state'.
// We provide the full shared/state surface so TypeScript is satisfied.
vi.mock('../../../shared/state', () => ({
sendMessage: vi.fn(),
openVaultTab: vi.fn(),
registerHost: vi.fn(),
getState: vi.fn(),
setState: vi.fn(),
navigate: vi.fn(),
escapeHtml: (s: string) => s,
popOutToTab: vi.fn(),
isInTab: vi.fn(() => false),
}));
import { sendMessage } from '../../../shared/state';
import { renderBackupPanel, teardown } from '../backup-panel';
const mockSendMessage = sendMessage as ReturnType<typeof vi.fn>;
describe('backup panel — export', () => {
let app: HTMLElement;
beforeEach(() => {
mockSendMessage.mockReset();
teardown();
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
});
it('clicking export with no passphrase is a no-op', async () => {
renderBackupPanel(app);
vi.spyOn(window, 'prompt').mockReturnValue(null);
(app.querySelector('#export-btn') as HTMLButtonElement).click();
await new Promise((r) => setTimeout(r, 0));
expect(mockSendMessage).not.toHaveBeenCalled();
});
it('clicking export with a passphrase fires export_backup', async () => {
renderBackupPanel(app);
vi.spyOn(window, 'prompt').mockReturnValue('test-backup-pass');
mockSendMessage.mockResolvedValue({ ok: true, data: { bytes: new ArrayBuffer(123) } });
(app.querySelector('#include-image') as HTMLInputElement).checked = true;
(app.querySelector('#export-btn') as HTMLButtonElement).click();
await new Promise((r) => setTimeout(r, 50));
expect(mockSendMessage).toHaveBeenCalledWith({
type: 'export_backup',
passphrase: 'test-backup-pass',
includeImage: true,
});
});
it('export error surfaces in the status pre', async () => {
renderBackupPanel(app);
vi.spyOn(window, 'prompt').mockReturnValue('p');
mockSendMessage.mockResolvedValue({ ok: false, error: 'no reference image stored locally' });
(app.querySelector('#export-btn') as HTMLButtonElement).click();
await new Promise((r) => setTimeout(r, 50));
const status = app.querySelector('#export-status') as HTMLElement;
expect(status.textContent).toContain('Failed');
expect(status.textContent).toContain('no reference image');
});
});
describe('backup panel — restore', () => {
let app: HTMLElement;
beforeEach(() => {
mockSendMessage.mockReset();
teardown();
document.body.innerHTML = '<div id="app"></div>';
app = document.getElementById('app')!;
});
it('clicking restore without filling new-remote shows an error', async () => {
renderBackupPanel(app);
vi.spyOn(window, 'prompt').mockReturnValue('p');
// Simulate file picked — this reveals the fieldset with the restore button.
const fakeFile = new File([new Uint8Array([0x52, 0x42, 0x41, 0x4b, 0x01])], 'v.relbak');
const input = app.querySelector('#restore-file') as HTMLInputElement;
Object.defineProperty(input, 'files', { value: [fakeFile] });
input.dispatchEvent(new Event('change'));
(app.querySelector('#restore-btn') as HTMLButtonElement).click();
await new Promise((r) => setTimeout(r, 50));
const status = app.querySelector('#restore-status') as HTMLElement;
expect(status.textContent).toContain('fill in');
expect(mockSendMessage).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,152 @@
import { sendMessage } from '../../shared/state';
type ViewMode = 'idle' | 'exporting' | 'restoring';
let mode: ViewMode = 'idle';
export function renderBackupPanel(app: HTMLElement): void {
app.innerHTML = `
<div class="panel">
<h1>Backup &amp; restore</h1>
<section class="card" id="export-card">
<h2>Export</h2>
<p>Pack this vault into a single encrypted <code>.relbak</code> file.
The backup passphrase is independent of your vault passphrase.</p>
<label><input type="checkbox" id="include-image"> Include reference image (single-file recovery)</label>
<p class="muted">Git history is not bundled from the extension. Use the CLI if you want a full audit-log backup.</p>
<button id="export-btn">Export backup…</button>
<pre id="export-status" class="status hidden"></pre>
</section>
<section class="card" id="restore-card">
<h2>Restore</h2>
<p>Decrypt a <code>.relbak</code> file and push it to a fresh
remote. The remote must be empty.</p>
<input type="file" id="restore-file" accept=".relbak">
<fieldset id="restore-remote" class="hidden">
<legend>Target remote</legend>
<label>Host type
<select id="rt-host-type">
<option value="gitea">Gitea</option>
<option value="github">GitHub</option>
</select>
</label>
<label>Host URL <input id="rt-host-url" type="url" placeholder="https://git.example.com"></label>
<label>Repo path <input id="rt-repo" type="text" placeholder="user/relicario-vault"></label>
<label>API token <input id="rt-token" type="password"></label>
<button id="restore-btn">Restore…</button>
</fieldset>
<pre id="restore-status" class="status hidden"></pre>
</section>
</div>
`;
wireExport(app);
wireRestore(app);
}
function wireExport(scope: HTMLElement): void {
const btn = scope.querySelector('#export-btn') as HTMLButtonElement;
const includeImage = scope.querySelector('#include-image') as HTMLInputElement;
const status = scope.querySelector('#export-status') as HTMLElement;
btn.addEventListener('click', async () => {
if (mode !== 'idle') return;
const passphrase = window.prompt('Backup passphrase (≥ zxcvbn score 3):');
if (!passphrase) return;
mode = 'exporting';
btn.disabled = true;
showStatus(status, 'Exporting…');
try {
const resp = await sendMessage({
type: 'export_backup',
passphrase,
includeImage: includeImage.checked,
});
if (!resp.ok) throw new Error(resp.error);
const data = (resp as { ok: true; data: { bytes: ArrayBuffer } }).data;
const blob = new Blob([data.bytes], { type: 'application/octet-stream' });
const url = URL.createObjectURL(blob);
const ts = new Date().toISOString().replace(/[:.]/g, '-');
const a = document.createElement('a');
a.href = url;
a.download = `relicario-${ts}.relbak`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
showStatus(status, `Exported (${(data.bytes.byteLength / 1024 / 1024).toFixed(2)} MiB).`);
} catch (e) {
showStatus(status, `Failed: ${(e as Error).message}`);
} finally {
mode = 'idle';
btn.disabled = false;
}
});
}
function wireRestore(scope: HTMLElement): void {
const file = scope.querySelector('#restore-file') as HTMLInputElement;
const remoteFs = scope.querySelector('#restore-remote') as HTMLElement;
const btn = scope.querySelector('#restore-btn') as HTMLButtonElement;
const status = scope.querySelector('#restore-status') as HTMLElement;
file.addEventListener('change', () => {
remoteFs.classList.toggle('hidden', file.files == null || file.files.length === 0);
});
btn.addEventListener('click', async () => {
if (mode !== 'idle') return;
const f = file.files?.[0];
if (!f) return;
const passphrase = window.prompt('Backup passphrase:');
if (!passphrase) return;
const hostType = (scope.querySelector('#rt-host-type') as HTMLSelectElement).value as 'gitea' | 'github';
const hostUrl = (scope.querySelector('#rt-host-url') as HTMLInputElement).value.trim();
const repoPath = (scope.querySelector('#rt-repo') as HTMLInputElement).value.trim();
const apiToken = (scope.querySelector('#rt-token') as HTMLInputElement).value.trim();
if (!hostUrl || !repoPath || !apiToken) {
showStatus(status, 'fill in host URL, repo path, and API token');
return;
}
mode = 'restoring';
btn.disabled = true;
showStatus(status, 'Restoring…');
try {
const bytes = await f.arrayBuffer();
const resp = await sendMessage({
type: 'restore_backup',
bytes,
passphrase,
newRemote: { hostType, hostUrl, repoPath, apiToken },
});
if (!resp.ok) throw new Error(resp.error);
const data = (resp as { ok: true; data: { summary: { itemCount: number; attachmentCount: number; hasImage: boolean } } }).data;
const summary = data.summary;
showStatus(status, `Restored: ${summary.itemCount} items, ${summary.attachmentCount} attachments${summary.hasImage ? ', image included' : ''}. Reload the extension and unlock.`);
} catch (e) {
showStatus(status, `Failed: ${(e as Error).message}`);
} finally {
mode = 'idle';
btn.disabled = false;
}
});
}
function showStatus(el: HTMLElement, text: string): void {
el.textContent = text;
el.classList.remove('hidden');
}
export function teardown(): void {
// No timers / object URLs to clean up — Blob URL revocation is per-export.
mode = 'idle';
}

View File

@@ -16,6 +16,7 @@ import { renderDevices, teardown as teardownDevices } from '../popup/components/
import { renderSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory, teardown as teardownFieldHistory } from '../popup/components/field-history';
import { renderBackupPanel, teardown as teardownBackup } from './components/backup-panel';
// ---------------------------------------------------------------------------
// Helpers
@@ -66,7 +67,7 @@ function typeLabel(t: ItemType): string {
// Hash routing
// ---------------------------------------------------------------------------
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history';
type VaultView = 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history' | 'backup';
interface HashRoute {
view: VaultView;
@@ -92,6 +93,7 @@ function parseHash(): HashRoute {
case 'settings':
case 'settings-vault':
case 'field-history':
case 'backup':
return { view };
default:
return { view: 'list' };
@@ -418,6 +420,7 @@ function teardownPaneComponents(): void {
teardownTrash();
teardownDevices();
teardownFieldHistory();
teardownBackup();
}
function renderPane(): void {
@@ -464,6 +467,9 @@ function renderPane(): void {
case 'field-history':
renderFieldHistory(pane);
break;
case 'backup':
renderBackupPanel(pane);
break;
default:
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';