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:
parent
d36e2d8709
commit
8ff27a629f
5 changed files with 885 additions and 0 deletions
434
crates/waybetter-host/src/sync.rs
Normal file
434
crates/waybetter-host/src/sync.rs
Normal 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, ¶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<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(§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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue