//! 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 //! `/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 { // 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 { 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() -> 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 { 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 { 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, 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 { 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 { 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, 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, ¶ms)?; let check = seal(&key, CHECK_PLAINTEXT, &meta_aad(¶ms, &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 { 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 { 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 = 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 { let Ok(entries) = fs::read_dir(dir.join(SNAPSHOT_DIR)) else { return Vec::new() }; let mut out: Vec = 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::(&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 { 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) -> Vec { 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), 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 = 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(§ions)).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()); } }