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.
This commit is contained in:
CRTkafa 2026-09-29 10:58:15 +07:00
parent 3416e7f1e2
commit 59e7bffeb9
5 changed files with 885 additions and 0 deletions

View file

@ -0,0 +1,290 @@
//! Settings backup: the versioned `.waybetter` JSON envelope, its import-side checks and the
//! sections every wayBetter app has (plugins, custom CSS, the account list). The app registers the
//! rest: which sections exist ([`parse_backup`] keeps only those) and how its own files map to
//! them. Pure (an app-data directory in, JSON out), unit-tested headless.
//!
//! Never included by these helpers: WebView profile folders (cookies, site sessions, local
//! storage), signing or sync keys, update state. [`strip_secrets`] removes secret-looking keys
//! from whatever is exported or imported as a second line of defence.
use crate::plugins::{valid_file_name, MAX_PLUGIN_BYTES};
use crate::profiles::{DEFAULT_COLOR, DEFAULT_PROFILE};
use serde::{Deserialize, Serialize};
use serde_json::{json, Map, Value};
use std::fs;
use std::path::Path;
pub const FORMAT: &str = "waybetter-backup";
pub const VERSION: u32 = 1;
pub const EXTENSION: &str = "waybetter";
/// Largest backup file accepted for import.
pub const MAX_BACKUP_BYTES: usize = 32 * 1024 * 1024;
pub const MAX_PLUGIN_FILES: usize = 64;
/// Largest JSON settings file a backup reads.
pub const MAX_JSON_FILE: usize = 8 * 1024 * 1024;
/// Keys that never leave (or enter) the app through a backup, compared case-insensitively with
/// `_`/`-` removed.
const SECRET_KEYS: [&str; 22] = [
"cookie", "cookies", "setcookie", "token", "tokens", "accesstoken", "authtoken", "refreshtoken", "idtoken", "ct0", "password",
"passphrase", "secret", "clientsecret", "session", "sessionid", "authorization", "bearer", "csrf", "csrftoken", "privatekey",
"signingkey",
];
fn secret_key(key: &str) -> bool {
let folded: String = key.chars().filter(|ch| *ch != '_' && *ch != '-').flat_map(char::to_lowercase).collect();
SECRET_KEYS.contains(&folded.as_str()) || folded.ends_with("secret") || folded.ends_with("token") || folded.ends_with("password")
}
/// Removes secret-looking keys anywhere in `value`; returns their JSON paths.
pub fn strip_secrets(value: &mut Value) -> Vec<String> {
fn walk(value: &mut Value, path: &str, removed: &mut Vec<String>) {
match value {
Value::Object(map) => {
let keys: Vec<String> = map.keys().cloned().collect();
for key in keys {
if secret_key(&key) {
map.remove(&key);
removed.push(format!("{path}.{key}"));
} else if let Some(child) = map.get_mut(&key) {
walk(child, &format!("{path}.{key}"), removed);
}
}
}
Value::Array(items) => {
for (index, item) in items.iter_mut().enumerate() {
walk(item, &format!("{path}[{index}]"), removed);
}
}
_ => {}
}
}
let mut removed = Vec::new();
walk(value, "$", &mut removed);
removed
}
/// A JSON settings file, if it exists, parses and is at most [`MAX_JSON_FILE`].
pub fn read_json_file(path: &Path) -> Option<Value> {
let raw = fs::read(path).ok()?;
if raw.len() > MAX_JSON_FILE {
return None;
}
serde_json::from_slice(&raw).ok()
}
/// A JSON object settings file, or `default` when it is missing, damaged or not an object.
pub fn object_section(path: &Path, default: Value) -> Value {
read_json_file(path).filter(Value::is_object).unwrap_or(default)
}
/// A plugin file of the owner's: `file` + `source`.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub struct PluginFile {
pub file: String,
pub source: String,
}
/// The `plugins` section (enabled states + the owner's own plugin files) and the `customCss`
/// section, read from the plugin store folder. `shipped` are the plugin files the app writes
/// itself (bundled + example): untouched copies of those are left out.
pub fn plugin_sections(plugins_dir: &Path, shipped: &[(&str, &str)]) -> (Value, Value) {
let enabled: Vec<String> = read_json_file(&plugins_dir.join("enabled.json"))
.and_then(|value| serde_json::from_value::<Vec<String>>(value).ok())
.unwrap_or_default()
.into_iter()
.filter(|file| valid_file_name(file))
.collect();
let mut files: Vec<PluginFile> = Vec::new();
if let Ok(entries) = fs::read_dir(plugins_dir) {
let mut names: Vec<String> = entries.flatten().filter_map(|entry| entry.file_name().to_str().map(str::to_owned)).collect();
names.sort();
for file in names {
if !valid_file_name(&file) || files.len() >= MAX_PLUGIN_FILES {
continue;
}
let path = plugins_dir.join(&file);
let Ok(meta) = fs::metadata(&path) else { continue };
if !meta.is_file() || meta.len() > MAX_PLUGIN_BYTES {
continue;
}
let Ok(source) = fs::read_to_string(&path) else { continue };
if shipped.iter().any(|(name, content)| *name == file && *content == source) {
continue;
}
files.push(PluginFile { file, source });
}
}
let css = fs::read_to_string(plugins_dir.join("custom.css")).unwrap_or_default();
(json!({ "enabled": enabled, "files": files }), json!({ "css": css }))
}
/// The `accounts` section from the profiles file: profile ids, names and colors only. The site
/// account a profile is signed in to is observed by the tabs (never imported), and its login is
/// the WebView profile folder, which a backup never reads.
pub fn accounts_section(profiles_file: &Path) -> Value {
let profiles = read_json_file(profiles_file).unwrap_or(Value::Null);
let list: Vec<Value> = profiles
.get("profiles")
.and_then(Value::as_array)
.map(|items| {
items
.iter()
.filter_map(|profile| {
Some(json!({
"id": profile.get("id")?.as_str()?,
"name": profile.get("name").and_then(Value::as_str).unwrap_or(""),
"color": profile.get("color").and_then(Value::as_str).unwrap_or(DEFAULT_COLOR),
}))
})
.collect()
})
.unwrap_or_default();
let active = profiles.get("active").and_then(Value::as_str).unwrap_or(DEFAULT_PROFILE);
json!({ "profiles": list, "active": active })
}
/// Finishes collected sections: secret-looking keys removed anywhere.
pub fn scrubbed(sections: Map<String, Value>) -> Map<String, Value> {
let mut value = Value::Object(sections);
strip_secrets(&mut value);
match value {
Value::Object(map) => map,
_ => Map::new(),
}
}
/// Checks the envelope of a backup file and returns its sections (only the app's `sections`,
/// secret-looking keys removed).
pub fn parse_backup(bytes: &[u8], sections: &[&str]) -> Result<Map<String, Value>, String> {
if bytes.len() > MAX_BACKUP_BYTES {
return Err("BACKUP: the file is larger than 32 MB".into());
}
let mut value: Value = serde_json::from_slice(bytes).map_err(|error| format!("BACKUP: not a wayBetter backup (line {}: {error})", error.line()))?;
if value.get("format").and_then(Value::as_str) != Some(FORMAT) {
return Err("BACKUP: not a wayBetter backup".into());
}
let version = value.get("version").and_then(Value::as_u64).unwrap_or(0);
if version == 0 || version > u64::from(VERSION) {
return Err(format!("BACKUP: made by a newer wayBetter (backup version {version}); update the app first"));
}
strip_secrets(&mut value);
let found = value.get_mut("sections").and_then(Value::as_object_mut).ok_or("BACKUP: the backup has no sections")?;
found.retain(|key, _| sections.contains(&key.as_str()));
Ok(std::mem::take(found))
}
/// The plugins section of an import: file names checked, sizes capped, at most [`MAX_PLUGIN_FILES`].
pub fn plugins_section(value: &Value) -> Result<(Vec<String>, Vec<PluginFile>), String> {
let enabled: Vec<String> = serde_json::from_value(value.get("enabled").cloned().unwrap_or(json!([]))).map_err(|_| "BACKUP: invalid plugin list")?;
let files: Vec<PluginFile> = serde_json::from_value(value.get("files").cloned().unwrap_or(json!([]))).map_err(|_| "BACKUP: invalid plugin files")?;
if files.len() > MAX_PLUGIN_FILES || enabled.len() > 256 {
return Err("BACKUP: too many plugins".into());
}
for name in enabled.iter().chain(files.iter().map(|file| &file.file)) {
if !valid_file_name(name) {
return Err("BACKUP: invalid plugin file name".into());
}
}
if files.iter().any(|file| file.source.len() as u64 > MAX_PLUGIN_BYTES) {
return Err("BACKUP: a plugin file is larger than 2 MiB".into());
}
Ok((enabled, files))
}
/// Writes a file next to its final name and renames it over (a running tab never reads half).
pub fn write_atomic(path: &Path, bytes: &[u8]) -> Result<(), String> {
let temp = path.with_extension("importing");
fs::write(&temp, bytes).map_err(|error| format!("BACKUP: {error}"))?;
fs::rename(&temp, path).map_err(|error| {
let _ = fs::remove_file(&temp);
format!("BACKUP: {error}")
})
}
#[cfg(test)]
mod tests {
use super::*;
const SECTIONS: [&str; 3] = ["plugins", "customCss", "settings"];
fn write(dir: &Path, name: &str, text: &str) {
let path = dir.join(name);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
}
#[test]
fn secret_keys_are_stripped_anywhere() {
let mut value = json!({ "a": { "Auth-Token": "t", "list": [{ "password": "p", "keep": 1 }] }, "sessionId": "s", "ok": true });
let removed = strip_secrets(&mut value);
assert_eq!(value, json!({ "a": { "list": [{ "keep": 1 }] }, "ok": true }));
assert_eq!(removed.len(), 3);
for key in ["clientSecret", "api_token", "refresh-token", "Bearer", "ct0", "signingKey", "PASSPHRASE"] {
assert!(secret_key(key), "{key}");
}
assert!(!secret_key("tokenizer") && !secret_key("homeUrl"));
}
#[test]
fn plugins_css_and_accounts_sections() {
let root = tempfile::tempdir().unwrap();
let dir = root.path();
write(dir, "plugins/enabled.json", r#"["mine.plugin.js","../bad.plugin.js"]"#);
write(dir, "plugins/mine.plugin.js", "mine");
write(dir, "plugins/shipped.plugin.js", "as shipped");
write(dir, "plugins/notes.txt", "not a plugin");
write(dir, "plugins/custom.css", "a{}");
write(dir, "profiles.json", r##"{"profiles":[{"id":"default","name":"Main","account":{"handle":"h"}},{"id":"w","color":"#000000","authToken":"t"}]}"##);
let (plugins, css) = plugin_sections(&dir.join("plugins"), &[("shipped.plugin.js", "as shipped")]);
assert_eq!(plugins, json!({ "enabled": ["mine.plugin.js"], "files": [{ "file": "mine.plugin.js", "source": "mine" }] }));
assert_eq!(css, json!({ "css": "a{}" }));
assert_eq!(
accounts_section(&dir.join("profiles.json")),
json!({ "profiles": [{ "id": "default", "name": "Main", "color": DEFAULT_COLOR }, { "id": "w", "name": "", "color": "#000000" }], "active": "default" })
);
assert_eq!(object_section(&dir.join("missing.json"), json!({})), json!({}));
assert_eq!(object_section(&dir.join("plugins/enabled.json"), json!({ "d": 1 })), json!({ "d": 1 }), "not an object");
}
#[test]
fn envelope_keeps_only_the_apps_sections_and_no_secret() {
assert!(parse_backup(b"not json", &SECTIONS).unwrap_err().contains("line 1"));
assert!(parse_backup(br#"{"format":"other","version":1,"sections":{}}"#, &SECTIONS).is_err());
assert!(parse_backup(br#"{"format":"waybetter-backup","version":0,"sections":{}}"#, &SECTIONS).is_err());
assert!(parse_backup(br#"{"format":"waybetter-backup","version":99,"sections":{}}"#, &SECTIONS).unwrap_err().contains("newer"));
assert!(parse_backup(br#"{"format":"waybetter-backup","version":1}"#, &SECTIONS).unwrap_err().contains("no sections"));
assert!(parse_backup(&vec![b' '; MAX_BACKUP_BYTES + 1], &SECTIONS).unwrap_err().contains("32 MB"));
let file = json!({ "format": FORMAT, "version": VERSION, "sections": {
"settings": { "homeUrl": "https://example.com/", "token": "t" }, "other": {}
} });
let parsed = parse_backup(&serde_json::to_vec(&file).unwrap(), &SECTIONS).unwrap();
assert_eq!(Value::Object(parsed), json!({ "settings": { "homeUrl": "https://example.com/" } }));
let collected = scrubbed(json!({ "settings": { "password": "p", "a": 1 } }).as_object().unwrap().clone());
assert_eq!(Value::Object(collected), json!({ "settings": { "a": 1 } }));
}
#[test]
fn plugin_imports_are_checked() {
assert!(plugins_section(&json!({ "files": [{ "file": "../evil.plugin.js", "source": "" }] })).is_err());
assert!(plugins_section(&json!({ "enabled": ["C:\\x.plugin.js"] })).is_err());
assert!(plugins_section(&json!({ "files": [{ "file": "a.plugin.js", "source": "", "extra": 1 }] })).is_err());
let big = "a".repeat(MAX_PLUGIN_BYTES as usize + 1);
assert!(plugins_section(&json!({ "files": [{ "file": "a.plugin.js", "source": big }] })).is_err());
let many: Vec<Value> = (0..=MAX_PLUGIN_FILES).map(|n| json!({ "file": format!("p{n}.plugin.js"), "source": "" })).collect();
assert!(plugins_section(&json!({ "files": many })).is_err());
let (enabled, files) = plugins_section(&json!({ "enabled": ["a.plugin.js"], "files": [{ "file": "a.plugin.js", "source": "x" }] })).unwrap();
assert_eq!((enabled.len(), files.len()), (1, 1));
}
#[test]
fn atomic_write_replaces_the_file() {
let root = tempfile::tempdir().unwrap();
let path = root.path().join("settings.json");
fs::write(&path, "old").unwrap();
write_atomic(&path, b"new").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "new");
assert!(!root.path().join("settings.importing").exists());
}
}

View file

@ -15,9 +15,12 @@
//! - [`tabs`]: tab layout/state payloads, link routing and tab-chrome validation.
//! - [`layout`]: native placement bookkeeping (unchanged bounds skipped, window-resize follow).
//! - [`i18n`]: the strings native UI and site tabs get in the owner's language (validated data), OS locale.
//! - [`backup`]: the settings backup envelope, secret scrubbing and the plugin/account sections.
//! - [`sync`]: encrypted settings sync through a folder the owner syncs (Argon2id + XChaCha20-Poly1305).
//!
//! With the `tauri` feature, [`host`] adds the Tauri glue that does not depend on app state.
pub mod backup;
pub mod hotfix;
pub mod i18n;
pub mod layout;
@ -25,6 +28,7 @@ pub mod plugins;
pub mod policy;
pub mod profiles;
pub mod site;
pub mod sync;
pub mod tabs;
pub mod update;

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());
}
}