use std::fs;
use std::io::Write;
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use anyhow::{Context, Result, anyhow, bail};
use base64::Engine;
use base64::engine::general_purpose::STANDARD as B64;
use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
use chacha20poly1305::{XChaCha20Poly1305, XNonce};
use rand_core::RngCore;
const KEY_LEN: usize = 32;
const NONCE_LEN: usize = 24;
const MAGIC_PLAINTEXT: u8 = 0x00;
const MAGIC_XCHACHA: u8 = 0x01;
static LEGACY_WARNED: AtomicBool = AtomicBool::new(false);
pub trait SyncEncryptor: Send + Sync {
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>>;
fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>>;
}
pub struct PassthroughEncryptor;
impl SyncEncryptor for PassthroughEncryptor {
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
Ok(plaintext.to_vec())
}
fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
Ok(ciphertext.to_vec())
}
}
pub struct XChaChaEncryptor {
key: [u8; KEY_LEN],
}
impl XChaChaEncryptor {
pub fn new(key: [u8; KEY_LEN]) -> Self {
Self { key }
}
pub fn key_base64(&self) -> String {
B64.encode(self.key)
}
fn cipher(&self) -> XChaCha20Poly1305 {
XChaCha20Poly1305::new((&self.key).into())
}
}
impl SyncEncryptor for XChaChaEncryptor {
fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
let mut nonce_bytes = [0u8; NONCE_LEN];
OsRng.fill_bytes(&mut nonce_bytes);
let nonce = XNonce::from_slice(&nonce_bytes);
let ct = self
.cipher()
.encrypt(nonce, plaintext)
.map_err(|e| anyhow!("xchacha20poly1305 encrypt failed: {e}"))?;
let mut framed = Vec::with_capacity(1 + NONCE_LEN + ct.len());
framed.push(MAGIC_XCHACHA);
framed.extend_from_slice(&nonce_bytes);
framed.extend_from_slice(&ct);
Ok(framed)
}
fn decrypt(&self, framed: &[u8]) -> Result<Vec<u8>> {
match framed.first() {
Some(&MAGIC_PLAINTEXT) | None => {
warn_legacy_once();
Ok(framed.get(1..).unwrap_or(&[]).to_vec())
}
Some(&MAGIC_XCHACHA) => {
if framed.len() < 1 + NONCE_LEN {
bail!("encrypted payload too short to contain a nonce");
}
let nonce = XNonce::from_slice(&framed[1..1 + NONCE_LEN]);
let ct = &framed[1 + NONCE_LEN..];
self.cipher()
.decrypt(nonce, ct)
.map_err(|e| anyhow!("xchacha20poly1305 decrypt failed (bad key or tampered payload): {e}"))
}
Some(other) => bail!("unknown sync payload magic byte: {other:#x}"),
}
}
}
pub fn seal_line(enc: &dyn SyncEncryptor, plaintext: &[u8]) -> Result<String> {
let framed = enc.encrypt(plaintext)?;
Ok(B64.encode(framed))
}
pub fn open_line(enc: &dyn SyncEncryptor, line: &str) -> Result<Vec<u8>> {
match B64.decode(line.trim()) {
Ok(framed) => enc.decrypt(&framed),
Err(_) => {
warn_legacy_once();
Ok(line.as_bytes().to_vec())
}
}
}
fn warn_legacy_once() {
if !LEGACY_WARNED.swap(true, Ordering::Relaxed) {
tracing::warn!(
"importing legacy plaintext sync payload; re-running sync will encrypt it going forward"
);
}
}
pub fn default_key_path() -> Result<PathBuf> {
let home = dirs::home_dir().context("cannot determine home directory")?;
Ok(home.join(".second-brain").join("sync.key"))
}
pub fn load_or_create_key(path: &Path) -> Result<[u8; KEY_LEN]> {
match fs::read_to_string(path) {
Ok(contents) => {
let trimmed = contents.trim();
let raw = B64
.decode(trimmed)
.context("decoding base64 sync key")?;
let key: [u8; KEY_LEN] = raw
.as_slice()
.try_into()
.map_err(|_| anyhow!("sync key must decode to {KEY_LEN} bytes, got {}", raw.len()))?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.context("tightening sync key permissions to 0600")?;
Ok(key)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let mut key = [0u8; KEY_LEN];
OsRng.fill_bytes(&mut key);
write_key(path, &key)?;
Ok(key)
}
Err(e) => Err(e).context("reading sync key"),
}
}
fn write_key(path: &Path, key: &[u8; KEY_LEN]) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).context("creating sync key parent dir")?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.mode(0o600)
.open(path)
.context("opening sync key for write")?;
file.write_all(B64.encode(key).as_bytes())
.context("writing sync key")?;
file.write_all(b"\n").context("writing sync key newline")?;
fs::set_permissions(path, fs::Permissions::from_mode(0o600))
.context("setting sync key mode 0600")?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_key(seed: u8) -> [u8; KEY_LEN] {
[seed; KEY_LEN]
}
#[test]
fn round_trip_returns_plaintext() {
let enc = XChaChaEncryptor::new(test_key(7));
let plaintext = b"sensitive memory line as jsonl";
let ct = enc.encrypt(plaintext).unwrap();
assert_ne!(ct.as_slice(), plaintext, "ciphertext must differ from plaintext");
let pt = enc.decrypt(&ct).unwrap();
assert_eq!(pt, plaintext);
}
#[test]
fn nonce_is_random_per_message() {
let enc = XChaChaEncryptor::new(test_key(3));
let a = enc.encrypt(b"same input").unwrap();
let b = enc.encrypt(b"same input").unwrap();
assert_ne!(a, b, "fresh random nonce must yield distinct ciphertext");
}
#[test]
fn decrypt_with_wrong_key_fails() {
let enc = XChaChaEncryptor::new(test_key(1));
let other = XChaChaEncryptor::new(test_key(2));
let ct = enc.encrypt(b"secret").unwrap();
assert!(
other.decrypt(&ct).is_err(),
"AEAD must reject a payload sealed under a different key"
);
}
#[test]
fn flipped_ciphertext_byte_fails() {
let enc = XChaChaEncryptor::new(test_key(9));
let mut ct = enc.encrypt(b"tamper me").unwrap();
let last = ct.len() - 1;
ct[last] ^= 0x01;
assert!(
enc.decrypt(&ct).is_err(),
"Poly1305 tag must reject a single flipped byte"
);
}
#[test]
fn legacy_plaintext_passes_through() {
let enc = XChaChaEncryptor::new(test_key(5));
let mut legacy = vec![MAGIC_PLAINTEXT];
legacy.extend_from_slice(b"{\"local_seq\":1}");
let out = enc.decrypt(&legacy).unwrap();
assert_eq!(out, b"{\"local_seq\":1}");
}
#[test]
fn empty_frame_decodes_as_empty_plaintext() {
let enc = XChaChaEncryptor::new(test_key(5));
let out = enc.decrypt(&[]).unwrap();
assert!(out.is_empty());
}
#[test]
fn seal_open_line_round_trips() {
let enc = XChaChaEncryptor::new(test_key(4));
let record = b"{\"local_seq\":42,\"op\":\"Create\"}";
let line = seal_line(&enc, record).unwrap();
assert!(!line.contains('\n'), "wire line must be single-line");
let out = open_line(&enc, &line).unwrap();
assert_eq!(out, record);
}
#[test]
fn open_line_passes_through_legacy_jsonl() {
let enc = XChaChaEncryptor::new(test_key(4));
let legacy = "{\"local_seq\":1,\"op\":\"Create\"}";
let out = open_line(&enc, legacy).unwrap();
assert_eq!(out, legacy.as_bytes());
}
#[test]
fn load_or_create_key_creates_0600_and_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("nested").join("sync.key");
let first = load_or_create_key(&path).unwrap();
let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "key file must be 0600");
let second = load_or_create_key(&path).unwrap();
assert_eq!(first, second, "second load must return the same persisted key");
}
}