car-sync 0.32.1

Multi-device sync core for Common Agent Runtime — replica-tagged append-only oplog + deterministic CRDT fold
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! End-to-end payload encryption boundary (slice B6 of
//! `docs/proposals/multi-device-sync.md`, §"Transport: Parslee-hosted relay,
//! E2E for personal scope").
//!
//! The proposal's trust posture: **the relay is a dumb, untrusted ordered-log
//! store.** Personal-scope payloads are encrypted end-to-end, always; the
//! relay stores only ciphertext and "can route and dedup on `op_id` and `hlc`
//! (which stay cleartext) but **cannot read conversations, memory, or
//! secrets**." This module is the encrypt/decrypt boundary that realizes it.
//!
//! ## The design that keeps the shipped oplog intact
//!
//! An op's `op_id` is the SHA-256 content address over `device_id ‖ seq ‖ prev
//! ‖ hlc ‖ scope ‖ surface ‖ canonical(payload)` (see [`crate::oplog`]). To
//! keep `op_id`/`seq`/`prev`/`hlc`/`scope`/`surface` **cleartext metadata** —
//! exactly what B3's relay chain-verification and dedup rely on — the
//! encryption is applied to the **payload only, at authoring time**: a device
//! that wants E2E authors its op with `cipher.encrypt(plaintext)` as the
//! payload, so the canonical op the whole system carries is ciphertext-native.
//! The chain hashes over ciphertext, [`crate::oplog::verify_log`] verifies it,
//! and the relay sees only the [`Envelope`]. A peer holding the same key
//! recovers the plaintext with [`PayloadCipher::decrypt`]. No change to the
//! `OpRecord` shape, the journal, the relay, or the fold — the ciphertext is
//! just a `serde_json::Value` like any other payload.
//!
//! ## Real crypto, not a placeholder
//!
//! [`LocalKeyCipher`] is a genuine AEAD: **ChaCha20-Poly1305** with a random
//! 96-bit nonce per op (RustCrypto `chacha20poly1305`). Confidentiality AND
//! integrity — a tampered ciphertext fails the Poly1305 tag and
//! [`PayloadCipher::decrypt`] returns [`CryptoError::Decrypt`], never silently
//! wrong plaintext. The key is a user-held 256-bit secret
//! ([`LocalKeyCipher::load_or_generate`] persists it `0600` under
//! `~/.car/sync/`), never transmitted — the proposal's "the key is user-held,
//! derived at Parslee login, never transmitted."
//!
//! ## Honest boundary — what lands here, what is a documented follow-up
//!
//! This slice ships the **boundary primitive + a local-key reference**, tested
//! (round-trip, relay-sees-ciphertext, tamper-rejected, wrong-key-rejected).
//! What remains, called out so no one mistakes this for a finished E2E story:
//!
//! - **Decrypt-before-fold wiring.** The per-surface fold rules group on
//!   `payload["id"]`/`fold_key` (fact_id dedup, registry LWW-per-record), which
//!   are hidden under ciphertext. A live E2E device must therefore decrypt each
//!   op's payload **after** the ciphertext chain verifies and **before** the
//!   fold groups it (op identity stays the cleartext-metadata `op_id`; only the
//!   payload is swapped). That decrypt-then-fold step in
//!   [`crate::session::SyncSession`] is the remaining engine wiring.
//! - **Key distribution.** [`LocalKeyCipher`] is a single **local** key — the
//!   single-user multi-device case. Deriving it from the Parslee login secret,
//!   and the org-key distribution via the entitlements layer, is the
//!   key-management follow-up (proposal §"Open questions / Key recovery,
//!   Org-key rotation").
//! - **Scopes are encryption audiences (B4 pin, binding on B6).** A whole-chain
//!   checkpoint that mixes `Personal` + `Shared{org}` payloads mixes different
//!   key audiences; it must be split per scope key or encrypted to the personal
//!   key only with org-shared state re-derived from the org op-stream — it must
//!   NOT ship a single-key whole-chain ciphertext to an org audience.
//!   [`encryption_audience`] surfaces a scope's audience tag so a caller can
//!   enforce single-audience-per-ciphertext; the per-scope checkpoint split is
//!   the follow-up (it lands with the per-scope relay streams B4 also deferred).

use crate::oplog::Scope;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;

use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};

/// The frozen algorithm tag written into every [`Envelope`] — lets a future
/// cipher upgrade coexist (a decryptor rejects an unknown tag rather than
/// mis-decoding).
pub const ALG_CHACHA20POLY1305: &str = "chacha20poly1305";

