// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Andy Dixon //! 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, 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> { 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> { 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> { 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(); } }