Files
nexus/rust/core/src/exportimport.rs
T
Andy Dixon 1c590f47ff 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.
2026-10-12 19:20:12 +01:00

96 lines
3.3 KiB
Rust

//! Password-encrypted profile exports: `[version 1][salt 32][nonce 12][AES-256-GCM ciphertext]`
//! over a JSON bundle, with the vault's Argon2id parameters.
use crate::Result;
use crate::models::Profile;
use crate::secrets::{derive_key, random_bytes};
use aes_gcm::aead::{Aead, KeyInit};
use aes_gcm::{Aes256Gcm, Nonce};
use serde::{Deserialize, Serialize};
use std::path::Path;
const VERSION: u8 = 1;
const MAX_IMPORT_BYTES: u64 = 10 * 1024 * 1024;
pub const EXTENSION: &str = "nexus";
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Bundle {
version: i64,
exported_at: String,
#[serde(default)]
profiles: Vec<Profile>,
count: usize,
}
pub fn encrypt_and_export(profiles: &[Profile], password: &str, path: &Path) -> Result<()> {
if password.is_empty() {
return Err("exportimport: password cannot be empty".into());
}
if profiles.is_empty() {
return Err("exportimport: no profiles to export".into());
}
let bundle = Bundle {
version: 1,
exported_at: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
profiles: profiles.to_vec(),
count: profiles.len(),
};
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).map_err(|e| e.to_string())?;
let mut out = vec![VERSION];
out.extend(salt);
out.extend(nonce);
out.extend(ct);
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>> {
if password.is_empty() {
return Err("exportimport: password cannot be empty".into());
}
if std::fs::metadata(path)?.len() > MAX_IMPORT_BYTES {
return Err("exportimport: file is too large (max 10 MiB)".into());
}
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)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips() {
let mut p = Profile::new(crate::models::Protocol::Ssh);
p.host = "box".into();
let ps = vec![p];
let out = std::env::temp_dir().join(format!("nexus-export-{}.nexus", std::process::id()));
encrypt_and_export(&ps, "pw", &out).unwrap();
assert!(decrypt_and_import(&out, "nope").is_err());
assert_eq!(decrypt_and_import(&out, "pw").unwrap(), ps);
std::fs::remove_file(out).unwrap();
}
}