Skip to main content

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}