Backup KDF was passing raw passphrase bytes to Argon2id without NFC normalization, causing cross-platform restore failures for non-ASCII passphrases (macOS NFD vs Linux NFC). Now matches derive_master_key behavior from crypto.rs. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
349 lines
12 KiB
Rust
349 lines
12 KiB
Rust
//! 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]>> {
|
|
use unicode_normalization::UnicodeNormalization;
|
|
|
|
// NFC normalize passphrase (matches derive_master_key in crypto.rs)
|
|
let nfc_passphrase: Vec<u8> = match std::str::from_utf8(passphrase) {
|
|
Ok(s) => s.nfc().collect::<String>().into_bytes(),
|
|
Err(_) => passphrase.to_vec(),
|
|
};
|
|
|
|
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(&nfc_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)),
|
|
},
|
|
})
|
|
}
|