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:
parent
3416e7f1e2
commit
59e7bffeb9
5 changed files with 885 additions and 0 deletions
290
crates/waybetter-host/src/backup.rs
Normal file
290
crates/waybetter-host/src/backup.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
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