car_sync/crypto.rs
1//! End-to-end payload encryption boundary (slice B6 of
2//! `docs/proposals/multi-device-sync.md`, §"Transport: Parslee-hosted relay,
3//! E2E for personal scope").
4//!
5//! The proposal's trust posture: **the relay is a dumb, untrusted ordered-log
6//! store.** Personal-scope payloads are encrypted end-to-end, always; the
7//! relay stores only ciphertext and "can route and dedup on `op_id` and `hlc`
8//! (which stay cleartext) but **cannot read conversations, memory, or
9//! secrets**." This module is the encrypt/decrypt boundary that realizes it.
10//!
11//! ## The design that keeps the shipped oplog intact
12//!
13//! An op's `op_id` is the SHA-256 content address over `device_id ‖ seq ‖ prev
14//! ‖ hlc ‖ scope ‖ surface ‖ canonical(payload)` (see [`crate::oplog`]). To
15//! keep `op_id`/`seq`/`prev`/`hlc`/`scope`/`surface` **cleartext metadata** —
16//! exactly what B3's relay chain-verification and dedup rely on — the
17//! encryption is applied to the **payload only, at authoring time**: a device
18//! that wants E2E authors its op with `cipher.encrypt(plaintext)` as the
19//! payload, so the canonical op the whole system carries is ciphertext-native.
20//! The chain hashes over ciphertext, [`crate::oplog::verify_log`] verifies it,
21//! and the relay sees only the [`Envelope`]. A peer holding the same key
22//! recovers the plaintext with [`PayloadCipher::decrypt`]. No change to the
23//! `OpRecord` shape, the journal, the relay, or the fold — the ciphertext is
24//! just a `serde_json::Value` like any other payload.
25//!
26//! ## Real crypto, not a placeholder
27//!
28//! [`LocalKeyCipher`] is a genuine AEAD: **ChaCha20-Poly1305** with a random
29//! 96-bit nonce per op (RustCrypto `chacha20poly1305`). Confidentiality AND
30//! integrity — a tampered ciphertext fails the Poly1305 tag and
31//! [`PayloadCipher::decrypt`] returns [`CryptoError::Decrypt`], never silently
32//! wrong plaintext. The key is a user-held 256-bit secret
33//! ([`LocalKeyCipher::load_or_generate`] persists it `0600` under
34//! `~/.car/sync/`), never transmitted — the proposal's "the key is user-held,
35//! derived at Parslee login, never transmitted."
36//!
37//! ## Honest boundary — what lands here, what is a documented follow-up
38//!
39//! This slice ships the **boundary primitive + a local-key reference**, tested
40//! (round-trip, relay-sees-ciphertext, tamper-rejected, wrong-key-rejected).
41//! What remains, called out so no one mistakes this for a finished E2E story:
42//!
43//! - **Decrypt-before-fold wiring.** The per-surface fold rules group on
44//! `payload["id"]`/`fold_key` (fact_id dedup, registry LWW-per-record), which
45//! are hidden under ciphertext. A live E2E device must therefore decrypt each
46//! op's payload **after** the ciphertext chain verifies and **before** the
47//! fold groups it (op identity stays the cleartext-metadata `op_id`; only the
48//! payload is swapped). That decrypt-then-fold step in
49//! [`crate::session::SyncSession`] is the remaining engine wiring.
50//! - **Key distribution.** [`LocalKeyCipher`] is a single **local** key — the
51//! single-user multi-device case. Deriving it from the Parslee login secret,
52//! and the org-key distribution via the entitlements layer, is the
53//! key-management follow-up (proposal §"Open questions / Key recovery,
54//! Org-key rotation").
55//! - **Scopes are encryption audiences (B4 pin, binding on B6).** A whole-chain
56//! checkpoint that mixes `Personal` + `Shared{org}` payloads mixes different
57//! key audiences; it must be split per scope key or encrypted to the personal
58//! key only with org-shared state re-derived from the org op-stream — it must
59//! NOT ship a single-key whole-chain ciphertext to an org audience.
60//! [`encryption_audience`] surfaces a scope's audience tag so a caller can
61//! enforce single-audience-per-ciphertext; the per-scope checkpoint split is
62//! the follow-up (it lands with the per-scope relay streams B4 also deferred).
63
64use crate::oplog::Scope;
65use serde::{Deserialize, Serialize};
66use serde_json::Value;
67use std::path::Path;
68
69use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
70use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
71
72/// The frozen algorithm tag written into every [`Envelope`] — lets a future
73/// cipher upgrade coexist (a decryptor rejects an unknown tag rather than
74/// mis-decoding).
75pub const ALG_CHACHA20POLY1305: &str = "chacha20poly1305";
76
77/// The ciphertext form of a payload — what the relay stores and sees. Cleartext
78/// `op_id`/`seq`/`hlc`/`scope`/`surface` metadata lives *outside* this, on the
79/// [`crate::oplog::OpRecord`]; the envelope hides only the payload body.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct Envelope {
82 /// Algorithm tag ([`ALG_CHACHA20POLY1305`]).
83 pub car_enc: String,
84 /// The 96-bit AEAD nonce, hex (24 chars). Random per encryption, so the
85 /// same plaintext encrypts to distinct ciphertext each time.
86 pub nonce: String,
87 /// The ciphertext ‖ Poly1305 tag, hex.
88 pub ct: String,
89}
90
91impl Envelope {
92 /// Is this JSON value a ciphertext envelope (vs. a cleartext payload)?
93 pub fn is_envelope(v: &Value) -> bool {
94 v.get("car_enc").and_then(Value::as_str) == Some(ALG_CHACHA20POLY1305)
95 && v.get("nonce").is_some()
96 && v.get("ct").is_some()
97 }
98}
99
100/// A crypto-boundary failure.
101#[derive(Debug)]
102pub enum CryptoError {
103 /// Serializing the plaintext payload / deserializing the recovered plaintext.
104 Json(serde_json::Error),
105 /// The envelope is malformed, or its algorithm tag is unknown.
106 BadEnvelope(String),
107 /// AEAD open failed — a wrong key OR a tampered ciphertext/nonce (Poly1305
108 /// tag mismatch). Indistinguishable by design; both mean "do not trust".
109 Decrypt,
110 /// The persisted key file is the wrong length or unreadable.
111 Key(String),
112 /// I/O reading/writing the key file.
113 Io(std::io::Error),
114}
115
116impl std::fmt::Display for CryptoError {
117 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
118 match self {
119 CryptoError::Json(e) => write!(f, "crypto payload json error: {e}"),
120 CryptoError::BadEnvelope(d) => write!(f, "crypto envelope malformed: {d}"),
121 CryptoError::Decrypt => {
122 write!(f, "crypto decrypt failed (wrong key or tampered ciphertext)")
123 }
124 CryptoError::Key(d) => write!(f, "crypto key error: {d}"),
125 CryptoError::Io(e) => write!(f, "crypto io error: {e}"),
126 }
127 }
128}
129
130impl std::error::Error for CryptoError {}
131
132impl From<serde_json::Error> for CryptoError {
133 fn from(e: serde_json::Error) -> Self {
134 CryptoError::Json(e)
135 }
136}
137impl From<std::io::Error> for CryptoError {
138 fn from(e: std::io::Error) -> Self {
139 CryptoError::Io(e)
140 }
141}
142
143/// The encrypt/decrypt boundary. A device authors an E2E op with
144/// `cipher.encrypt(plaintext)` as its payload; a peer holding the key recovers
145/// it with `cipher.decrypt(&op.payload)`. Object-safe so a daemon can hold an
146/// `Arc<dyn PayloadCipher>` (a null/local reference now, a login-derived key
147/// later) without a type change.
148pub trait PayloadCipher: Send + Sync {
149 /// Encrypt a cleartext payload into a ciphertext [`Envelope`] (as a
150 /// `Value`).
151 fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError>;
152 /// Recover the cleartext payload from a ciphertext [`Envelope`]. Fails
153 /// ([`CryptoError::Decrypt`]) on a wrong key or any tamper.
154 fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError>;
155}
156
157/// The single-user reference cipher: a local 256-bit ChaCha20-Poly1305 key.
158///
159/// Genuinely linearizable-free confidentiality + integrity for the personal
160/// multi-device case. NOT a login-derived or org-distributed key — see the
161/// module's key-distribution follow-up.
162#[derive(Clone)]
163pub struct LocalKeyCipher {
164 key: [u8; 32],
165}
166
167impl std::fmt::Debug for LocalKeyCipher {
168 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169 // Never print the key.
170 f.debug_struct("LocalKeyCipher").finish_non_exhaustive()
171 }
172}
173
174impl LocalKeyCipher {
175 /// Build a cipher over an explicit 256-bit key.
176 pub fn from_key(key: [u8; 32]) -> Self {
177 Self { key }
178 }
179
180 /// Mint a fresh random key (OS CSPRNG). Not persisted — pair with
181 /// [`Self::key_hex`] to store it, or use [`Self::load_or_generate`].
182 pub fn generate() -> Self {
183 let key = ChaCha20Poly1305::generate_key(&mut OsRng);
184 Self { key: key.into() }
185 }
186
187 /// The key as 64 hex chars (for persistence). Handle as a secret.
188 pub fn key_hex(&self) -> String {
189 to_hex(&self.key)
190 }
191
192 /// Parse a 64-hex-char key.
193 pub fn from_key_hex(hex: &str) -> Result<Self, CryptoError> {
194 let bytes = from_hex(hex).map_err(CryptoError::Key)?;
195 let key: [u8; 32] = bytes
196 .try_into()
197 .map_err(|_| CryptoError::Key("key must be 32 bytes (64 hex chars)".into()))?;
198 Ok(Self { key })
199 }
200
201 /// Load the key from `path`, or mint + persist a new one there (`0600` on
202 /// unix). The single-user "the key lives on my devices" story — a device
203 /// gets the key out of band (copy the file / a recovery phrase); this is
204 /// the local reference, not the login-derived distribution (the follow-up).
205 pub fn load_or_generate(path: &Path) -> Result<Self, CryptoError> {
206 if path.exists() {
207 let hex = std::fs::read_to_string(path)?;
208 return Self::from_key_hex(hex.trim());
209 }
210 let cipher = Self::generate();
211 if let Some(parent) = path.parent() {
212 std::fs::create_dir_all(parent)?;
213 }
214 // Create the key file 0600 from the FIRST byte (review): a
215 // `write` + later `chmod` leaves the 256-bit AEAD key in a
216 // world-readable file for the window between the two syscalls,
217 // and a swallowed chmod error would leave it 0600-claimed but
218 // 0644-real forever. `create_new` also refuses a symlink/TOCTOU
219 // swap at the path. The chmod failure is surfaced, never
220 // discarded.
221 #[cfg(unix)]
222 {
223 use std::io::Write;
224 use std::os::unix::fs::OpenOptionsExt;
225 let mut f = std::fs::OpenOptions::new()
226 .write(true)
227 .create_new(true)
228 .mode(0o600)
229 .open(path)?;
230 f.write_all(cipher.key_hex().as_bytes())?;
231 f.sync_all()?;
232 }
233 #[cfg(not(unix))]
234 {
235 std::fs::write(path, cipher.key_hex())?;
236 }
237 Ok(cipher)
238 }
239
240 fn aead(&self) -> ChaCha20Poly1305 {
241 ChaCha20Poly1305::new(Key::from_slice(&self.key))
242 }
243}
244
245impl PayloadCipher for LocalKeyCipher {
246 fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError> {
247 let bytes = serde_json::to_vec(plaintext)?;
248 let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
249 let ct = self
250 .aead()
251 .encrypt(&nonce, bytes.as_ref())
252 .map_err(|_| CryptoError::Decrypt)?;
253 let env = Envelope {
254 car_enc: ALG_CHACHA20POLY1305.to_string(),
255 nonce: to_hex(nonce.as_slice()),
256 ct: to_hex(&ct),
257 };
258 Ok(serde_json::to_value(env)?)
259 }
260
261 fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError> {
262 let env: Envelope = serde_json::from_value(envelope.clone())
263 .map_err(|e| CryptoError::BadEnvelope(e.to_string()))?;
264 if env.car_enc != ALG_CHACHA20POLY1305 {
265 return Err(CryptoError::BadEnvelope(format!(
266 "unknown algorithm tag {:?}",
267 env.car_enc
268 )));
269 }
270 let nonce_bytes = from_hex(&env.nonce).map_err(CryptoError::BadEnvelope)?;
271 if nonce_bytes.len() != 12 {
272 return Err(CryptoError::BadEnvelope("nonce must be 12 bytes".into()));
273 }
274 let ct = from_hex(&env.ct).map_err(CryptoError::BadEnvelope)?;
275 let nonce = Nonce::from_slice(&nonce_bytes);
276 let pt = self
277 .aead()
278 .decrypt(nonce, ct.as_ref())
279 .map_err(|_| CryptoError::Decrypt)?;
280 Ok(serde_json::from_slice(&pt)?)
281 }
282}
283
284/// The encryption **audience** a scope maps to — the set of principals whose
285/// key a payload under this scope is encrypted to. `Personal` → the user's own
286/// key; `Shared{org}` → the org key. The B4-pinned rule ("scopes are
287/// encryption audiences") uses this: a single ciphertext must have a single
288/// audience, so a whole-chain checkpoint mixing scopes cannot be one ciphertext.
289pub fn encryption_audience(scope: &Scope) -> String {
290 match scope {
291 Scope::Personal => "personal".to_string(),
292 Scope::Shared { org } => format!("org:{org}"),
293 }
294}
295
296fn to_hex(bytes: &[u8]) -> String {
297 bytes.iter().map(|b| format!("{b:02x}")).collect()
298}
299
300fn from_hex(s: &str) -> Result<Vec<u8>, String> {
301 if !s.len().is_multiple_of(2) {
302 return Err("hex length must be even".into());
303 }
304 (0..s.len())
305 .step_by(2)
306 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
307 .collect()
308}
309
310#[cfg(test)]
311mod tests {
312 use super::*;
313 use crate::oplog::{logical_clock, verify_log, DeviceLog, Surface};
314 use serde_json::json;
315
316 #[test]
317 fn local_key_cipher_round_trips() {
318 let cipher = LocalKeyCipher::generate();
319 let plaintext = json!({"id": "f1", "secret": "the launch codes", "n": 42});
320 let env = cipher.encrypt(&plaintext).unwrap();
321 assert!(Envelope::is_envelope(&env));
322 assert_eq!(cipher.decrypt(&env).unwrap(), plaintext);
323
324 // Randomized nonce: two encryptions of the same plaintext differ.
325 let env2 = cipher.encrypt(&plaintext).unwrap();
326 assert_ne!(env, env2, "each encryption uses a fresh nonce");
327 assert_eq!(cipher.decrypt(&env2).unwrap(), plaintext);
328 }
329
330 #[test]
331 fn encrypted_op_chain_verifies_and_relay_sees_only_ciphertext() {
332 // A device authors two ops with ENCRYPTED payloads. The op_id chain is
333 // ciphertext-native, so verify_log passes and the relay (which only
334 // ever holds op.payload) sees no plaintext — exactly the proposal's
335 // "the relay stores only ciphertext; op_id/hlc stay cleartext".
336 let cipher = LocalKeyCipher::generate();
337 let mut dev = DeviceLog::new("mac-a");
338 dev.set_wall_clock(logical_clock());
339
340 let secret1 = json!({"id": "f1", "body": "the sky is blue"});
341 let secret2 = json!({"id": "f2", "body": "water is wet"});
342 let op1 = dev.append(
343 Scope::Personal,
344 Surface::Knowledge,
345 cipher.encrypt(&secret1).unwrap(),
346 );
347 let op2 = dev.append(
348 Scope::Personal,
349 Surface::Knowledge,
350 cipher.encrypt(&secret2).unwrap(),
351 );
352
353 // The ciphertext chain verifies (op_id covers the ciphertext payload).
354 verify_log(&[op1.clone(), op2.clone()]).unwrap();
355 assert!(op1.id_valid());
356
357 // The wire form leaks nothing: no "body"/"id" fields, only the envelope.
358 for op in [&op1, &op2] {
359 assert!(Envelope::is_envelope(&op.payload));
360 assert!(op.payload.get("body").is_none());
361 assert!(op.payload.get("id").is_none());
362 }
363
364 // A peer holding the key recovers the plaintext.
365 assert_eq!(cipher.decrypt(&op1.payload).unwrap(), secret1);
366 assert_eq!(cipher.decrypt(&op2.payload).unwrap(), secret2);
367 }
368
369 #[test]
370 fn tampered_ciphertext_is_rejected() {
371 let cipher = LocalKeyCipher::generate();
372 let env = cipher.encrypt(&json!({"x": 1})).unwrap();
373
374 // Flip one hex nibble of the ciphertext → AEAD tag mismatch → refusal.
375 let mut tampered = env.clone();
376 let ct = tampered["ct"].as_str().unwrap().to_string();
377 let flipped: String = {
378 let mut chars: Vec<char> = ct.chars().collect();
379 chars[0] = if chars[0] == '0' { '1' } else { '0' };
380 chars.into_iter().collect()
381 };
382 tampered["ct"] = json!(flipped);
383 assert!(matches!(cipher.decrypt(&tampered), Err(CryptoError::Decrypt)));
384 }
385
386 #[test]
387 fn wrong_key_cannot_decrypt() {
388 let cipher = LocalKeyCipher::generate();
389 let other = LocalKeyCipher::generate();
390 let env = cipher.encrypt(&json!({"x": 1})).unwrap();
391 assert!(matches!(other.decrypt(&env), Err(CryptoError::Decrypt)));
392 }
393
394 #[test]
395 fn load_or_generate_persists_and_reloads_the_same_key() {
396 let dir = tempfile::tempdir().unwrap();
397 let path = dir.path().join("sync").join("personal.key");
398 let a = LocalKeyCipher::load_or_generate(&path).unwrap();
399 assert!(path.exists());
400 let b = LocalKeyCipher::load_or_generate(&path).unwrap();
401 assert_eq!(a.key_hex(), b.key_hex(), "the persisted key reloads identically");
402
403 // And the reloaded key decrypts the first cipher's output (same key).
404 let env = a.encrypt(&json!({"k": "v"})).unwrap();
405 assert_eq!(b.decrypt(&env).unwrap(), json!({"k": "v"}));
406 }
407
408 #[test]
409 fn scope_maps_to_a_single_encryption_audience() {
410 assert_eq!(encryption_audience(&Scope::Personal), "personal");
411 assert_eq!(
412 encryption_audience(&Scope::Shared { org: "acme".into() }),
413 "org:acme"
414 );
415 }
416}