second-brain-sync 0.5.1

Bidirectional sync for second-brain: SSH transport, JSONL change log, conflict resolution
Documentation
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() {
            // legacy payloads written before encryption was added carry no AEAD
            // frame, so pass them through and warn once that re-sync will encrypt.
            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}"),
        }
    }
}

/// Seal one plaintext JSONL record into a single base64 line safe for the
/// line-oriented SSH transport. base64 is required because the AEAD frame is
/// binary and the wire is newline-delimited text.
pub fn seal_line(enc: &dyn SyncEncryptor, plaintext: &[u8]) -> Result<String> {
    let framed = enc.encrypt(plaintext)?;
    Ok(B64.encode(framed))
}

/// Open one wire line back into the plaintext JSONL record. A line that is not
/// valid base64 is treated as a legacy plaintext JSONL record and returned as-is
/// (with a one-time warning), so logs written before encryption still import.
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"))
}

/// Load the 32-byte sync key from `path`, generating and persisting a fresh one
/// if absent. The file is written 0600 because the key grants full read of every
/// synced memory (safety: file perms must be 0600).
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()))?;
            // correct an over-permissive key file left by an earlier write or manual copy.
            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")?;
    // because OpenOptions::mode only applies on creation, enforce 0600 even when
    // truncating an existing file (safety: file perms must be 0600).
    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");
    }
}