second_brain_sync/
crypto.rs1use 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 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
98pub fn seal_line(enc: &dyn SyncEncryptor, plaintext: &[u8]) -> Result<String> {
102 let framed = enc.encrypt(plaintext)?;
103 Ok(B64.encode(framed))
104}
105
106pub 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
132pub 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 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 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}