/// The ciphertext form of a payload — what the relay stores and sees. Cleartext
/// `op_id`/`seq`/`hlc`/`scope`/`surface` metadata lives *outside* this, on the
/// [`crate::oplog::OpRecord`]; the envelope hides only the payload body.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Envelope {
    /// Algorithm tag ([`ALG_CHACHA20POLY1305`]).
    pub car_enc: String,
    /// The 96-bit AEAD nonce, hex (24 chars). Random per encryption, so the
    /// same plaintext encrypts to distinct ciphertext each time.
    pub nonce: String,
    /// The ciphertext ‖ Poly1305 tag, hex.
    pub ct: String,
}

impl Envelope {
    /// Is this JSON value a ciphertext envelope (vs. a cleartext payload)?
    pub fn is_envelope(v: &Value) -> bool {
        v.get("car_enc").and_then(Value::as_str) == Some(ALG_CHACHA20POLY1305)
            && v.get("nonce").is_some()
            && v.get("ct").is_some()
    }
}

/// A crypto-boundary failure.
#[derive(Debug)]
pub enum CryptoError {
    /// Serializing the plaintext payload / deserializing the recovered plaintext.
    Json(serde_json::Error),
    /// The envelope is malformed, or its algorithm tag is unknown.
    BadEnvelope(String),
    /// AEAD open failed — a wrong key OR a tampered ciphertext/nonce (Poly1305
    /// tag mismatch). Indistinguishable by design; both mean "do not trust".
    Decrypt,
    /// The persisted key file is the wrong length or unreadable.
    Key(String),
    /// I/O reading/writing the key file.
    Io(std::io::Error),
}

impl std::fmt::Display for CryptoError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            CryptoError::Json(e) => write!(f, "crypto payload json error: {e}"),
            CryptoError::BadEnvelope(d) => write!(f, "crypto envelope malformed: {d}"),
            CryptoError::Decrypt => {
                write!(f, "crypto decrypt failed (wrong key or tampered ciphertext)")
            }
            CryptoError::Key(d) => write!(f, "crypto key error: {d}"),
            CryptoError::Io(e) => write!(f, "crypto io error: {e}"),
        }
    }
}

impl std::error::Error for CryptoError {}

impl From<serde_json::Error> for CryptoError {
    fn from(e: serde_json::Error) -> Self {
        CryptoError::Json(e)
    }
}
impl From<std::io::Error> for CryptoError {
    fn from(e: std::io::Error) -> Self {
        CryptoError::Io(e)
    }
}

/// The encrypt/decrypt boundary. A device authors an E2E op with
/// `cipher.encrypt(plaintext)` as its payload; a peer holding the key recovers
/// it with `cipher.decrypt(&op.payload)`. Object-safe so a daemon can hold an
/// `Arc<dyn PayloadCipher>` (a null/local reference now, a login-derived key
/// later) without a type change.
pub trait PayloadCipher: Send + Sync {
    /// Encrypt a cleartext payload into a ciphertext [`Envelope`] (as a
    /// `Value`).
    fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError>;
    /// Recover the cleartext payload from a ciphertext [`Envelope`]. Fails
    /// ([`CryptoError::Decrypt`]) on a wrong key or any tamper.
    fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError>;
}

/// The single-user reference cipher: a local 256-bit ChaCha20-Poly1305 key.
///
/// Genuinely linearizable-free confidentiality + integrity for the personal
/// multi-device case. NOT a login-derived or org-distributed key — see the
/// module's key-distribution follow-up.
#[derive(Clone)]
pub struct LocalKeyCipher {
    key: [u8; 32],
}

impl std::fmt::Debug for LocalKeyCipher {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        // Never print the key.
        f.debug_struct("LocalKeyCipher").finish_non_exhaustive()
    }
}

impl LocalKeyCipher {
    /// Build a cipher over an explicit 256-bit key.
    pub fn from_key(key: [u8; 32]) -> Self {
        Self { key }
    }

    /// Mint a fresh random key (OS CSPRNG). Not persisted — pair with
    /// [`Self::key_hex`] to store it, or use [`Self::load_or_generate`].
    pub fn generate() -> Self {
        let key = ChaCha20Poly1305::generate_key(&mut OsRng);
        Self { key: key.into() }
    }

    /// The key as 64 hex chars (for persistence). Handle as a secret.
    pub fn key_hex(&self) -> String {
        to_hex(&self.key)
    }

