This commit is contained in:
Mateusz Gruszczyński committed 2026-10-04 11:15:58 +02:00
1 parent 87e571c170
commit fa7abc9e53
308 files changed
+49746 -50237

No files matched your search

+3 -742
View File
@@ -1,748 +1,9 @@
//! Password-encrypted full backup/restore for RustProxy durable data.
//! The format is streaming, authenticated and deliberately excludes caches,
//! logs, sockets and temporary runtime state.
use anyhow::{bail, ensure, Context, Result};
use argon2::{Algorithm, Argon2, Params, Version};
use base64::Engine as _;
use rand::{rngs::OsRng, RngCore};
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{self, File, OpenOptions},
io::{Read, Write},
path::{Component, Path, PathBuf},
};
use zeroize::Zeroizing;
const BACKUP_MAGIC: &[u8; 8] = b"RPROXYB1";
const ARCHIVE_MAGIC: &[u8; 8] = b"RPROXYA1";
const KEY_MAGIC: &[u8; 4] = b"RPK1";
const FORMAT_VERSION: u32 = 1;
const ARCHIVE_VERSION: u32 = 1;
const CHUNK_SIZE: usize = 1024 * 1024;
const ARGON_MEMORY_KIB: u32 = 64 * 1024;
const ARGON_ITERATIONS: u32 = 3;
const ARGON_PARALLELISM: u32 = 1;
const MAX_HEADER_BYTES: usize = 16 * 1024;
const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
const KEY_AAD: &[u8] = b"rustproxy-secret-store-key-v1";
#[derive(Debug, Clone, Serialize, Deserialize)]
struct CryptoHeader {
format_version: u32,
kdf: String,
salt_b64: String,
memory_kib: u32,
iterations: u32,
parallelism: u32,
aead: String,
nonce_prefix_b64: String,
chunk_size: usize,
}
struct CryptoContext {
header_bytes: Vec<u8>,
key: Zeroizing<[u8; 32]>,
nonce_prefix: [u8; 4],
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct Manifest {
archive_version: u32,
rustproxy_version: String,
schema_version: i64,
configuration_revision: u64,
created_at_unix: i64,
secret_store_encryption_version: u32,
backup_encryption: String,
entries: Vec<ManifestEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
struct ManifestEntry {
path: String,
size: u64,
sha256: String,
}
enum EntrySource {
File(PathBuf),
Bytes(Vec<u8>),
}
struct ArchiveEntry {
manifest: ManifestEntry,
source: EntrySource,
}
#[derive(Debug, Clone)]
pub struct BackupSummary {
pub path: PathBuf,
pub files: usize,
pub configuration_revision: u64,
}
struct TempRoot(PathBuf);
impl Drop for TempRoot {
fn drop(&mut self) {
let _ = fs::remove_dir_all(&self.0);
}
}
struct PartialFile(PathBuf, bool);
impl Drop for PartialFile {
fn drop(&mut self) {
if !self.1 {
let _ = fs::remove_file(&self.0);
}
}
}
fn parent_or_dot(path: &Path) -> &Path {
path.parent()
.filter(|parent| !parent.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."))
}
fn validate_backup_version(version: &str) -> Result<()> {
fn parse(value: &str) -> Result<(u64, u64, u64)> {
let value = value.split_once('-').map_or(value, |(base, _)| base);
let mut parts = value.split('.');
let major = parts.next().context("missing major version")?.parse()?;
let minor = parts.next().context("missing minor version")?.parse()?;
let patch = parts.next().context("missing patch version")?.parse()?;
ensure!(parts.next().is_none(), "invalid RustProxy version");
Ok((major, minor, patch))
}
let backup = parse(version).context("invalid RustProxy version in backup manifest")?;
let current = parse(env!("CARGO_PKG_VERSION")).context("invalid running RustProxy version")?;
ensure!(
backup <= current,
"backup was created by newer RustProxy version {version}; current version is {}",
env!("CARGO_PKG_VERSION")
);
Ok(())
}
fn harden_tree(path: &Path) -> Result<()> {
let metadata = fs::symlink_metadata(path)?;
ensure!(!metadata.file_type().is_symlink(), "restore staging contains a symlink");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = if metadata.is_dir() { 0o700 } else { 0o600 };
fs::set_permissions(path, fs::Permissions::from_mode(mode))?;
}
if metadata.is_dir() {
for entry in fs::read_dir(path)? {
harden_tree(&entry?.path())?;
}
}
Ok(())
}
fn private_dir(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
let meta = fs::symlink_metadata(path)?;
ensure!(meta.is_dir() && !meta.file_type().is_symlink(), "backup directory must not be a symlink");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(path, fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn private_file_options() -> OpenOptions {
let mut options = OpenOptions::new();
options.write(true).read(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt;
options.mode(0o600).custom_flags(libc::O_NOFOLLOW);
}
options
}
fn create_temp_root(parent: &Path, prefix: &str) -> Result<TempRoot> {
ensure!(parent.is_dir(), "backup parent directory does not exist");
for _ in 0..32 {
let name = format!(".{prefix}-{:016x}", rand::random::<u64>());
let path = parent.join(name);
match fs::create_dir(&path) {
Ok(()) => {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(&path, fs::Permissions::from_mode(0o700))?;
}
return Ok(TempRoot(path));
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue,
Err(error) => return Err(error.into()),
}
}
bail!("could not allocate private backup staging directory")
}
fn derive_key(password: &str, salt: &[u8]) -> Result<Zeroizing<[u8; 32]>> {
ensure!(!password.is_empty(), "backup password cannot be empty");
let params = Params::new(
ARGON_MEMORY_KIB,
ARGON_ITERATIONS,
ARGON_PARALLELISM,
Some(32),
)
.map_err(|_| anyhow::anyhow!("invalid backup KDF parameters"))?;
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
let mut key = Zeroizing::new([0u8; 32]);
argon
.hash_password_into(password.as_bytes(), salt, key.as_mut())
.map_err(|_| anyhow::anyhow!("backup key derivation failed"))?;
Ok(key)
}
fn new_crypto(password: &str) -> Result<CryptoContext> {
let mut salt = [0u8; 16];
let mut nonce_prefix = [0u8; 4];
OsRng.fill_bytes(&mut salt);
OsRng.fill_bytes(&mut nonce_prefix);
let header = CryptoHeader {
format_version: FORMAT_VERSION,
kdf: "Argon2id".into(),
salt_b64: base64::engine::general_purpose::STANDARD.encode(salt),
memory_kib: ARGON_MEMORY_KIB,
iterations: ARGON_ITERATIONS,
parallelism: ARGON_PARALLELISM,
aead: "AES-256-GCM".into(),
nonce_prefix_b64: base64::engine::general_purpose::STANDARD.encode(nonce_prefix),
chunk_size: CHUNK_SIZE,
};
let header_bytes = serde_json::to_vec(&header)?;
let key = derive_key(password, &salt)?;
Ok(CryptoContext {
header_bytes,
key,
nonce_prefix,
})
}
fn parse_crypto_header(bytes: Vec<u8>, password: &str) -> Result<CryptoContext> {
let header: CryptoHeader = serde_json::from_slice(&bytes).context("invalid backup header")?;
ensure!(
header.format_version == FORMAT_VERSION
&& header.kdf == "Argon2id"
&& header.memory_kib == ARGON_MEMORY_KIB
&& header.iterations == ARGON_ITERATIONS
&& header.parallelism == ARGON_PARALLELISM
&& header.aead == "AES-256-GCM"
&& header.chunk_size == CHUNK_SIZE,
"unsupported backup cryptographic format"
);
let salt = base64::engine::general_purpose::STANDARD
.decode(&header.salt_b64)
.context("invalid backup salt")?;
ensure!(salt.len() == 16, "invalid backup salt");
let prefix = base64::engine::general_purpose::STANDARD
.decode(&header.nonce_prefix_b64)
.context("invalid backup nonce prefix")?;
ensure!(prefix.len() == 4, "invalid backup nonce prefix");
let mut nonce_prefix = [0u8; 4];
nonce_prefix.copy_from_slice(&prefix);
let key = derive_key(password, &salt)?;
Ok(CryptoContext {
header_bytes: bytes,
key,
nonce_prefix,
})
}
fn cipher(key: &[u8]) -> Result<LessSafeKey> {
Ok(LessSafeKey::new(
UnboundKey::new(&AES_256_GCM, key)
.map_err(|_| anyhow::anyhow!("cannot initialize backup cipher"))?,
))
}
fn nonce(prefix: [u8; 4], counter: u64) -> Nonce {
let mut bytes = [0u8; 12];
bytes[..4].copy_from_slice(&prefix);
bytes[4..].copy_from_slice(&counter.to_be_bytes());
Nonce::assume_unique_for_key(bytes)
}
fn chunk_aad(header: &[u8], counter: u64) -> Vec<u8> {
let mut aad = Vec::with_capacity(BACKUP_MAGIC.len() + header.len() + 8);
aad.extend_from_slice(BACKUP_MAGIC);
aad.extend_from_slice(header);
aad.extend_from_slice(&counter.to_be_bytes());
aad
}
fn encrypt_archive(input: &Path, output: &Path, crypto: &CryptoContext) -> Result<()> {
ensure!(!output.exists(), "backup output already exists");
let mut source = File::open(input)?;
let mut target = private_file_options().create_new(true).open(output)?;
let mut guard = PartialFile(output.to_path_buf(), false);
target.write_all(BACKUP_MAGIC)?;
let header_len: u32 = crypto.header_bytes.len().try_into()?;
target.write_all(&header_len.to_be_bytes())?;
target.write_all(&crypto.header_bytes)?;
let cipher = cipher(crypto.key.as_ref())?;
let mut counter = 0u64;
let mut buffer = vec![0u8; CHUNK_SIZE];
loop {
let read = source.read(&mut buffer)?;
if read == 0 {
break;
}
let mut chunk = buffer[..read].to_vec();
cipher
.seal_in_place_append_tag(
nonce(crypto.nonce_prefix, counter),
Aad::from(chunk_aad(&crypto.header_bytes, counter)),
&mut chunk,
)
.map_err(|_| anyhow::anyhow!("backup encryption failed"))?;
let length: u32 = chunk.len().try_into()?;
target.write_all(&length.to_be_bytes())?;
target.write_all(&chunk)?;
counter = counter.checked_add(1).context("backup is too large")?;
}
target.write_all(&0u32.to_be_bytes())?;
target.sync_all()?;
guard.1 = true;
Ok(())
}
fn decrypt_archive(input: &Path, output: &Path, password: &str) -> Result<Zeroizing<[u8; 32]>> {
let metadata = fs::symlink_metadata(input)?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup must be a regular file");
let mut source = File::open(input)?;
let mut magic = [0u8; 8];
source.read_exact(&mut magic)?;
ensure!(&magic == BACKUP_MAGIC, "not a RustProxy backup");
let header_len = read_u32(&mut source)? as usize;
ensure!(header_len > 0 && header_len <= MAX_HEADER_BYTES, "invalid backup header length");
let mut header_bytes = vec![0u8; header_len];
source.read_exact(&mut header_bytes)?;
let crypto = parse_crypto_header(header_bytes, password)?;
let cipher = cipher(crypto.key.as_ref())?;
let mut target = private_file_options().create_new(true).open(output)?;
let mut counter = 0u64;
loop {
let length = read_u32(&mut source)? as usize;
if length == 0 {
break;
}
ensure!(
length >= AES_256_GCM.tag_len() && length <= CHUNK_SIZE + AES_256_GCM.tag_len(),
"invalid encrypted backup chunk"
);
let mut chunk = vec![0u8; length];
source.read_exact(&mut chunk)?;
let plaintext = cipher
.open_in_place(
nonce(crypto.nonce_prefix, counter),
Aad::from(chunk_aad(&crypto.header_bytes, counter)),
&mut chunk,
)
.map_err(|_| anyhow::anyhow!("incorrect backup password or corrupted archive"))?;
target.write_all(plaintext)?;
counter = counter.checked_add(1).context("backup is too large")?;
}
let mut extra = [0u8; 1];
ensure!(source.read(&mut extra)? == 0, "trailing data after encrypted backup");
target.sync_all()?;
Ok(crypto.key)
}
fn seal_master_key(master: &[u8], backup_key: &[u8]) -> Result<Vec<u8>> {
ensure!(master.len() == 32, "invalid secret store master key");
let mut nonce_bytes = [0u8; 12];
OsRng.fill_bytes(&mut nonce_bytes);
let cipher = cipher(backup_key)?;
let mut encrypted = Zeroizing::new(master.to_vec());
cipher
.seal_in_place_append_tag(
Nonce::assume_unique_for_key(nonce_bytes),
Aad::from(KEY_AAD),
&mut *encrypted,
)
.map_err(|_| anyhow::anyhow!("could not wrap secret store key"))?;
let mut output = Vec::with_capacity(4 + 12 + encrypted.len());
output.extend_from_slice(KEY_MAGIC);
output.extend_from_slice(&nonce_bytes);
output.extend_from_slice(encrypted.as_ref());
Ok(output)
}
fn open_master_key(blob: &[u8], backup_key: &[u8]) -> Result<Zeroizing<Vec<u8>>> {
ensure!(blob.len() == 4 + 12 + 32 + AES_256_GCM.tag_len(), "invalid wrapped secret store key");
ensure!(&blob[..4] == KEY_MAGIC, "invalid wrapped secret store key");
let mut nonce_bytes = [0u8; 12];
nonce_bytes.copy_from_slice(&blob[4..16]);
let mut encrypted = Zeroizing::new(blob[16..].to_vec());
let cipher = cipher(backup_key)?;
let plaintext = cipher
.open_in_place(
Nonce::assume_unique_for_key(nonce_bytes),
Aad::from(KEY_AAD),
encrypted.as_mut(),
)
.map_err(|_| anyhow::anyhow!("wrapped secret store key authentication failed"))?;
Ok(Zeroizing::new(plaintext.to_vec()))
}
fn hash_file(path: &Path) -> Result<(u64, String)> {
let metadata = fs::symlink_metadata(path)?;
ensure!(metadata.is_file() && !metadata.file_type().is_symlink(), "backup input must be a regular file");
let mut file = File::open(path)?;
let mut hasher = Sha256::new();
let mut size = 0u64;
let mut buffer = [0u8; 1024 * 1024];
loop {
let read = file.read(&mut buffer)?;
if read == 0 {
break;
}
size = size.checked_add(read as u64).context("backup input too large")?;
hasher.update(&buffer[..read]);
}
Ok((size, format!("{:x}", hasher.finalize())))
}
fn hash_bytes(bytes: &[u8]) -> String {
format!("{:x}", Sha256::digest(bytes))
}
fn excluded_directory(name: &str) -> bool {
matches!(name, "cache" | "logs" | "tmp" | "temp" | "work" | "acme-http01")
}
fn excluded_file(name: &str) -> bool {
name == "process.lock"
|| name == "config.sqlite3"
|| name == "config.sqlite3-wal"
|| name == "config.sqlite3-shm"
|| name == "master.key"
|| name == ".lock"
|| name.ends_with(".sock")
|| name.ends_with(".pid")
|| name.ends_with(".log")
|| name.ends_with(".jsonl")
}
fn collect_data_files(root: &Path, current: &Path, entries: &mut Vec<ArchiveEntry>) -> Result<()> {
for item in fs::read_dir(current)? {
let item = item?;
let path = item.path();
let meta = fs::symlink_metadata(&path)?;
ensure!(!meta.file_type().is_symlink(), "backup refuses symlinks in the data directory: {}", path.display());
let name = item.file_name();
let name_text = name.to_string_lossy();
if meta.is_dir() {
if excluded_directory(&name_text) || (path == root.join("secrets")) {
continue;
}
collect_data_files(root, &path, entries)?;
} else if meta.is_file() {
if excluded_file(&name_text) {
continue;
}
let relative = path.strip_prefix(root)?;
let archive_path = format!("data/{}", relative.to_string_lossy().replace('\\', "/"));
let (size, sha256) = hash_file(&path)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry { path: archive_path, size, sha256 },
source: EntrySource::File(path),
});
}
}
Ok(())
}
fn write_inner_archive(path: &Path, manifest: &Manifest, entries: &[ArchiveEntry]) -> Result<()> {
let mut file = private_file_options().create_new(true).open(path)?;
file.write_all(ARCHIVE_MAGIC)?;
let manifest_bytes = serde_json::to_vec(manifest)?;
ensure!(manifest_bytes.len() <= MAX_MANIFEST_BYTES, "backup manifest is too large");
file.write_all(&(manifest_bytes.len() as u32).to_be_bytes())?;
file.write_all(&manifest_bytes)?;
let mut buffer = [0u8; 1024 * 1024];
for entry in entries {
let path_bytes = entry.manifest.path.as_bytes();
ensure!(!path_bytes.is_empty() && path_bytes.len() <= u16::MAX as usize, "backup path is too long");
file.write_all(&(path_bytes.len() as u16).to_be_bytes())?;
file.write_all(path_bytes)?;
file.write_all(&entry.manifest.size.to_be_bytes())?;
match &entry.source {
EntrySource::File(source) => {
let mut input = File::open(source)?;
let mut remaining = entry.manifest.size;
while remaining > 0 {
let take = usize::try_from(remaining.min(buffer.len() as u64))?;
let read = input.read(&mut buffer[..take])?;
ensure!(read > 0, "backup input changed while being read");
file.write_all(&buffer[..read])?;
remaining -= read as u64;
}
ensure!(input.read(&mut buffer[..1])? == 0, "backup input changed while being read");
}
EntrySource::Bytes(bytes) => file.write_all(bytes)?,
}
}
file.sync_all()?;
Ok(())
}
fn read_u16<R: Read>(reader: &mut R) -> Result<u16> {
let mut bytes = [0u8; 2];
reader.read_exact(&mut bytes)?;
Ok(u16::from_be_bytes(bytes))
}
fn read_u32<R: Read>(reader: &mut R) -> Result<u32> {
let mut bytes = [0u8; 4];
reader.read_exact(&mut bytes)?;
Ok(u32::from_be_bytes(bytes))
}
fn read_u64<R: Read>(reader: &mut R) -> Result<u64> {
let mut bytes = [0u8; 8];
reader.read_exact(&mut bytes)?;
Ok(u64::from_be_bytes(bytes))
}
fn safe_relative(path: &str) -> Result<PathBuf> {
ensure!(!path.is_empty() && path.len() <= 4096 && !path.contains('\\') && !path.contains(':'), "unsafe backup path");
let value = Path::new(path);
ensure!(!value.is_absolute(), "unsafe absolute backup path");
for component in value.components() {
ensure!(matches!(component, Component::Normal(_)), "unsafe backup path component");
}
Ok(value.to_path_buf())
}
fn extract_inner_archive(path: &Path, stage: &Path) -> Result<(Manifest, Vec<u8>)> {
let mut file = File::open(path)?;
let mut magic = [0u8; 8];
file.read_exact(&mut magic)?;
ensure!(&magic == ARCHIVE_MAGIC, "invalid decrypted RustProxy backup payload");
let manifest_len = read_u32(&mut file)? as usize;
ensure!(manifest_len > 0 && manifest_len <= MAX_MANIFEST_BYTES, "invalid backup manifest length");
let mut manifest_bytes = vec![0u8; manifest_len];
file.read_exact(&mut manifest_bytes)?;
let manifest: Manifest = serde_json::from_slice(&manifest_bytes).context("invalid backup manifest")?;
ensure!(manifest.archive_version == ARCHIVE_VERSION, "unsupported backup archive version");
validate_backup_version(&manifest.rustproxy_version)?;
ensure!(manifest.schema_version <= crate::db::SCHEMA_VERSION, "backup database schema is newer than this RustProxy binary");
ensure!(manifest.entries.len() <= 100_000, "backup contains too many files");
let mut wrapped_key = None;
let mut buffer = vec![0u8; 1024 * 1024];
for expected in &manifest.entries {
let path_len = read_u16(&mut file)? as usize;
ensure!(path_len > 0 && path_len <= 4096, "invalid backup path length");
let mut path_bytes = vec![0u8; path_len];
file.read_exact(&mut path_bytes)?;
let archive_path = String::from_utf8(path_bytes).context("backup path is not UTF-8")?;
ensure!(archive_path == expected.path, "backup manifest entry order/path mismatch");
let size = read_u64(&mut file)?;
ensure!(size == expected.size, "backup manifest size mismatch");
let mut hasher = Sha256::new();
if archive_path == "keys/secret-store-key.enc" {
ensure!(size <= 256, "wrapped secret store key is too large");
let mut bytes = vec![0u8; size as usize];
file.read_exact(&mut bytes)?;
hasher.update(&bytes);
wrapped_key = Some(bytes);
} else {
let relative = archive_path
.strip_prefix("data/")
.ok_or_else(|| anyhow::anyhow!("unsupported backup entry"))?;
let relative = safe_relative(relative)?;
let target = stage.join(relative);
if let Some(parent) = target.parent() {
private_dir(parent)?;
}
let mut output = private_file_options().create_new(true).open(&target)?;
let mut remaining = size;
while remaining > 0 {
let take = usize::try_from(remaining.min(buffer.len() as u64))?;
file.read_exact(&mut buffer[..take])?;
output.write_all(&buffer[..take])?;
hasher.update(&buffer[..take]);
remaining -= take as u64;
}
output.sync_all()?;
}
ensure!(format!("{:x}", hasher.finalize()) == expected.sha256, "backup entry checksum mismatch");
}
let mut extra = [0u8; 1];
ensure!(file.read(&mut extra)? == 0, "trailing data in backup payload");
let key = wrapped_key.context("backup is missing Secret Store key material")?;
ensure!(stage.join("config.sqlite3").is_file(), "backup is missing database");
Ok((manifest, key))
}
pub async fn create(data_dir: &Path, output: &Path, password: &str) -> Result<BackupSummary> {
crate::auth::prepare_directory(data_dir)?;
let _lock = crate::auth::lock_directory(data_dir)
.context("stop RustProxy before creating a full backup")?;
let database = data_dir.join("config.sqlite3");
ensure!(database.is_file(), "database does not exist at {}", database.display());
crate::auth::prepare_database(&database)?;
let store = crate::db::Store::open(&database, &crate::config::Config::default()).await?;
crate::secret_store::initialize(data_dir)?;
let cert_dir = data_dir.join("certs");
if cert_dir.is_dir() {
crate::secret_store::migrate_acme_credentials(&store, data_dir, &cert_dir).await?;
}
let parent = parent_or_dot(output);
let data_root = fs::canonicalize(data_dir).context("cannot resolve data directory")?;
let output_parent = fs::canonicalize(parent).context("cannot resolve backup output directory")?;
ensure!(
!output_parent.starts_with(&data_root),
"backup output must be outside the RustProxy data directory"
);
let temp = create_temp_root(parent, "rustproxy-backup")?;
let snapshot = temp.0.join("config.sqlite3");
store.backup_to(&snapshot).await?;
let envelope = store.load().await?;
let crypto = new_crypto(password)?;
let master = crate::secret_store::read_master_key_for_backup(data_dir)?;
let key_blob = seal_master_key(master.as_ref(), crypto.key.as_ref())?;
let (db_size, db_hash) = hash_file(&snapshot)?;
let mut entries = vec![ArchiveEntry {
manifest: ManifestEntry {
path: "data/config.sqlite3".into(),
size: db_size,
sha256: db_hash,
},
source: EntrySource::File(snapshot),
}];
collect_data_files(data_dir, data_dir, &mut entries)?;
entries.push(ArchiveEntry {
manifest: ManifestEntry {
path: "keys/secret-store-key.enc".into(),
size: key_blob.len() as u64,
sha256: hash_bytes(&key_blob),
},
source: EntrySource::Bytes(key_blob),
});
entries.sort_by(|a, b| a.manifest.path.cmp(&b.manifest.path));
let manifest = Manifest {
archive_version: ARCHIVE_VERSION,
rustproxy_version: env!("CARGO_PKG_VERSION").into(),
schema_version: crate::db::SCHEMA_VERSION,
configuration_revision: envelope.revision,
created_at_unix: crate::auth::now(),
secret_store_encryption_version: 1,
backup_encryption: "Argon2id + AES-256-GCM chunked AEAD".into(),
entries: entries.iter().map(|entry| entry.manifest.clone()).collect(),
};
let plaintext = temp.0.join("archive.rpa");
write_inner_archive(&plaintext, &manifest, &entries)?;
encrypt_archive(&plaintext, output, &crypto)?;
store.close().await;
Ok(BackupSummary {
path: output.to_path_buf(),
files: manifest.entries.len(),
configuration_revision: manifest.configuration_revision,
})
}
pub async fn restore(archive: &Path, data_dir: &Path, password: &str) -> Result<BackupSummary> {
let archive_meta = fs::symlink_metadata(archive).context("backup archive does not exist")?;
ensure!(archive_meta.is_file() && !archive_meta.file_type().is_symlink(), "backup archive must be a regular file");
let parent = parent_or_dot(data_dir);
ensure!(parent.is_dir(), "restore parent directory does not exist");
let old_lock = if data_dir.exists() {
crate::auth::prepare_directory(data_dir)?;
Some(crate::auth::lock_directory(data_dir).context("stop RustProxy before restore")?)
} else {
None
};
let temp = create_temp_root(parent, "rustproxy-restore")?;
let plaintext = temp.0.join("archive.rpa");
let backup_key = decrypt_archive(archive, &plaintext, password)?;
let stage = temp.0.join("stage");
private_dir(&stage)?;
let (manifest, wrapped_key) = extract_inner_archive(&plaintext, &stage)?;
let master = open_master_key(&wrapped_key, backup_key.as_ref())?;
crate::secret_store::write_restored_master_key(&stage, master.as_ref())?;
harden_tree(&stage)?;
crate::auth::prepare_directory(&stage)?;
let stage_db = stage.join("config.sqlite3");
crate::auth::prepare_database(&stage_db)?;
let store = crate::db::Store::open(&stage_db, &crate::config::Config::default()).await?;
let envelope = store.load().await.context("restored configuration is unreadable")?;
crate::secret_store::validate_all(&store, &stage)
.await
.context("restored Secret Store cannot be decrypted")?;
store.close().await;
for suffix in ["-wal", "-shm"] {
let mut name = stage_db.as_os_str().to_os_string();
name.push(suffix);
let _ = fs::remove_file(PathBuf::from(name));
}
let stage_lock = crate::auth::lock_directory(&stage)?;
let previous = temp.0.join("previous-data");
if data_dir.exists() {
fs::rename(data_dir, &previous).context("could not move current data directory aside")?;
}
if let Err(error) = fs::rename(&stage, data_dir) {
if previous.exists() {
let _ = fs::rename(&previous, data_dir);
}
return Err(error).context("could not atomically activate restored data directory");
}
drop(old_lock);
if previous.exists() {
fs::remove_dir_all(&previous).context("restore succeeded but old data directory could not be removed")?;
}
drop(stage_lock);
Ok(BackupSummary {
path: archive.to_path_buf(),
files: manifest.entries.len(),
configuration_revision: envelope.revision,
})
}
pub fn generated_password() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
include!("backup/archive.rs");
include!("backup/validation.rs");
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn password_encryption_rejects_wrong_password_and_tampering() {
let dir = tempfile::tempdir().unwrap();
let input = dir.path().join("input");
let encrypted = dir.path().join("backup.rpb");
let output = dir.path().join("output");
fs::write(&input, b"private backup payload").unwrap();
let crypto = new_crypto("correct horse battery staple").unwrap();
encrypt_archive(&input, &encrypted, &crypto).unwrap();
assert!(decrypt_archive(&encrypted, &output, "wrong password").is_err());
decrypt_archive(&encrypted, &output, "correct horse battery staple").unwrap();
assert_eq!(fs::read(&output).unwrap(), b"private backup payload");
}
#[test]
fn restored_paths_cannot_escape_staging() {
assert!(safe_relative("certs/example.pem").is_ok());
assert!(safe_relative("../outside").is_err());
assert!(safe_relative("/absolute").is_err());
assert!(safe_relative("a\\b").is_err());
}
#[test]
fn manifest_rejects_newer_rustproxy_version() {
assert!(validate_backup_version(env!("CARGO_PKG_VERSION")).is_ok());
assert!(validate_backup_version("999.0.0").is_err());
}
}
include!("backup/tests.rs");