v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
+3
-742
@@ -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");
|
||||
Reference in new issue
Block a user