car-sync 0.35.0

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
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
//! 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."
//!
//! ## What's wired, what remains
//!
//! Shipped + tested here and in the session:
//!
//! - **Decrypt-before-fold** — [`crate::session::SyncSession::with_key_provider`]
//!   encrypts each payload at `append` (ciphertext-native, `op_id` over
//!   ciphertext) and decrypts at `state()` *after* the chain verifies and
//!   *before* the fold groups on `payload["id"]`/`fold_key`. Op identity stays
//!   the cleartext-metadata `op_id`.
//! - **Login-derived key distribution** — [`DerivedKeyProvider`] HKDF-derives
//!   per-audience keys from one master; [`DerivedKeyProvider::from_passphrase`]
//!   is the zero-knowledge cross-device source (same passphrase → same keys on
//!   every device, never transmitted). [`LocalKeyCipher`] remains the raw
//!   single-key reference. Per-audience isolation via [`encryption_audience`].
//! - **Checkpoints under E2E** — [`crate::session::SyncSession::publish_checkpoint`]
//!   is guarded off under a key provider: a ciphertext-folded checkpoint would
//!   form an inconsistent decrypt base, and a cleartext one would leak. The
//!   encrypted op log is retained and cold bootstrap replays it.
//!
//! Remaining: **per-scope encrypted checkpoint push** — a whole-chain checkpoint
//! mixes `Personal` + `Shared{org}` audiences, so it must be split per scope key
//! (or encrypted to the personal key with org state re-derived from the org
//! stream) before it can be pushed to an untrusted relay to restore relay-side
//! GC; and **org-key distribution** via the entitlements layer.

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}"),
    }
}

// ---------------------------------------------------------------------------
// Login-derived key distribution (B6).
//
// A single-user reference key (`LocalKeyCipher::load_or_generate`) doesn't scale
// to "onboard once": a new device would have to copy a key file out of band.
// Instead every device HKDF-derives its per-audience AEAD keys from ONE master
// secret it gets from the Parslee login (per-user) / entitlements (per-org).
// Same login → same master → same derived keys on every device (so they decrypt
// each other), while "personal" and "org:<id>" audiences stay cryptographically
// independent. The master never leaves the authenticated client — the relay
// only ever holds ciphertext under a key it does not possess.
// ---------------------------------------------------------------------------

/// HKDF-SHA256 info prefix for CAR sync AEAD keys — bump `v1` on a KDF change.
const KDF_INFO_PREFIX: &[u8] = b"car-sync/v1/aead/";
/// A fixed (non-secret) HKDF salt. A constant makes derivation deterministic
/// across a user's devices from the same master — the whole point.
const KDF_SALT: &[u8] = b"car-sync/v1/salt";

/// Derive a 256-bit AEAD key for `audience` from a login/entitlement `master`
/// secret via HKDF-SHA256. Deterministic: the same `(master, audience)` yields
/// the same key on every device (a user's Mac and phone decrypt each other's
/// ops); distinct audiences ("personal" vs "org:<id>") yield independent keys.
pub fn derive_key(master: &[u8], audience: &str) -> [u8; 32] {
    let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(KDF_SALT), master);
    let mut info = KDF_INFO_PREFIX.to_vec();
    info.extend_from_slice(audience.as_bytes());
    let mut okm = [0u8; 32];
    hk.expand(&info, &mut okm)
        .expect("32 bytes is a valid HKDF-SHA256 output length");
    okm
}

/// Supplies the [`PayloadCipher`] for a scope's encryption audience. The daemon
/// holds one and asks for a cipher per op-scope, so a remote relay only ever
/// sees ciphertext under the right (personal / org) key.
pub trait SyncKeyProvider: Send + Sync {
    fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher>;
}

/// Derives per-audience [`LocalKeyCipher`]s from one login-derived master secret
/// (HKDF-SHA256), caching by audience. The master comes from the Parslee login
/// (per-user) / entitlements (per-org); it is NEVER sent to the relay.
pub struct DerivedKeyProvider {
    master: Vec<u8>,
    cache: std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<LocalKeyCipher>>>,
}

impl std::fmt::Debug for DerivedKeyProvider {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("DerivedKeyProvider").finish_non_exhaustive()
    }
}

impl DerivedKeyProvider {
    /// Build over a raw master secret (already stable across the user's devices,
    /// e.g. a per-user sync key Parslee issued at login).
    pub fn new(master: impl Into<Vec<u8>>) -> Self {
        Self {
            master: master.into(),
            cache: std::sync::Mutex::new(std::collections::HashMap::new()),
        }
    }

    /// Build from login material bound to `user_id` — HKDF the raw secret into a
    /// stable per-user master so two logins for the same user converge on the
    /// same keys and different users never collide.
    pub fn from_login_secret(login_secret: &[u8], user_id: &str) -> Self {
        Self::new(derive_key(login_secret, &format!("user/{user_id}")).to_vec())
    }