    /// Parse a 64-hex-char key.
    pub fn from_key_hex(hex: &str) -> Result<Self, CryptoError> {
        let bytes = from_hex(hex).map_err(CryptoError::Key)?;
        let key: [u8; 32] = bytes
            .try_into()
            .map_err(|_| CryptoError::Key("key must be 32 bytes (64 hex chars)".into()))?;
        Ok(Self { key })
    }

    /// Load the key from `path`, or mint + persist a new one there (`0600` on
    /// unix). The single-user "the key lives on my devices" story — a device
    /// gets the key out of band (copy the file / a recovery phrase); this is
    /// the local reference, not the login-derived distribution (the follow-up).
    pub fn load_or_generate(path: &Path) -> Result<Self, CryptoError> {
        if path.exists() {
            let hex = std::fs::read_to_string(path)?;
            return Self::from_key_hex(hex.trim());
        }
        let cipher = Self::generate();
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        // Create the key file 0600 from the FIRST byte (review): a
        // `write` + later `chmod` leaves the 256-bit AEAD key in a
        // world-readable file for the window between the two syscalls,
        // and a swallowed chmod error would leave it 0600-claimed but
        // 0644-real forever. `create_new` also refuses a symlink/TOCTOU
        // swap at the path. The chmod failure is surfaced, never
        // discarded.
        #[cfg(unix)]
        {
            use std::io::Write;
            use std::os::unix::fs::OpenOptionsExt;
            let mut f = std::fs::OpenOptions::new()
                .write(true)
                .create_new(true)
                .mode(0o600)
                .open(path)?;
            f.write_all(cipher.key_hex().as_bytes())?;
            f.sync_all()?;
        }
        #[cfg(not(unix))]
        {
            std::fs::write(path, cipher.key_hex())?;
        }
        Ok(cipher)
    }

    fn aead(&self) -> ChaCha20Poly1305 {
        ChaCha20Poly1305::new(Key::from_slice(&self.key))
    }
}

impl PayloadCipher for LocalKeyCipher {
    fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError> {
        let bytes = serde_json::to_vec(plaintext)?;
        let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
        let ct = self
            .aead()
            .encrypt(&nonce, bytes.as_ref())
            .map_err(|_| CryptoError::Decrypt)?;
        let env = Envelope {
            car_enc: ALG_CHACHA20POLY1305.to_string(),
            nonce: to_hex(nonce.as_slice()),
            ct: to_hex(&ct),
        };
        Ok(serde_json::to_value(env)?)
    }

    fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError> {
        let env: Envelope = serde_json::from_value(envelope.clone())
            .map_err(|e| CryptoError::BadEnvelope(e.to_string()))?;
        if env.car_enc != ALG_CHACHA20POLY1305 {
            return Err(CryptoError::BadEnvelope(format!(
                "unknown algorithm tag {:?}",
                env.car_enc
            )));
        }
        let nonce_bytes = from_hex(&env.nonce).map_err(CryptoError::BadEnvelope)?;
        if nonce_bytes.len() != 12 {
            return Err(CryptoError::BadEnvelope("nonce must be 12 bytes".into()));
        }
        let ct = from_hex(&env.ct).map_err(CryptoError::BadEnvelope)?;
        let nonce = Nonce::from_slice(&nonce_bytes);
        let pt = self
            .aead()
            .decrypt(nonce, ct.as_ref())
            .map_err(|_| CryptoError::Decrypt)?;
        Ok(serde_json::from_slice(&pt)?)
    }
}

/// The encryption **audience** a scope maps to — the set of principals whose
/// key a payload under this scope is encrypted to. `Personal` → the user's own
/// key; `Shared{org}` → the org key. The B4-pinned rule ("scopes are
/// encryption audiences") uses this: a single ciphertext must have a single
/// audience, so a whole-chain checkpoint mixing scopes cannot be one ciphertext.
pub fn encryption_audience(scope: &Scope) -> String {
    match scope {
        Scope::Personal => "personal".to_string(),
        Scope::Shared { org } => format!("org:{org}"),
    }
}

fn to_hex(bytes: &[u8]) -> String {
    bytes.iter().map(|b| format!("{b:02x}")).collect()
}

