host: settings backup envelope and encrypted folder sync

backup: the versioned .waybetter envelope (format/version checks, only the
app's registered sections kept), secret-key scrubbing, the plugins /
customCss / accounts sections every app has, plugin import checks and
atomic writes. sync: Argon2id + XChaCha20-Poly1305 snapshots of the
settings backup in a folder the owner syncs, moved unchanged from the
wayBetter for X app with its tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
CRTkafa 2026-09-29 10:58:15 +07:00
parent d36e2d8709
commit 8ff27a629f
5 changed files with 885 additions and 0 deletions

View file

@ -0,0 +1,434 @@
//! Encrypted settings sync through a folder the owner already syncs (OneDrive, iCloud Drive,
//! Dropbox, a NAS share...). No server of ours, no account: the app writes encrypted, versioned
//! snapshots of its settings backup ([`crate::backup`], never drafts, cookies, tokens or keys) into
//! `<folder>/wayBetter Sync/`, and another machine with the same passphrase offers to pull a newer
//! one.
//!
//! Crypto: the key is Argon2id(passphrase, salt) (32 bytes; the salt and cost live in
//! `sync.json` in the sync folder, so every machine derives the same key); each snapshot is sealed
//! with XChaCha20-Poly1305 under a fresh random 24-byte nonce, the snapshot header as associated
//! data. `sync.json` also holds a sealed check value, so a wrong passphrase is refused before
//! anything is read or written. Pure (files and bytes in, no Tauri), unit-tested headless.
use argon2::{Algorithm, Argon2, Params, Version};
use base64::{engine::general_purpose::STANDARD as B64, Engine};
use chacha20poly1305::aead::{Aead, KeyInit, Payload};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
pub const FOLDER_NAME: &str = "wayBetter Sync";
pub const META_FILE: &str = "sync.json";
pub const SNAPSHOT_DIR: &str = "snapshots";
pub const SNAPSHOT_SUFFIX: &str = ".wbsync";
pub const FORMAT: &str = "waybetter-sync";
pub const VERSION: u32 = 1;
/// Snapshots this device keeps of its own in the folder (older ones are deleted).
pub const KEEP_PER_DEVICE: usize = 10;
/// A snapshot's plaintext (the settings backup, plugins included) is at most this large.
pub const MAX_PLAINTEXT: usize = 24 * 1024 * 1024;
const CHECK_PLAINTEXT: &[u8] = b"waybetter-sync-key-check-v1";
const MIN_PASSPHRASE: usize = 8;
/// Argon2id cost. Defaults: 64 MiB, 3 passes, 1 lane (OWASP's Argon2id baseline is 19 MiB/t=2).
#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct KdfParams {
pub memory_kib: u32,
pub iterations: u32,
pub parallelism: u32,
}
impl Default for KdfParams {
fn default() -> Self {
Self { memory_kib: 64 * 1024, iterations: 3, parallelism: 1 }
}
}
impl KdfParams {
fn checked(&self) -> Result<Params, String> {
// Bounds so a hostile sync.json cannot make this machine spend minutes or gigabytes.
if !(8 * 1024..=1024 * 1024).contains(&self.memory_kib) || !(1..=10).contains(&self.iterations) || !(1..=4).contains(&self.parallelism) {
return Err("SYNC: unsupported key derivation settings in sync.json".into());
}
Params::new(self.memory_kib, self.iterations, self.parallelism, Some(32)).map_err(|_| "SYNC: invalid key derivation settings".into())
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Sealed {
pub nonce: String,
pub ciphertext: String,
}
/// `sync.json` in the sync folder: public parameters only (no key, no passphrase).
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SyncMeta {
pub format: String,
pub version: u32,
pub kdf: String,
pub params: KdfParams,
pub salt: String,
pub cipher: String,
pub check: Sealed,
}
/// One snapshot file: a public header (who wrote it, when) and the sealed backup.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct Snapshot {
pub format: String,
pub version: u32,
pub device: String,
pub device_name: String,
/// Milliseconds since the epoch; also the start of the file name.
pub created_at: u64,
pub sealed: Sealed,
}
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct SnapshotInfo {
pub name: String,
pub device: String,
pub device_name: String,
pub created_at: u64,
pub size: u64,
}
/// A derived 32-byte key. Never serialized, never logged.
#[derive(Clone)]
pub struct Key([u8; 32]);
impl Key {
pub fn from_bytes(bytes: [u8; 32]) -> Self {
Self(bytes)
}
pub fn to_base64(&self) -> String {
B64.encode(self.0)
}
pub fn from_base64(text: &str) -> Option<Self> {
let bytes = B64.decode(text.trim()).ok()?;
Some(Self(bytes.try_into().ok()?))
}
}
impl std::fmt::Debug for Key {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("Key(…)")
}
}
fn random<const N: usize>() -> Result<[u8; N], String> {
let mut bytes = [0u8; N];
getrandom::getrandom(&mut bytes).map_err(|_| "SYNC: no secure random source".to_string())?;
Ok(bytes)
}
pub fn derive_key(passphrase: &str, salt: &[u8], params: &KdfParams) -> Result<Key, String> {
if passphrase.chars().count() < MIN_PASSPHRASE {
return Err(format!("SYNC: the passphrase needs at least {MIN_PASSPHRASE} characters"));
}
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params.checked()?);
let mut out = [0u8; 32];
argon.hash_password_into(passphrase.as_bytes(), salt, &mut out).map_err(|_| "SYNC: key derivation failed".to_string())?;
Ok(Key(out))
}
pub fn seal(key: &Key, plaintext: &[u8], aad: &[u8]) -> Result<Sealed, String> {
let cipher = XChaCha20Poly1305::new((&key.0).into());
let nonce = random::<24>()?;
let ciphertext = cipher
.encrypt(XNonce::from_slice(&nonce), Payload { msg: plaintext, aad })
.map_err(|_| "SYNC: encryption failed".to_string())?;
Ok(Sealed { nonce: B64.encode(nonce), ciphertext: B64.encode(ciphertext) })
}
/// Fails for a wrong key, a changed header (associated data) or a changed ciphertext alike.
pub fn open(key: &Key, sealed: &Sealed, aad: &[u8]) -> Result<Vec<u8>, String> {
let nonce = B64.decode(&sealed.nonce).map_err(|_| "SYNC: damaged snapshot")?;
let ciphertext = B64.decode(&sealed.ciphertext).map_err(|_| "SYNC: damaged snapshot")?;
if nonce.len() != 24 {
return Err("SYNC: damaged snapshot".into());
}
XChaCha20Poly1305::new((&key.0).into())
.decrypt(XNonce::from_slice(&nonce), Payload { msg: &ciphertext, aad })
.map_err(|_| "SYNC: wrong passphrase or damaged snapshot".into())
}
fn meta_aad(params: &KdfParams, salt: &str) -> Vec<u8> {
format!("{FORMAT}|{VERSION}|meta|argon2id|{}|{}|{}|{salt}", params.memory_kib, params.iterations, params.parallelism).into_bytes()
}
fn snapshot_aad(device: &str, device_name: &str, created_at: u64) -> Vec<u8> {
format!("{FORMAT}|{VERSION}|snapshot|{device}|{created_at}|{device_name}").into_bytes()
}
/// The sync folder inside the folder the owner picked.
pub fn sync_dir(chosen: &Path) -> PathBuf {
chosen.join(FOLDER_NAME)
}
pub fn read_meta(dir: &Path) -> Result<Option<SyncMeta>, String> {
let path = dir.join(META_FILE);
let Ok(raw) = fs::read(&path) else { return Ok(None) };
if raw.len() > 64 * 1024 {
return Err("SYNC: sync.json is too large".into());
}
let meta: SyncMeta = serde_json::from_slice(&raw).map_err(|_| "SYNC: sync.json is not a wayBetter sync file")?;
if meta.format != FORMAT || meta.version != VERSION || meta.kdf != "argon2id" || meta.cipher != "xchacha20poly1305" {
return Err("SYNC: this sync folder was made by an unknown version".into());
}
Ok(Some(meta))
}
/// Unlocks an existing sync folder, or sets one up (new salt, new check value) when it has none.
/// Returns the key and whether the folder was created now.
pub fn unlock_or_create(dir: &Path, passphrase: &str, params: KdfParams) -> Result<(Key, bool), String> {
if let Some(meta) = read_meta(dir)? {
let salt = B64.decode(&meta.salt).map_err(|_| "SYNC: damaged sync.json")?;
let key = derive_key(passphrase, &salt, &meta.params)?;
let check = open(&key, &meta.check, &meta_aad(&meta.params, &meta.salt)).map_err(|_| "SYNC: wrong passphrase for this sync folder".to_string())?;
if check != CHECK_PLAINTEXT {
return Err("SYNC: wrong passphrase for this sync folder".into());
}
return Ok((key, false));
}
let salt = random::<16>()?;
let salt_text = B64.encode(salt);
let key = derive_key(passphrase, &salt, &params)?;
let check = seal(&key, CHECK_PLAINTEXT, &meta_aad(&params, &salt_text))?;
let meta = SyncMeta {
format: FORMAT.into(),
version: VERSION,
kdf: "argon2id".into(),
params,
salt: salt_text,
cipher: "xchacha20poly1305".into(),
check,
};
fs::create_dir_all(dir.join(SNAPSHOT_DIR)).map_err(|error| format!("SYNC: {error}"))?;
write_atomic(&dir.join(META_FILE), &serde_json::to_vec_pretty(&meta).map_err(|_| "SYNC: serialization failed")?)?;
Ok((key, true))
}
/// Checks a remembered key against the folder's check value (the folder may have been reset).
pub fn key_matches(dir: &Path, key: &Key) -> bool {
matches!(read_meta(dir), Ok(Some(meta)) if open(key, &meta.check, &meta_aad(&meta.params, &meta.salt)).is_ok_and(|check| check == CHECK_PLAINTEXT))
}
fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
let temp = path.with_extension("partial");
fs::write(&temp, bytes).map_err(|error| format!("SYNC: {error}"))?;
fs::rename(&temp, path).map_err(|error| {
let _ = fs::remove_file(&temp);
format!("SYNC: {error}")
})
}
pub fn valid_device(device: &str) -> bool {
device.len() == 16 && device.bytes().all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
}
/// A new random device id (16 lowercase hex characters).
pub fn new_device_id() -> Result<String, String> {
Ok(random::<8>()?.iter().map(|byte| format!("{byte:02x}")).collect())
}
fn clean_device_name(name: &str) -> String {
let name: String = name.chars().filter(|ch| !ch.is_control()).take(40).collect();
let name = name.trim();
if name.is_empty() { "This computer".into() } else { name.into() }
}
fn snapshot_name(created_at: u64, device: &str) -> String {
format!("{created_at:013}-{device}{SNAPSHOT_SUFFIX}")
}
fn parse_name(name: &str) -> Option<(u64, String)> {
let stem = name.strip_suffix(SNAPSHOT_SUFFIX)?;
let (time, device) = stem.split_once('-')?;
if time.len() != 13 || !time.bytes().all(|b| b.is_ascii_digit()) || !valid_device(device) {
return None;
}
Some((time.parse().ok()?, device.to_owned()))
}
/// Seals `plaintext` into a new snapshot of `device` and deletes this device's oldest snapshots
/// beyond [`KEEP_PER_DEVICE`]. Returns the new snapshot's name.
pub fn write_snapshot(dir: &Path, key: &Key, device: &str, device_name: &str, created_at: u64, plaintext: &[u8]) -> Result<String, String> {
if !valid_device(device) {
return Err("SYNC: invalid device id".into());
}
if plaintext.len() > MAX_PLAINTEXT {
return Err("SYNC: the settings are too large to sync".into());
}
if !key_matches(dir, key) {
return Err("SYNC: the sync folder's passphrase changed; unlock it again".into());
}
let device_name = clean_device_name(device_name);
let sealed = seal(key, plaintext, &snapshot_aad(device, &device_name, created_at))?;
let snapshot = Snapshot { format: FORMAT.into(), version: VERSION, device: device.into(), device_name, created_at, sealed };
let folder = dir.join(SNAPSHOT_DIR);
fs::create_dir_all(&folder).map_err(|error| format!("SYNC: {error}"))?;
let name = snapshot_name(created_at, device);
write_atomic(&folder.join(&name), &serde_json::to_vec(&snapshot).map_err(|_| "SYNC: serialization failed")?)?;
prune(dir, device, KEEP_PER_DEVICE);
Ok(name)
}
/// Deletes all but the newest `keep` snapshots of `device` (never another device's).
pub fn prune(dir: &Path, device: &str, keep: usize) {
let mut own: Vec<SnapshotInfo> = list(dir).into_iter().filter(|info| info.device == device).collect();
own.sort_by(|a, b| b.created_at.cmp(&a.created_at));
for old in own.into_iter().skip(keep) {
let _ = fs::remove_file(dir.join(SNAPSHOT_DIR).join(old.name));
}
}
/// Snapshots in the folder, newest first. Reads only file names and the public header.
pub fn list(dir: &Path) -> Vec<SnapshotInfo> {
let Ok(entries) = fs::read_dir(dir.join(SNAPSHOT_DIR)) else { return Vec::new() };
let mut out: Vec<SnapshotInfo> = Vec::new();
for entry in entries.flatten() {
let Some(name) = entry.file_name().to_str().map(str::to_owned) else { continue };
let Some((created_at, device)) = parse_name(&name) else { continue };
let size = entry.metadata().map(|meta| meta.len()).unwrap_or(0);
if size == 0 || size as usize > MAX_PLAINTEXT * 2 {
continue;
}
// The device's display name is in the header; files are small enough to read once.
let device_name = fs::read(entry.path())
.ok()
.and_then(|raw| serde_json::from_slice::<Snapshot>(&raw).ok())
.filter(|snapshot| snapshot.device == device && snapshot.created_at == created_at)
.map(|snapshot| clean_device_name(&snapshot.device_name));
let Some(device_name) = device_name else { continue };
out.push(SnapshotInfo { name, device, device_name, created_at, size });
}
out.sort_by(|a, b| b.created_at.cmp(&a.created_at).then(a.name.cmp(&b.name)));
out
}
/// The newest snapshot written by another device after `after` (ms), if any.
pub fn newer_from_others(dir: &Path, device: &str, after: u64) -> Option<SnapshotInfo> {
list(dir).into_iter().find(|info| info.device != device && info.created_at > after)
}
/// What a snapshot holds: the settings sections as canonical JSON (sorted keys, so equal settings
/// give equal bytes), never drafts, with secret-looking keys removed.
pub fn settings_plaintext(sections: &serde_json::Map<String, serde_json::Value>) -> Vec<u8> {
let mut value = serde_json::Value::Object(sections.clone());
if let Some(map) = value.as_object_mut() {
map.remove("drafts");
}
crate::backup::strip_secrets(&mut value);
serde_json::to_vec(&value).unwrap_or_default()
}
/// Opens snapshot `name` (a bare file name from [`list`]).
pub fn read_snapshot(dir: &Path, key: &Key, name: &str) -> Result<(SnapshotInfo, Vec<u8>), String> {
let (created_at, device) = parse_name(name).ok_or("SYNC: invalid snapshot name")?;
let path = dir.join(SNAPSHOT_DIR).join(name);
let raw = fs::read(&path).map_err(|_| "SYNC: snapshot not found")?;
if raw.len() > MAX_PLAINTEXT * 2 {
return Err("SYNC: snapshot is too large".into());
}
let snapshot: Snapshot = serde_json::from_slice(&raw).map_err(|_| "SYNC: damaged snapshot")?;
if snapshot.format != FORMAT || snapshot.version != VERSION || snapshot.device != device || snapshot.created_at != created_at {
return Err("SYNC: damaged snapshot".into());
}
let plaintext = open(key, &snapshot.sealed, &snapshot_aad(&snapshot.device, &snapshot.device_name, snapshot.created_at))?;
let info = SnapshotInfo { name: name.into(), device, device_name: clean_device_name(&snapshot.device_name), created_at, size: raw.len() as u64 };
Ok((info, plaintext))
}
#[cfg(test)]
mod tests {
use super::*;
/// Cheap parameters so debug-build tests stay fast (production uses the defaults).
const FAST: KdfParams = KdfParams { memory_kib: 8 * 1024, iterations: 1, parallelism: 1 };
#[test]
fn snapshots_never_carry_drafts_or_secrets_and_are_canonical() {
let sections: serde_json::Map<String, serde_json::Value> = serde_json::from_str(
r#"{ "settings": { "restoreTabs": true, "homeUrl": "https://x.com/home" }, "drafts": { "drafts": [{ "kind": "post", "text": "private" }] }, "accounts": { "authToken": "t" } }"#,
)
.unwrap();
let text = String::from_utf8(settings_plaintext(&sections)).unwrap();
assert!(!text.contains("private") && !text.contains("drafts") && !text.contains("authToken"));
assert_eq!(text, r#"{"accounts":{},"settings":{"homeUrl":"https://x.com/home","restoreTabs":true}}"#);
}
#[test]
fn crypto_round_trip_and_wrong_passphrase_fails() {
let key = derive_key("correct horse battery", b"0123456789abcdef", &FAST).unwrap();
let sealed = seal(&key, b"{\"themes\":1}", b"header").unwrap();
assert_eq!(open(&key, &sealed, b"header").unwrap(), b"{\"themes\":1}");
let wrong = derive_key("correct horse battery!", b"0123456789abcdef", &FAST).unwrap();
assert!(open(&wrong, &sealed, b"header").is_err(), "wrong passphrase");
assert!(open(&key, &sealed, b"other header").is_err(), "changed associated data");
let mut tampered = sealed.clone();
let mut bytes = B64.decode(&tampered.ciphertext).unwrap();
bytes[0] ^= 1;
tampered.ciphertext = B64.encode(bytes);
assert!(open(&key, &tampered, b"header").is_err(), "changed ciphertext");
// Fresh nonce every time: the same plaintext never seals to the same bytes.
assert_ne!(seal(&key, b"x", b"").unwrap(), seal(&key, b"x", b"").unwrap());
assert!(derive_key("short", b"0123456789abcdef", &FAST).is_err());
assert!(format!("{key:?}") == "Key(…)", "keys never print");
}
#[test]
fn folder_setup_unlock_and_wrong_passphrase_refused() {
let root = tempfile::tempdir().unwrap();
let dir = sync_dir(root.path());
let (key, created) = unlock_or_create(&dir, "a long passphrase", FAST).unwrap();
assert!(created);
let meta = std::fs::read_to_string(dir.join(META_FILE)).unwrap();
assert!(!meta.contains("a long passphrase") && !meta.contains(&key.to_base64()), "sync.json holds no secret");
let (again, created) = unlock_or_create(&dir, "a long passphrase", FAST).unwrap();
assert!(!created);
assert_eq!(again.to_base64(), key.to_base64(), "same salt, same key on every machine");
let error = unlock_or_create(&dir, "another passphrase", FAST).unwrap_err();
assert!(error.contains("wrong passphrase"), "{error}");
// A hostile sync.json cannot ask for absurd costs.
let mut hostile: SyncMeta = serde_json::from_str(&meta).unwrap();
hostile.params.memory_kib = 64 * 1024 * 1024;
std::fs::write(dir.join(META_FILE), serde_json::to_vec(&hostile).unwrap()).unwrap();
assert!(unlock_or_create(&dir, "a long passphrase", FAST).unwrap_err().contains("unsupported"));
}
#[test]
fn snapshots_are_versioned_per_device_and_pulled_only_when_newer_elsewhere() {
let root = tempfile::tempdir().unwrap();
let dir = sync_dir(root.path());
let (key, _) = unlock_or_create(&dir, "a long passphrase", FAST).unwrap();
let (laptop, desktop) = ("0123456789abcdef", "fedcba9876543210");
for n in 0..(KEEP_PER_DEVICE as u64 + 3) {
write_snapshot(&dir, &key, laptop, "Laptop", 1_700_000_000_000 + n, format!("v{n}").as_bytes()).unwrap();
}
assert_eq!(list(&dir).len(), KEEP_PER_DEVICE, "old snapshots of this device are pruned");
let name = write_snapshot(&dir, &key, desktop, "Desk\u{7}top", 1_800_000_000_000, b"from desktop").unwrap();
assert_eq!(list(&dir).len(), KEEP_PER_DEVICE + 1, "another device's snapshots are kept");
let newer = newer_from_others(&dir, laptop, 0).unwrap();
assert_eq!((newer.name.as_str(), newer.device_name.as_str()), (name.as_str(), "Desktop"));
assert!(newer_from_others(&dir, laptop, 1_800_000_000_000).is_none(), "already pulled");
assert!(newer_from_others(&dir, desktop, 1_800_000_000_000).is_none(), "own snapshots are never offered");
let (info, plaintext) = read_snapshot(&dir, &key, &name).unwrap();
assert_eq!((info.device.as_str(), plaintext.as_slice()), (desktop, b"from desktop".as_slice()));
let wrong = derive_key("wrong passphrase!", b"0123456789abcdef", &FAST).unwrap();
assert!(read_snapshot(&dir, &wrong, &name).is_err());
assert!(write_snapshot(&dir, &wrong, laptop, "Laptop", 1_900_000_000_000, b"x").is_err(), "never writes with a key the folder does not accept");
assert!(read_snapshot(&dir, &key, "../sync.json").is_err());
// A snapshot whose header was edited (e.g. renamed device) no longer opens.
let path = dir.join(SNAPSHOT_DIR).join(&name);
let edited = std::fs::read_to_string(&path).unwrap().replace("Desktop", "Evil");
std::fs::write(&path, edited).unwrap();
assert!(read_snapshot(&dir, &key, &name).is_err());
}
}