    /// Build from a user **sync passphrase** — the zero-knowledge cross-device
    /// key source that needs NO server key distribution: every device on which
    /// the user enters the same passphrase derives the same keys, and Parslee
    /// (relay + platform) never sees it. `user_id` domain-separates users. This
    /// is the recommended source today; a Parslee-issued per-user master
    /// (delivered over the authenticated channel) is the alternative, and both
    /// land here as the master secret.
    pub fn from_passphrase(passphrase: &str, user_id: &str) -> Self {
        Self::from_login_secret(passphrase.as_bytes(), user_id)
    }

    fn cipher_for_audience(&self, audience: &str) -> std::sync::Arc<dyn PayloadCipher> {
        let mut cache = self.cache.lock().expect("key cache poisoned");
        if let Some(c) = cache.get(audience) {
            return c.clone();
        }
        let cipher =
            std::sync::Arc::new(LocalKeyCipher::from_key(derive_key(&self.master, audience)));
        cache.insert(audience.to_string(), cipher.clone());
        cipher
    }
}

impl SyncKeyProvider for DerivedKeyProvider {
    fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher> {
        self.cipher_for_audience(&encryption_audience(scope))
    }
}

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"
        );
    }

    #[test]
    fn same_login_master_derives_interoperable_keys_across_devices() {
        // Mac and phone each build a provider from the SAME login master. A
        // payload the Mac encrypts under `Personal` must decrypt on the phone —
        // the whole "my devices share config after one login" property.
        let master = b"parslee-issued-per-user-sync-secret";
        let mac = DerivedKeyProvider::new(master.to_vec());
        let phone = DerivedKeyProvider::new(master.to_vec());

        let secret = json!({"messaging_allowlist": ["+15551234567"]});
        let env = mac.cipher_for(&Scope::Personal).encrypt(&secret).unwrap();
        assert_eq!(
            phone.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
            secret
        );
    }

    #[test]
    fn personal_and_org_audiences_are_cryptographically_isolated() {
        let p = DerivedKeyProvider::new(b"master".to_vec());
        let env = p
            .cipher_for(&Scope::Personal)
            .encrypt(&json!({"x": 1}))
            .unwrap();
        // The org key cannot read a personal-audience ciphertext.
        assert!(matches!(
            p.cipher_for(&Scope::Shared { org: "acme".into() })
                .decrypt(&env),
            Err(CryptoError::Decrypt)
        ));
    }

    #[test]
    fn passphrase_derives_the_same_keys_on_every_device_zero_knowledge() {
        // The zero-knowledge path: same passphrase + user → same keys, so the
        // phone reads what the Mac wrote, with no server ever holding the key.
        let mac = DerivedKeyProvider::from_passphrase("correct horse battery staple", "user-1");
        let phone = DerivedKeyProvider::from_passphrase("correct horse battery staple", "user-1");
        let env = mac
            .cipher_for(&Scope::Personal)
            .encrypt(&json!({"s": 1}))
            .unwrap();
        assert_eq!(
            phone.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
            json!({"s": 1})
        );
        // A wrong passphrase cannot read it.
        let wrong = DerivedKeyProvider::from_passphrase("hunter2", "user-1");
        assert!(matches!(
            wrong.cipher_for(&Scope::Personal).decrypt(&env),
            Err(CryptoError::Decrypt)
        ));
    }

    #[test]
    fn a_different_login_cannot_decrypt() {
        let mine = DerivedKeyProvider::new(b"my-secret".to_vec());
        let theirs = DerivedKeyProvider::new(b"their-secret".to_vec());
        let env = mine
            .cipher_for(&Scope::Personal)
            .encrypt(&json!({"x": 1}))
            .unwrap();
        assert!(matches!(
            theirs.cipher_for(&Scope::Personal).decrypt(&env),
            Err(CryptoError::Decrypt)
        ));
    }

    #[test]
    fn from_login_secret_is_stable_per_user_and_distinct_across_users() {
        let raw = b"raw-oauth-derived-material";
        // Two sign-ins for the same user → same keys (idempotent onboarding).
        let a = DerivedKeyProvider::from_login_secret(raw, "user-1");
        let b = DerivedKeyProvider::from_login_secret(raw, "user-1");
        let env = a
            .cipher_for(&Scope::Personal)
            .encrypt(&json!({"k": "v"}))
            .unwrap();
        assert_eq!(
            b.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
            json!({"k": "v"})
        );
        // A different user derives a different master → cannot decrypt.
        let other = DerivedKeyProvider::from_login_secret(raw, "user-2");
        assert!(matches!(
            other.cipher_for(&Scope::Personal).decrypt(&env),
            Err(CryptoError::Decrypt)
        ));
    }
}