fn from_hex(s: &str) -> Result<Vec<u8>, String> {
    if !s.len().is_multiple_of(2) {
        return Err("hex length must be even".into());
    }
    (0..s.len())
        .step_by(2)
        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::oplog::{logical_clock, verify_log, DeviceLog, Surface};
    use serde_json::json;

    #[test]
    fn local_key_cipher_round_trips() {
        let cipher = LocalKeyCipher::generate();
        let plaintext = json!({"id": "f1", "secret": "the launch codes", "n": 42});
        let env = cipher.encrypt(&plaintext).unwrap();
        assert!(Envelope::is_envelope(&env));
        assert_eq!(cipher.decrypt(&env).unwrap(), plaintext);

        // Randomized nonce: two encryptions of the same plaintext differ.
        let env2 = cipher.encrypt(&plaintext).unwrap();
        assert_ne!(env, env2, "each encryption uses a fresh nonce");
        assert_eq!(cipher.decrypt(&env2).unwrap(), plaintext);
    }

    #[test]
    fn encrypted_op_chain_verifies_and_relay_sees_only_ciphertext() {
        // A device authors two ops with ENCRYPTED payloads. The op_id chain is
        // ciphertext-native, so verify_log passes and the relay (which only
        // ever holds op.payload) sees no plaintext — exactly the proposal's
        // "the relay stores only ciphertext; op_id/hlc stay cleartext".
        let cipher = LocalKeyCipher::generate();
        let mut dev = DeviceLog::new("mac-a");
        dev.set_wall_clock(logical_clock());

        let secret1 = json!({"id": "f1", "body": "the sky is blue"});
        let secret2 = json!({"id": "f2", "body": "water is wet"});
        let op1 = dev.append(
            Scope::Personal,
            Surface::Knowledge,
            cipher.encrypt(&secret1).unwrap(),
        );
        let op2 = dev.append(
            Scope::Personal,
            Surface::Knowledge,
            cipher.encrypt(&secret2).unwrap(),
        );

        // The ciphertext chain verifies (op_id covers the ciphertext payload).
        verify_log(&[op1.clone(), op2.clone()]).unwrap();
        assert!(op1.id_valid());

        // The wire form leaks nothing: no "body"/"id" fields, only the envelope.
        for op in [&op1, &op2] {
            assert!(Envelope::is_envelope(&op.payload));
            assert!(op.payload.get("body").is_none());
            assert!(op.payload.get("id").is_none());
        }

        // A peer holding the key recovers the plaintext.
        assert_eq!(cipher.decrypt(&op1.payload).unwrap(), secret1);
        assert_eq!(cipher.decrypt(&op2.payload).unwrap(), secret2);
    }

    #[test]
    fn tampered_ciphertext_is_rejected() {
        let cipher = LocalKeyCipher::generate();
        let env = cipher.encrypt(&json!({"x": 1})).unwrap();

        // Flip one hex nibble of the ciphertext → AEAD tag mismatch → refusal.
        let mut tampered = env.clone();
        let ct = tampered["ct"].as_str().unwrap().to_string();
        let flipped: String = {
            let mut chars: Vec<char> = ct.chars().collect();
            chars[0] = if chars[0] == '0' { '1' } else { '0' };
            chars.into_iter().collect()
        };
        tampered["ct"] = json!(flipped);
        assert!(matches!(cipher.decrypt(&tampered), Err(CryptoError::Decrypt)));
    }

    #[test]
    fn wrong_key_cannot_decrypt() {
        let cipher = LocalKeyCipher::generate();
        let other = LocalKeyCipher::generate();
        let env = cipher.encrypt(&json!({"x": 1})).unwrap();
        assert!(matches!(other.decrypt(&env), Err(CryptoError::Decrypt)));
    }

    #[test]
    fn load_or_generate_persists_and_reloads_the_same_key() {
        let dir = tempfile::tempdir().unwrap();
        let path = dir.path().join("sync").join("personal.key");
        let a = LocalKeyCipher::load_or_generate(&path).unwrap();
        assert!(path.exists());
        let b = LocalKeyCipher::load_or_generate(&path).unwrap();
        assert_eq!(a.key_hex(), b.key_hex(), "the persisted key reloads identically");

        // And the reloaded key decrypts the first cipher's output (same key).
        let env = a.encrypt(&json!({"k": "v"})).unwrap();
        assert_eq!(b.decrypt(&env).unwrap(), json!({"k": "v"}));
    }

    #[test]
    fn scope_maps_to_a_single_encryption_audience() {
        assert_eq!(encryption_audience(&Scope::Personal), "personal");
        assert_eq!(
            encryption_audience(&Scope::Shared { org: "acme".into() }),
            "org:acme"
        );
    }
}