feat: encrypted profile sync through a shared folder
Profiles can be kept in step across machines through any folder they share (cloud drive, network share, git checkout): one file, nexus-profiles.nexus, in the encrypted-export format under a sync password kept in the keychain/vault. Each sync merges profile by profile (newest updated_at wins), propagates deletions as dated tombstones (an edit after a deletion brings the profile back; tombstones expire after 180 days), validates every incoming profile, writes the file only when it changed, and never syncs credentials. Settings → Security → Profile sync picks the folder and sets the password; syncs run at launch, 3 s after a profile change, every 10 minutes and on Sync Now (automatic runs stay quiet unless something changed). An end-to-end test drives create, edit and delete between two data directories sharing a folder.
This commit is contained in:
@@ -36,16 +36,33 @@ pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) ->
|
||||
profiles: profiles.to_vec(),
|
||||
count: profiles.len(),
|
||||
};
|
||||
let plain = serde_json::to_vec_pretty(&bundle)?;
|
||||
crate::secrets::write_private(path, &seal(&serde_json::to_vec_pretty(&bundle)?, password)?)
|
||||
}
|
||||
|
||||
/// `[version][salt 32][nonce 12][AES-256-GCM ciphertext]` under an Argon2id key from `password`.
|
||||
pub fn seal(plain: &[u8], password: &str) -> Result<Vec<u8>> {
|
||||
let salt = random_bytes::<32>();
|
||||
let nonce = random_bytes::<12>();
|
||||
let key = derive_key(password, &salt)?;
|
||||
let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain.as_slice()).map_err(|e| e.to_string())?;
|
||||
let ct = Aes256Gcm::new((&*key).into()).encrypt(Nonce::from_slice(&nonce), plain).map_err(|e| e.to_string())?;
|
||||
let mut out = vec![VERSION];
|
||||
out.extend(salt);
|
||||
out.extend(nonce);
|
||||
out.extend(ct);
|
||||
crate::secrets::write_private(path, &out)
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn open(data: &[u8], password: &str) -> Result<Vec<u8>> {
|
||||
if data.len() < 1 + 32 + 12 + 1 + 16 {
|
||||
return Err("exportimport: file is too small or corrupt".into());
|
||||
}
|
||||
if data[0] != VERSION {
|
||||
return Err(format!("exportimport: unsupported format version {}", data[0]).into());
|
||||
}
|
||||
let key = derive_key(password, &data[1..33])?;
|
||||
Ok(Aes256Gcm::new((&*key).into())
|
||||
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
|
||||
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?)
|
||||
}
|
||||
|
||||
pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
|
||||
@@ -55,17 +72,7 @@ pub fn decrypt_and_import(path: &Path, password: &str) -> Result<Vec<Profile>> {
|
||||
if std::fs::metadata(path)?.len() > MAX_IMPORT_BYTES {
|
||||
return Err("exportimport: file is too large (max 10 MiB)".into());
|
||||
}
|
||||
let data = std::fs::read(path)?;
|
||||
if data.len() < 1 + 32 + 12 + 1 + 16 {
|
||||
return Err("exportimport: file is too small or corrupt".into());
|
||||
}
|
||||
if data[0] != VERSION {
|
||||
return Err(format!("exportimport: unsupported format version {}", data[0]).into());
|
||||
}
|
||||
let key = derive_key(password, &data[1..33])?;
|
||||
let plain = Aes256Gcm::new((&*key).into())
|
||||
.decrypt(Nonce::from_slice(&data[33..45]), &data[45..])
|
||||
.map_err(|_| "exportimport: decryption failed — wrong password or corrupt file")?;
|
||||
let plain = open(&std::fs::read(path)?, password)?;
|
||||
let bundle: Bundle = serde_json::from_slice(&plain).map_err(|e| format!("exportimport: invalid bundle JSON: {e}"))?;
|
||||
Ok(bundle.profiles)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ pub mod service;
|
||||
pub mod sessions;
|
||||
pub mod sshconfig;
|
||||
pub mod storage;
|
||||
pub mod sync;
|
||||
pub mod terminalenv;
|
||||
pub mod updater;
|
||||
pub mod validation;
|
||||
|
||||
@@ -628,6 +628,8 @@ pub struct AppPreferences {
|
||||
pub confirm_multiline_paste: bool,
|
||||
/// Reopen the previous session's tabs and splits at launch.
|
||||
pub restore_sessions: bool,
|
||||
/// Folder holding the encrypted profile sync file (empty: sync off).
|
||||
pub sync_folder: String,
|
||||
}
|
||||
|
||||
impl Default for AppPreferences {
|
||||
@@ -650,6 +652,7 @@ impl Default for AppPreferences {
|
||||
allow_remote_clipboard: true,
|
||||
confirm_multiline_paste: true,
|
||||
restore_sessions: true,
|
||||
sync_folder: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,23 @@ pub const VERSION: &str = match option_env!("NEXUS_VERSION") {
|
||||
};
|
||||
const MAX_IMPORT_JSON_BYTES: usize = 5 * 1024 * 1024;
|
||||
|
||||
/// Secret-store key of the profile-sync password.
|
||||
const SYNC_PASSWORD: &str = "sync:password";
|
||||
/// Setting holding deleted profile ids and when, for sync.
|
||||
const SYNC_DELETED: &str = "sync_deleted";
|
||||
|
||||
#[derive(Debug, Default, Clone, PartialEq)]
|
||||
pub struct SyncReport {
|
||||
/// Profiles created or updated from the shared file.
|
||||
pub pulled: usize,
|
||||
/// Profiles deleted because another machine deleted them.
|
||||
pub removed: usize,
|
||||
/// Incoming profiles that failed validation.
|
||||
pub skipped: usize,
|
||||
/// Whether the shared file was rewritten.
|
||||
pub pushed: bool,
|
||||
}
|
||||
|
||||
pub struct Service {
|
||||
pub config: AppConfig,
|
||||
pub store: Arc<Store>,
|
||||
@@ -72,6 +89,10 @@ impl Service {
|
||||
let p = self.store.get_profile(id).map_err(|e| format!("profile not found: {e}"))?;
|
||||
self.store.delete_profile(id).map_err(|e| format!("failed to delete profile: {e}"))?;
|
||||
self.secrets.delete_profile(id);
|
||||
// So other machines syncing the same folder delete it too.
|
||||
let mut gone = self.sync_tombstones();
|
||||
gone.insert(id.to_string(), now());
|
||||
let _ = self.store.set_setting(SYNC_DELETED, &serde_json::to_string(&gone).unwrap_or_default());
|
||||
self.store.add_audit("profile.delete", id, "", &format!("Deleted profile {:?}", p.display_name));
|
||||
Ok(())
|
||||
}
|
||||
@@ -88,6 +109,65 @@ impl Service {
|
||||
Ok(d)
|
||||
}
|
||||
|
||||
fn sync_tombstones(&self) -> std::collections::BTreeMap<String, Time> {
|
||||
self.store.setting(SYNC_DELETED).and_then(|j| serde_json::from_str(&j).ok()).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Sets (or, when empty, forgets) the profile-sync password.
|
||||
pub fn set_sync_password(&self, password: &str) -> Result<()> {
|
||||
if password.is_empty() {
|
||||
self.secrets.delete(SYNC_PASSWORD);
|
||||
return Ok(());
|
||||
}
|
||||
self.secrets.set_checked(SYNC_PASSWORD, password)
|
||||
}
|
||||
|
||||
pub fn has_sync_password(&self) -> bool {
|
||||
self.secrets.get(SYNC_PASSWORD).is_some()
|
||||
}
|
||||
|
||||
/// Merges profiles with the shared sync file; see [`crate::sync`].
|
||||
pub fn sync_profiles(&self) -> Result<SyncReport> {
|
||||
let folder = self.store.preferences().sync_folder;
|
||||
if folder.trim().is_empty() {
|
||||
return Err("profile sync isn't set up (choose a folder in Settings)".into());
|
||||
}
|
||||
let folder = PathBuf::from(folder.trim());
|
||||
if !folder.is_dir() {
|
||||
return Err(format!("sync folder {} isn't available", folder.display()).into());
|
||||
}
|
||||
let password = self.secrets.get(SYNC_PASSWORD).ok_or("no sync password is stored (set one in Settings)")?;
|
||||
let remote = crate::sync::read(&folder, &password)?.unwrap_or_default();
|
||||
let m = crate::sync::merge(&self.profiles(), &self.sync_tombstones(), &remote, now());
|
||||
let mut report = SyncReport::default();
|
||||
for mut p in m.upsert {
|
||||
// The file is ours, but it crossed a shared folder: validate what comes in.
|
||||
p.normalize();
|
||||
if validation::validate_profile(&p).is_err() {
|
||||
report.skipped += 1;
|
||||
continue;
|
||||
}
|
||||
let r = if self.store.get_profile(&p.id).is_ok() { self.store.update_profile(&p) } else { self.store.create_profile(&p) };
|
||||
match r {
|
||||
Ok(()) => report.pulled += 1,
|
||||
Err(_) => report.skipped += 1,
|
||||
}
|
||||
}
|
||||
for id in &m.delete {
|
||||
if self.store.delete_profile(id).is_ok() {
|
||||
self.secrets.delete_profile(id);
|
||||
report.removed += 1;
|
||||
}
|
||||
}
|
||||
if m.file != remote {
|
||||
crate::sync::write(&folder, &password, &m.file)?;
|
||||
report.pushed = true;
|
||||
}
|
||||
self.store.set_setting(SYNC_DELETED, &serde_json::to_string(&m.file.deleted)?)?;
|
||||
self.store.add_audit("sync", "", "", &format!("Profile sync: {} in, {} removed, wrote file: {}", report.pulled, report.removed, report.pushed));
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
pub fn profiles(&self) -> Vec<Profile> {
|
||||
self.store.list_profiles().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
//! Profile sync through a folder the user picks (a cloud drive, a network share, a git checkout):
|
||||
//! one encrypted file, `nexus-profiles.nexus`, in the export format (Argon2id + AES-256-GCM) under
|
||||
//! a sync password. Each side merges profile by profile — the newer `updated_at` wins — and
|
||||
//! deletions travel as dated tombstones, so a profile removed on one machine goes away on the
|
||||
//! others. Credentials are never synced: they live in each machine's keychain or vault.
|
||||
|
||||
use crate::Result;
|
||||
use crate::models::{Profile, Time};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
|
||||
pub const FILE_NAME: &str = "nexus-profiles.nexus";
|
||||
/// Tombstones older than this are forgotten (every machine has had time to see them).
|
||||
const TOMBSTONE_DAYS: i64 = 180;
|
||||
const MAX_FILE: u64 = 20 << 20;
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, Debug, Clone, PartialEq)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncFile {
|
||||
pub version: i64,
|
||||
pub profiles: Vec<Profile>,
|
||||
/// Deleted profile id → when.
|
||||
#[serde(default)]
|
||||
pub deleted: BTreeMap<String, Time>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, PartialEq)]
|
||||
pub struct Merged {
|
||||
/// Profiles to create or update locally.
|
||||
pub upsert: Vec<Profile>,
|
||||
/// Local profiles to delete.
|
||||
pub delete: Vec<String>,
|
||||
/// What the shared file should now hold.
|
||||
pub file: SyncFile,
|
||||
}
|
||||
|
||||
/// Merges local state with the shared file.
|
||||
pub fn merge(local: &[Profile], local_deleted: &BTreeMap<String, Time>, remote: &SyncFile, now: Time) -> Merged {
|
||||
let mut deleted = remote.deleted.clone();
|
||||
for (id, t) in local_deleted {
|
||||
let e = deleted.entry(id.clone()).or_insert(*t);
|
||||
*e = (*e).max(*t);
|
||||
}
|
||||
deleted.retain(|_, t| (now - *t).num_days() < TOMBSTONE_DAYS);
|
||||
|
||||
let remote_by: HashMap<&str, &Profile> = remote.profiles.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||
let local_by: HashMap<&str, &Profile> = local.iter().map(|p| (p.id.as_str(), p)).collect();
|
||||
let mut out = Merged::default();
|
||||
let mut ids: Vec<&str> = local_by.keys().chain(remote_by.keys()).copied().collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
for id in ids {
|
||||
let winner = match (local_by.get(id), remote_by.get(id)) {
|
||||
(Some(l), Some(r)) => {
|
||||
if r.updated_at > l.updated_at {
|
||||
out.upsert.push((*r).clone());
|
||||
*r
|
||||
} else {
|
||||
*l
|
||||
}
|
||||
}
|
||||
(Some(l), None) => *l,
|
||||
(None, Some(r)) => *r,
|
||||
(None, None) => continue,
|
||||
};
|
||||
// A deletion newer than the last edit wins; an edit after the deletion brings it back.
|
||||
if deleted.get(id).is_some_and(|t| *t >= winner.updated_at) {
|
||||
if local_by.contains_key(id) {
|
||||
out.delete.push(id.to_string());
|
||||
}
|
||||
out.upsert.retain(|p| p.id != id);
|
||||
continue;
|
||||
}
|
||||
deleted.remove(id);
|
||||
if !local_by.contains_key(id) {
|
||||
out.upsert.push(winner.clone());
|
||||
}
|
||||
out.file.profiles.push(winner.clone());
|
||||
}
|
||||
out.file.version = 1;
|
||||
out.file.deleted = deleted;
|
||||
out
|
||||
}
|
||||
|
||||
pub fn read(folder: &Path, password: &str) -> Result<Option<SyncFile>> {
|
||||
let path = folder.join(FILE_NAME);
|
||||
let Ok(meta) = std::fs::metadata(&path) else { return Ok(None) };
|
||||
if meta.len() > MAX_FILE {
|
||||
return Err("sync file is too large".into());
|
||||
}
|
||||
let plain = crate::exportimport::open(&std::fs::read(&path)?, password).map_err(|_| "the sync password doesn't open the sync file (or it is damaged)")?;
|
||||
Ok(Some(serde_json::from_slice(&plain).map_err(|e| format!("sync file isn't readable: {e}"))?))
|
||||
}
|
||||
|
||||
pub fn write(folder: &Path, password: &str, file: &SyncFile) -> Result<()> {
|
||||
crate::secrets::write_private(&folder.join(FILE_NAME), &crate::exportimport::seal(&serde_json::to_vec(file)?, password)?)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::models::Protocol;
|
||||
|
||||
fn p(id: &str, host: &str, t: i64) -> Profile {
|
||||
let mut p = Profile::new(Protocol::Ssh);
|
||||
p.id = id.into();
|
||||
p.host = host.into();
|
||||
p.updated_at = chrono::DateTime::from_timestamp(t, 0).unwrap().fixed_offset();
|
||||
p
|
||||
}
|
||||
fn at(t: i64) -> Time {
|
||||
chrono::DateTime::from_timestamp(t, 0).unwrap().fixed_offset()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn newest_wins_and_deletions_travel() {
|
||||
let now = at(1_000_000);
|
||||
let local = vec![p("a", "a-local-new", 200), p("b", "b-local-old", 100), p("c", "only-local", 100), p("d", "deleted-remotely", 100)];
|
||||
let remote = SyncFile {
|
||||
version: 1,
|
||||
profiles: vec![p("a", "a-remote-old", 150), p("b", "b-remote-new", 300), p("e", "only-remote", 100), p("f", "deleted-locally", 100)],
|
||||
deleted: [("d".to_string(), at(150))].into(),
|
||||
};
|
||||
let local_deleted = [("f".to_string(), at(120))].into();
|
||||
let m = merge(&local, &local_deleted, &remote, now);
|
||||
let hosts = |v: &[Profile]| v.iter().map(|p| p.host.clone()).collect::<Vec<_>>();
|
||||
assert_eq!(hosts(&m.upsert), ["b-remote-new", "only-remote"]);
|
||||
assert_eq!(m.delete, ["d"]);
|
||||
assert_eq!(hosts(&m.file.profiles), ["a-local-new", "b-remote-new", "only-local", "only-remote"]);
|
||||
assert_eq!(m.file.deleted.keys().collect::<Vec<_>>(), ["d", "f"]);
|
||||
// An edit after a deletion brings the profile back (and clears the tombstone).
|
||||
let remote = SyncFile { version: 1, profiles: vec![], deleted: [("x".to_string(), at(100))].into() };
|
||||
let m = merge(&[p("x", "edited-later", 200)], &BTreeMap::new(), &remote, now);
|
||||
assert!(m.delete.is_empty() && m.file.deleted.is_empty() && m.file.profiles.len() == 1);
|
||||
// Old tombstones are forgotten.
|
||||
let remote = SyncFile { version: 1, profiles: vec![], deleted: [("old".to_string(), at(0))].into() };
|
||||
assert!(merge(&[], &BTreeMap::new(), &remote, at(TOMBSTONE_DAYS * 86_400 + 10)).file.deleted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_round_trips_under_the_password() {
|
||||
let dir = std::env::temp_dir().join(format!("nexus-sync-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let f = SyncFile { version: 1, profiles: vec![p("a", "h", 1)], deleted: BTreeMap::new() };
|
||||
write(&dir, "pw", &f).unwrap();
|
||||
assert_eq!(read(&dir, "pw").unwrap().unwrap().profiles[0].host, "h");
|
||||
assert!(read(&dir, "wrong").is_err());
|
||||
assert!(read(&dir.join("missing"), "pw").unwrap().is_none());
|
||||
std::fs::remove_dir_all(dir).unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
//! Profile sync between two "machines" (data directories) sharing one folder.
|
||||
|
||||
use nexus_core::config::AppConfig;
|
||||
use nexus_core::models::{Profile, Protocol};
|
||||
use nexus_core::service::Service;
|
||||
|
||||
fn machine(root: &std::path::Path, name: &str, share: &std::path::Path) -> Service {
|
||||
let cfg = AppConfig::in_dir(root.join(name));
|
||||
std::fs::create_dir_all(&cfg.vault_dir).unwrap();
|
||||
let svc = Service::open(cfg).unwrap();
|
||||
svc.secrets.vault.initialise("master").unwrap();
|
||||
let mut prefs = svc.preferences();
|
||||
prefs.sync_folder = share.to_string_lossy().into_owned();
|
||||
svc.save_preferences(&prefs).unwrap();
|
||||
svc.set_sync_password("shared-secret").unwrap();
|
||||
svc
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profiles_follow_edits_and_deletes_across_machines() {
|
||||
let root = std::env::temp_dir().join(format!("nexus-sync-e2e-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let share = root.join("share");
|
||||
std::fs::create_dir_all(&share).unwrap();
|
||||
let (a, b) = (machine(&root, "a", &share), machine(&root, "b", &share));
|
||||
|
||||
let mut p = Profile::new(Protocol::Ssh);
|
||||
p.display_name = "web".into();
|
||||
p.host = "web.example".into();
|
||||
p.username = "me".into();
|
||||
let p = a.create_profile(p).unwrap();
|
||||
assert!(a.sync_profiles().unwrap().pushed);
|
||||
assert_eq!(b.sync_profiles().unwrap().pulled, 1);
|
||||
assert_eq!(b.profiles()[0].host, "web.example");
|
||||
|
||||
std::thread::sleep(std::time::Duration::from_millis(1100)); // updated_at has 1 s resolution
|
||||
let mut edited = b.profiles()[0].clone();
|
||||
edited.host = "web2.example".into();
|
||||
b.update_profile(edited).unwrap();
|
||||
b.sync_profiles().unwrap();
|
||||
assert_eq!(a.sync_profiles().unwrap().pulled, 1);
|
||||
assert_eq!(a.profiles()[0].host, "web2.example");
|
||||
|
||||
a.delete_profile(&p.id).unwrap();
|
||||
a.sync_profiles().unwrap();
|
||||
assert_eq!(b.sync_profiles().unwrap().removed, 1);
|
||||
assert!(b.profiles().is_empty());
|
||||
// Nothing left to do on either side.
|
||||
assert!(!a.sync_profiles().unwrap().pushed && !b.sync_profiles().unwrap().pushed);
|
||||
let _ = std::fs::remove_dir_all(root);
|
||||
}
|
||||
Reference in New Issue
Block a user