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:
29
CHANGELOG.md
29
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 <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
111
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: ¶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<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<()> {
|
||||
|
||||
142
crates/relicario-cli/tests/backup.rs
Normal file
142
crates/relicario-cli/tests/backup.rs
Normal 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}");
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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]
|
||||
|
||||
340
crates/relicario-core/src/backup.rs
Normal file
340
crates/relicario-core/src/backup.rs
Normal 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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,3 +77,6 @@ pub use vault::{
|
||||
};
|
||||
|
||||
pub mod imgsecret;
|
||||
|
||||
pub mod backup;
|
||||
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};
|
||||
|
||||
188
crates/relicario-core/tests/backup.rs
Normal file
188
crates/relicario-core/tests/backup.rs
Normal 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:?}"),
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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 & restore</div>
|
||||
<div class="settings-row">
|
||||
<button class="btn" id="open-backup">Backup & 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;
|
||||
|
||||
153
extension/src/service-worker/__tests__/backup.test.ts
Normal file
153
extension/src/service-worker/__tests__/backup.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
152
extension/src/vault/components/backup-panel.ts
Normal file
152
extension/src/vault/components/backup-panel.ts
Normal 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 & 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';
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user