Skip to main content

second_brain_sync/
crypto.rs

1use std::fs;
2use std::io::Write;
3use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
4use std::path::{Path, PathBuf};
5use std::sync::atomic::{AtomicBool, Ordering};
6
7use anyhow::{Context, Result, anyhow, bail};
8use base64::Engine;
9use base64::engine::general_purpose::STANDARD as B64;
10use chacha20poly1305::aead::{Aead, KeyInit, OsRng};
11use chacha20poly1305::{XChaCha20Poly1305, XNonce};
12use rand_core::RngCore;
13
14const KEY_LEN: usize = 32;
15const NONCE_LEN: usize = 24;
16
17const MAGIC_PLAINTEXT: u8 = 0x00;
18const MAGIC_XCHACHA: u8 = 0x01;
19
20static LEGACY_WARNED: AtomicBool = AtomicBool::new(false);
21
22pub trait SyncEncryptor: Send + Sync {
23    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>>;
24    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>>;
25}
26
27pub struct PassthroughEncryptor;
28
29impl SyncEncryptor for PassthroughEncryptor {
30    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
31        Ok(plaintext.to_vec())
32    }
33
34    fn decrypt(&self, ciphertext: &[u8]) -> Result<Vec<u8>> {
35        Ok(ciphertext.to_vec())
36    }
37}
38
39pub struct XChaChaEncryptor {
40    key: [u8; KEY_LEN],
41}
42
43impl XChaChaEncryptor {
44    pub fn new(key: [u8; KEY_LEN]) -> Self {
45        Self { key }
46    }
47
48    pub fn key_base64(&self) -> String {
49        B64.encode(self.key)
50    }
51
52    fn cipher(&self) -> XChaCha20Poly1305 {
53        XChaCha20Poly1305::new((&self.key).into())
54    }
55}
56
57impl SyncEncryptor for XChaChaEncryptor {
58    fn encrypt(&self, plaintext: &[u8]) -> Result<Vec<u8>> {
59        let mut nonce_bytes = [0u8; NONCE_LEN];
60        OsRng.fill_bytes(&mut nonce_bytes);
61        let nonce = XNonce::from_slice(&nonce_bytes);
62
63        let ct = self
64            .cipher()
65            .encrypt(nonce, plaintext)
66            .map_err(|e| anyhow!("xchacha20poly1305 encrypt failed: {e}"))?;
67
68        let mut framed = Vec::with_capacity(1 + NONCE_LEN + ct.len());
69        framed.push(MAGIC_XCHACHA);
70        framed.extend_from_slice(&nonce_bytes);
71        framed.extend_from_slice(&ct);
72        Ok(framed)
73    }
74
75    fn decrypt(&self, framed: &[u8]) -> Result<Vec<u8>> {
76        match framed.first() {
77            // legacy payloads written before encryption was added carry no AEAD
78            // frame, so pass them through and warn once that re-sync will encrypt.
79            Some(&MAGIC_PLAINTEXT) | None => {
80                warn_legacy_once();
81                Ok(framed.get(1..).unwrap_or(&[]).to_vec())
82            }
83            Some(&MAGIC_XCHACHA) => {
84                if framed.len() < 1 + NONCE_LEN {
85                    bail!("encrypted payload too short to contain a nonce");
86                }
87                let nonce = XNonce::from_slice(&framed[1..1 + NONCE_LEN]);
88                let ct = &framed[1 + NONCE_LEN..];
89                self.cipher()
90                    .decrypt(nonce, ct)
91                    .map_err(|e| anyhow!("xchacha20poly1305 decrypt failed (bad key or tampered payload): {e}"))
92            }
93            Some(other) => bail!("unknown sync payload magic byte: {other:#x}"),
94        }
95    }
96}
97
98/// Seal one plaintext JSONL record into a single base64 line safe for the
99/// line-oriented SSH transport. base64 is required because the AEAD frame is
100/// binary and the wire is newline-delimited text.
101pub fn seal_line(enc: &dyn SyncEncryptor, plaintext: &[u8]) -> Result<String> {
102    let framed = enc.encrypt(plaintext)?;
103    Ok(B64.encode(framed))
104}
105
106/// Open one wire line back into the plaintext JSONL record. A line that is not
107/// valid base64 is treated as a legacy plaintext JSONL record and returned as-is
108/// (with a one-time warning), so logs written before encryption still import.
109pub fn open_line(enc: &dyn SyncEncryptor, line: &str) -> Result<Vec<u8>> {
110    match B64.decode(line.trim()) {
111        Ok(framed) => enc.decrypt(&framed),
112        Err(_) => {
113            warn_legacy_once();
114            Ok(line.as_bytes().to_vec())
115        }
116    }
117}
118
119fn warn_legacy_once() {
120    if !LEGACY_WARNED.swap(true, Ordering::Relaxed) {
121        tracing::warn!(
122            "importing legacy plaintext sync payload; re-running sync will encrypt it going forward"
123        );
124    }
125}
126
127pub fn default_key_path() -> Result<PathBuf> {
128    let home = dirs::home_dir().context("cannot determine home directory")?;
129    Ok(home.join(".second-brain").join("sync.key"))
130}
131
132/// Load the 32-byte sync key from `path`, generating and persisting a fresh one
133/// if absent. The file is written 0600 because the key grants full read of every
134/// synced memory (safety: file perms must be 0600).
135pub fn load_or_create_key(path: &Path) -> Result<[u8; KEY_LEN]> {
136    match fs::read_to_string(path) {
137        Ok(contents) => {
138            let trimmed = contents.trim();
139            let raw = B64
140                .decode(trimmed)
141                .context("decoding base64 sync key")?;
142            let key: [u8; KEY_LEN] = raw
143                .as_slice()
144                .try_into()
145                .map_err(|_| anyhow!("sync key must decode to {KEY_LEN} bytes, got {}", raw.len()))?;
146            // correct an over-permissive key file left by an earlier write or manual copy.
147            fs::set_permissions(path, fs::Permissions::from_mode(0o600))
148                .context("tightening sync key permissions to 0600")?;
149            Ok(key)
150        }
151        Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
152            let mut key = [0u8; KEY_LEN];
153            OsRng.fill_bytes(&mut key);
154            write_key(path, &key)?;
155            Ok(key)
156        }
157        Err(e) => Err(e).context("reading sync key"),
158    }
159}
160
161fn write_key(path: &Path, key: &[u8; KEY_LEN]) -> Result<()> {
162    if let Some(parent) = path.parent()
163        && !parent.as_os_str().is_empty()
164    {
165        fs::create_dir_all(parent).context("creating sync key parent dir")?;
166    }
167    let mut file = fs::OpenOptions::new()
168        .create(true)
169        .write(true)
170        .truncate(true)
171        .mode(0o600)
172        .open(path)
173        .context("opening sync key for write")?;
174    file.write_all(B64.encode(key).as_bytes())
175        .context("writing sync key")?;
176    file.write_all(b"\n").context("writing sync key newline")?;
177    // because OpenOptions::mode only applies on creation, enforce 0600 even when
178    // truncating an existing file (safety: file perms must be 0600).
179    fs::set_permissions(path, fs::Permissions::from_mode(0o600))
180        .context("setting sync key mode 0600")?;
181    Ok(())
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn test_key(seed: u8) -> [u8; KEY_LEN] {
189        [seed; KEY_LEN]
190    }
191
192    #[test]
193    fn round_trip_returns_plaintext() {
194        let enc = XChaChaEncryptor::new(test_key(7));
195        let plaintext = b"sensitive memory line as jsonl";
196        let ct = enc.encrypt(plaintext).unwrap();
197        assert_ne!(ct.as_slice(), plaintext, "ciphertext must differ from plaintext");
198        let pt = enc.decrypt(&ct).unwrap();
199        assert_eq!(pt, plaintext);
200    }
201
202    #[test]
203    fn nonce_is_random_per_message() {
204        let enc = XChaChaEncryptor::new(test_key(3));
205        let a = enc.encrypt(b"same input").unwrap();
206        let b = enc.encrypt(b"same input").unwrap();
207        assert_ne!(a, b, "fresh random nonce must yield distinct ciphertext");
208    }
209
210    #[test]
211    fn decrypt_with_wrong_key_fails() {
212        let enc = XChaChaEncryptor::new(test_key(1));
213        let other = XChaChaEncryptor::new(test_key(2));
214        let ct = enc.encrypt(b"secret").unwrap();
215        assert!(
216            other.decrypt(&ct).is_err(),
217            "AEAD must reject a payload sealed under a different key"
218        );
219    }
220
221    #[test]
222    fn flipped_ciphertext_byte_fails() {
223        let enc = XChaChaEncryptor::new(test_key(9));
224        let mut ct = enc.encrypt(b"tamper me").unwrap();
225        let last = ct.len() - 1;
226        ct[last] ^= 0x01;
227        assert!(
228            enc.decrypt(&ct).is_err(),
229            "Poly1305 tag must reject a single flipped byte"
230        );
231    }
232
233    #[test]
234    fn legacy_plaintext_passes_through() {
235        let enc = XChaChaEncryptor::new(test_key(5));
236        let mut legacy = vec![MAGIC_PLAINTEXT];
237        legacy.extend_from_slice(b"{\"local_seq\":1}");
238        let out = enc.decrypt(&legacy).unwrap();
239        assert_eq!(out, b"{\"local_seq\":1}");
240    }
241
242    #[test]
243    fn empty_frame_decodes_as_empty_plaintext() {
244        let enc = XChaChaEncryptor::new(test_key(5));
245        let out = enc.decrypt(&[]).unwrap();
246        assert!(out.is_empty());
247    }
248
249    #[test]
250    fn seal_open_line_round_trips() {
251        let enc = XChaChaEncryptor::new(test_key(4));
252        let record = b"{\"local_seq\":42,\"op\":\"Create\"}";
253        let line = seal_line(&enc, record).unwrap();
254        assert!(!line.contains('\n'), "wire line must be single-line");
255        let out = open_line(&enc, &line).unwrap();
256        assert_eq!(out, record);
257    }
258
259    #[test]
260    fn open_line_passes_through_legacy_jsonl() {
261        let enc = XChaChaEncryptor::new(test_key(4));
262        let legacy = "{\"local_seq\":1,\"op\":\"Create\"}";
263        let out = open_line(&enc, legacy).unwrap();
264        assert_eq!(out, legacy.as_bytes());
265    }
266
267    #[test]
268    fn load_or_create_key_creates_0600_and_is_idempotent() {
269        let dir = tempfile::tempdir().unwrap();
270        let path = dir.path().join("nested").join("sync.key");
271        let first = load_or_create_key(&path).unwrap();
272
273        let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777;
274        assert_eq!(mode, 0o600, "key file must be 0600");
275
276        let second = load_or_create_key(&path).unwrap();
277        assert_eq!(first, second, "second load must return the same persisted key");
278    }
279}