feat(core): backup module — empty-vault round-trip
pack_backup / unpack_backup ship the magic header, format version, Argon2id KDF, XChaCha20-Poly1305 AEAD, and zstd-compressed JSON envelope. Empty-vault round-trip is the foundation; later tasks add items, attachments, image, and git history.
This commit is contained in:
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)),
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -77,3 +77,6 @@ pub use vault::{
|
||||
};
|
||||
|
||||
pub mod imgsecret;
|
||||
|
||||
pub mod backup;
|
||||
pub use backup::{pack_backup, unpack_backup, BackupInput, BackupOutput, BackupItem, BackupAttachment};
|
||||
|
||||
Reference in New Issue
Block a user