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:
Andy Dixon
2026-10-12 19:20:12 +01:00
parent 32890c6415
commit 1c590f47ff
11 changed files with 437 additions and 15 deletions
+21 -14
View File
@@ -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)
}