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//! ## What's wired, what remains
38//!
39//! Shipped + tested here and in the session:
40//!
41//! - **Decrypt-before-fold** — [`crate::session::SyncSession::with_key_provider`]
42//!   encrypts each payload at `append` (ciphertext-native, `op_id` over
43//!   ciphertext) and decrypts at `state()` *after* the chain verifies and
44//!   *before* the fold groups on `payload["id"]`/`fold_key`. Op identity stays
45//!   the cleartext-metadata `op_id`.
46//! - **Login-derived key distribution** — [`DerivedKeyProvider`] HKDF-derives
47//!   per-audience keys from one master; [`DerivedKeyProvider::from_passphrase`]
48//!   is the zero-knowledge cross-device source (same passphrase → same keys on
49//!   every device, never transmitted). [`LocalKeyCipher`] remains the raw
50//!   single-key reference. Per-audience isolation via [`encryption_audience`].
51//! - **Checkpoints under E2E** — [`crate::session::SyncSession::publish_checkpoint`]
52//!   is guarded off under a key provider: a ciphertext-folded checkpoint would
53//!   form an inconsistent decrypt base, and a cleartext one would leak. The
54//!   encrypted op log is retained and cold bootstrap replays it.
55//!
56//! Remaining: **per-scope encrypted checkpoint push** — a whole-chain checkpoint
57//! mixes `Personal` + `Shared{org}` audiences, so it must be split per scope key
58//! (or encrypted to the personal key with org state re-derived from the org
59//! stream) before it can be pushed to an untrusted relay to restore relay-side
60//! GC; and **org-key distribution** via the entitlements layer.
61
62use crate::oplog::Scope;
63use serde::{Deserialize, Serialize};
64use serde_json::Value;
65use std::path::Path;
66
67use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng};
68use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
69
70/// The frozen algorithm tag written into every [`Envelope`] — lets a future
71/// cipher upgrade coexist (a decryptor rejects an unknown tag rather than
72/// mis-decoding).
73pub const ALG_CHACHA20POLY1305: &str = "chacha20poly1305";
74
75/// The ciphertext form of a payload — what the relay stores and sees. Cleartext
76/// `op_id`/`seq`/`hlc`/`scope`/`surface` metadata lives *outside* this, on the
77/// [`crate::oplog::OpRecord`]; the envelope hides only the payload body.
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
79pub struct Envelope {
80    /// Algorithm tag ([`ALG_CHACHA20POLY1305`]).
81    pub car_enc: String,
82    /// The 96-bit AEAD nonce, hex (24 chars). Random per encryption, so the
83    /// same plaintext encrypts to distinct ciphertext each time.
84    pub nonce: String,
85    /// The ciphertext ‖ Poly1305 tag, hex.
86    pub ct: String,
87}
88
89impl Envelope {
90    /// Is this JSON value a ciphertext envelope (vs. a cleartext payload)?
91    pub fn is_envelope(v: &Value) -> bool {
92        v.get("car_enc").and_then(Value::as_str) == Some(ALG_CHACHA20POLY1305)
93            && v.get("nonce").is_some()
94            && v.get("ct").is_some()
95    }
96}
97
98/// A crypto-boundary failure.
99#[derive(Debug)]
100pub enum CryptoError {
101    /// Serializing the plaintext payload / deserializing the recovered plaintext.
102    Json(serde_json::Error),
103    /// The envelope is malformed, or its algorithm tag is unknown.
104    BadEnvelope(String),
105    /// AEAD open failed — a wrong key OR a tampered ciphertext/nonce (Poly1305
106    /// tag mismatch). Indistinguishable by design; both mean "do not trust".
107    Decrypt,
108    /// The persisted key file is the wrong length or unreadable.
109    Key(String),
110    /// I/O reading/writing the key file.
111    Io(std::io::Error),
112}
113
114impl std::fmt::Display for CryptoError {
115    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
116        match self {
117            CryptoError::Json(e) => write!(f, "crypto payload json error: {e}"),
118            CryptoError::BadEnvelope(d) => write!(f, "crypto envelope malformed: {d}"),
119            CryptoError::Decrypt => {
120                write!(
121                    f,
122                    "crypto decrypt failed (wrong key or tampered ciphertext)"
123                )
124            }
125            CryptoError::Key(d) => write!(f, "crypto key error: {d}"),
126            CryptoError::Io(e) => write!(f, "crypto io error: {e}"),
127        }
128    }
129}
130
131impl std::error::Error for CryptoError {}
132
133impl From<serde_json::Error> for CryptoError {
134    fn from(e: serde_json::Error) -> Self {
135        CryptoError::Json(e)
136    }
137}
138impl From<std::io::Error> for CryptoError {
139    fn from(e: std::io::Error) -> Self {
140        CryptoError::Io(e)
141    }
142}
143
144/// The encrypt/decrypt boundary. A device authors an E2E op with
145/// `cipher.encrypt(plaintext)` as its payload; a peer holding the key recovers
146/// it with `cipher.decrypt(&op.payload)`. Object-safe so a daemon can hold an
147/// `Arc<dyn PayloadCipher>` (a null/local reference now, a login-derived key
148/// later) without a type change.
149pub trait PayloadCipher: Send + Sync {
150    /// Encrypt a cleartext payload into a ciphertext [`Envelope`] (as a
151    /// `Value`).
152    fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError>;
153    /// Recover the cleartext payload from a ciphertext [`Envelope`]. Fails
154    /// ([`CryptoError::Decrypt`]) on a wrong key or any tamper.
155    fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError>;
156}
157
158/// The single-user reference cipher: a local 256-bit ChaCha20-Poly1305 key.
159///
160/// Genuinely linearizable-free confidentiality + integrity for the personal
161/// multi-device case. NOT a login-derived or org-distributed key — see the
162/// module's key-distribution follow-up.
163#[derive(Clone)]
164pub struct LocalKeyCipher {
165    key: [u8; 32],
166}
167
168impl std::fmt::Debug for LocalKeyCipher {
169    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
170        // Never print the key.
171        f.debug_struct("LocalKeyCipher").finish_non_exhaustive()
172    }
173}
174
175impl LocalKeyCipher {
176    /// Build a cipher over an explicit 256-bit key.
177    pub fn from_key(key: [u8; 32]) -> Self {
178        Self { key }
179    }
180
181    /// Mint a fresh random key (OS CSPRNG). Not persisted — pair with
182    /// [`Self::key_hex`] to store it, or use [`Self::load_or_generate`].
183    pub fn generate() -> Self {
184        let key = ChaCha20Poly1305::generate_key(&mut OsRng);
185        Self { key: key.into() }
186    }
187
188    /// The key as 64 hex chars (for persistence). Handle as a secret.
189    pub fn key_hex(&self) -> String {
190        to_hex(&self.key)
191    }
192
193    /// Parse a 64-hex-char key.
194    pub fn from_key_hex(hex: &str) -> Result<Self, CryptoError> {
195        let bytes = from_hex(hex).map_err(CryptoError::Key)?;
196        let key: [u8; 32] = bytes
197            .try_into()
198            .map_err(|_| CryptoError::Key("key must be 32 bytes (64 hex chars)".into()))?;
199        Ok(Self { key })
200    }
201
202    /// Load the key from `path`, or mint + persist a new one there (`0600` on
203    /// unix). The single-user "the key lives on my devices" story — a device
204    /// gets the key out of band (copy the file / a recovery phrase); this is
205    /// the local reference, not the login-derived distribution (the follow-up).
206    pub fn load_or_generate(path: &Path) -> Result<Self, CryptoError> {
207        if path.exists() {
208            let hex = std::fs::read_to_string(path)?;
209            return Self::from_key_hex(hex.trim());
210        }
211        let cipher = Self::generate();
212        if let Some(parent) = path.parent() {
213            std::fs::create_dir_all(parent)?;
214        }
215        // Create the key file 0600 from the FIRST byte (review): a
216        // `write` + later `chmod` leaves the 256-bit AEAD key in a
217        // world-readable file for the window between the two syscalls,
218        // and a swallowed chmod error would leave it 0600-claimed but
219        // 0644-real forever. `create_new` also refuses a symlink/TOCTOU
220        // swap at the path. The chmod failure is surfaced, never
221        // discarded.
222        #[cfg(unix)]
223        {
224            use std::io::Write;
225            use std::os::unix::fs::OpenOptionsExt;
226            let mut f = std::fs::OpenOptions::new()
227                .write(true)
228                .create_new(true)
229                .mode(0o600)
230                .open(path)?;
231            f.write_all(cipher.key_hex().as_bytes())?;
232            f.sync_all()?;
233        }
234        #[cfg(not(unix))]
235        {
236            std::fs::write(path, cipher.key_hex())?;
237        }
238        Ok(cipher)
239    }
240
241    fn aead(&self) -> ChaCha20Poly1305 {
242        ChaCha20Poly1305::new(Key::from_slice(&self.key))
243    }
244}
245
246impl PayloadCipher for LocalKeyCipher {
247    fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError> {
248        let bytes = serde_json::to_vec(plaintext)?;
249        let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
250        let ct = self
251            .aead()
252            .encrypt(&nonce, bytes.as_ref())
253            .map_err(|_| CryptoError::Decrypt)?;
254        let env = Envelope {
255            car_enc: ALG_CHACHA20POLY1305.to_string(),
256            nonce: to_hex(nonce.as_slice()),
257            ct: to_hex(&ct),
258        };
259        Ok(serde_json::to_value(env)?)
260    }
261
262    fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError> {
263        let env: Envelope = serde_json::from_value(envelope.clone())
264            .map_err(|e| CryptoError::BadEnvelope(e.to_string()))?;
265        if env.car_enc != ALG_CHACHA20POLY1305 {
266            return Err(CryptoError::BadEnvelope(format!(
267                "unknown algorithm tag {:?}",
268                env.car_enc
269            )));
270        }
271        let nonce_bytes = from_hex(&env.nonce).map_err(CryptoError::BadEnvelope)?;
272        if nonce_bytes.len() != 12 {
273            return Err(CryptoError::BadEnvelope("nonce must be 12 bytes".into()));
274        }
275        let ct = from_hex(&env.ct).map_err(CryptoError::BadEnvelope)?;
276        let nonce = Nonce::from_slice(&nonce_bytes);
277        let pt = self
278            .aead()
279            .decrypt(nonce, ct.as_ref())
280            .map_err(|_| CryptoError::Decrypt)?;
281        Ok(serde_json::from_slice(&pt)?)
282    }
283}
284
285/// The encryption **audience** a scope maps to — the set of principals whose
286/// key a payload under this scope is encrypted to. `Personal` → the user's own
287/// key; `Shared{org}` → the org key. The B4-pinned rule ("scopes are
288/// encryption audiences") uses this: a single ciphertext must have a single
289/// audience, so a whole-chain checkpoint mixing scopes cannot be one ciphertext.
290pub fn encryption_audience(scope: &Scope) -> String {
291    match scope {
292        Scope::Personal => "personal".to_string(),
293        Scope::Shared { org } => format!("org:{org}"),
294    }
295}
296
297// ---------------------------------------------------------------------------
298// Login-derived key distribution (B6).
299//
300// A single-user reference key (`LocalKeyCipher::load_or_generate`) doesn't scale
301// to "onboard once": a new device would have to copy a key file out of band.
302// Instead every device HKDF-derives its per-audience AEAD keys from ONE master
303// secret it gets from the Parslee login (per-user) / entitlements (per-org).
304// Same login → same master → same derived keys on every device (so they decrypt
305// each other), while "personal" and "org:<id>" audiences stay cryptographically
306// independent. The master never leaves the authenticated client — the relay
307// only ever holds ciphertext under a key it does not possess.
308// ---------------------------------------------------------------------------
309
310/// HKDF-SHA256 info prefix for CAR sync AEAD keys — bump `v1` on a KDF change.
311const KDF_INFO_PREFIX: &[u8] = b"car-sync/v1/aead/";
312/// A fixed (non-secret) HKDF salt. A constant makes derivation deterministic
313/// across a user's devices from the same master — the whole point.
314const KDF_SALT: &[u8] = b"car-sync/v1/salt";
315
316/// Derive a 256-bit AEAD key for `audience` from a login/entitlement `master`
317/// secret via HKDF-SHA256. Deterministic: the same `(master, audience)` yields
318/// the same key on every device (a user's Mac and phone decrypt each other's
319/// ops); distinct audiences ("personal" vs "org:<id>") yield independent keys.
320pub fn derive_key(master: &[u8], audience: &str) -> [u8; 32] {
321    let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(KDF_SALT), master);
322    let mut info = KDF_INFO_PREFIX.to_vec();
323    info.extend_from_slice(audience.as_bytes());
324    let mut okm = [0u8; 32];
325    hk.expand(&info, &mut okm)
326        .expect("32 bytes is a valid HKDF-SHA256 output length");
327    okm
328}
329
330/// Supplies the [`PayloadCipher`] for a scope's encryption audience. The daemon
331/// holds one and asks for a cipher per op-scope, so a remote relay only ever
332/// sees ciphertext under the right (personal / org) key.
333pub trait SyncKeyProvider: Send + Sync {
334    fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher>;
335}
336
337/// Derives per-audience [`LocalKeyCipher`]s from one login-derived master secret
338/// (HKDF-SHA256), caching by audience. The master comes from the Parslee login
339/// (per-user) / entitlements (per-org); it is NEVER sent to the relay.
340pub struct DerivedKeyProvider {
341    master: Vec<u8>,
342    cache: std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<LocalKeyCipher>>>,
343}
344
345impl std::fmt::Debug for DerivedKeyProvider {
346    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
347        f.debug_struct("DerivedKeyProvider").finish_non_exhaustive()
348    }
349}
350
351impl DerivedKeyProvider {
352    /// Build over a raw master secret (already stable across the user's devices,
353    /// e.g. a per-user sync key Parslee issued at login).
354    pub fn new(master: impl Into<Vec<u8>>) -> Self {
355        Self {
356            master: master.into(),
357            cache: std::sync::Mutex::new(std::collections::HashMap::new()),
358        }
359    }
360
361    /// Build from login material bound to `user_id` — HKDF the raw secret into a
362    /// stable per-user master so two logins for the same user converge on the
363    /// same keys and different users never collide.
364    pub fn from_login_secret(login_secret: &[u8], user_id: &str) -> Self {
365        Self::new(derive_key(login_secret, &format!("user/{user_id}")).to_vec())
366    }
367
368    /// Build from a user **sync passphrase** — the zero-knowledge cross-device
369    /// key source that needs NO server key distribution: every device on which
370    /// the user enters the same passphrase derives the same keys, and Parslee
371    /// (relay + platform) never sees it. `user_id` domain-separates users. This
372    /// is the recommended source today; a Parslee-issued per-user master
373    /// (delivered over the authenticated channel) is the alternative, and both
374    /// land here as the master secret.
375    pub fn from_passphrase(passphrase: &str, user_id: &str) -> Self {
376        Self::from_login_secret(passphrase.as_bytes(), user_id)
377    }
378
379    fn cipher_for_audience(&self, audience: &str) -> std::sync::Arc<dyn PayloadCipher> {
380        let mut cache = self.cache.lock().expect("key cache poisoned");
381        if let Some(c) = cache.get(audience) {
382            return c.clone();
383        }
384        let cipher =
385            std::sync::Arc::new(LocalKeyCipher::from_key(derive_key(&self.master, audience)));
386        cache.insert(audience.to_string(), cipher.clone());
387        cipher
388    }
389}
390
391impl SyncKeyProvider for DerivedKeyProvider {
392    fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher> {
393        self.cipher_for_audience(&encryption_audience(scope))
394    }
395}
396
397fn to_hex(bytes: &[u8]) -> String {
398    bytes.iter().map(|b| format!("{b:02x}")).collect()
399}
400
401fn from_hex(s: &str) -> Result<Vec<u8>, String> {
402    if !s.len().is_multiple_of(2) {
403        return Err("hex length must be even".into());
404    }
405    (0..s.len())
406        .step_by(2)
407        .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
408        .collect()
409}
410
411#[cfg(test)]
412mod tests {
413    use super::*;
414    use crate::oplog::{logical_clock, verify_log, DeviceLog, Surface};
415    use serde_json::json;
416
417    #[test]
418    fn local_key_cipher_round_trips() {
419        let cipher = LocalKeyCipher::generate();
420        let plaintext = json!({"id": "f1", "secret": "the launch codes", "n": 42});
421        let env = cipher.encrypt(&plaintext).unwrap();
422        assert!(Envelope::is_envelope(&env));
423        assert_eq!(cipher.decrypt(&env).unwrap(), plaintext);
424
425        // Randomized nonce: two encryptions of the same plaintext differ.
426        let env2 = cipher.encrypt(&plaintext).unwrap();
427        assert_ne!(env, env2, "each encryption uses a fresh nonce");
428        assert_eq!(cipher.decrypt(&env2).unwrap(), plaintext);
429    }
430
431    #[test]
432    fn encrypted_op_chain_verifies_and_relay_sees_only_ciphertext() {
433        // A device authors two ops with ENCRYPTED payloads. The op_id chain is
434        // ciphertext-native, so verify_log passes and the relay (which only
435        // ever holds op.payload) sees no plaintext — exactly the proposal's
436        // "the relay stores only ciphertext; op_id/hlc stay cleartext".
437        let cipher = LocalKeyCipher::generate();
438        let mut dev = DeviceLog::new("mac-a");
439        dev.set_wall_clock(logical_clock());
440
441        let secret1 = json!({"id": "f1", "body": "the sky is blue"});
442        let secret2 = json!({"id": "f2", "body": "water is wet"});
443        let op1 = dev.append(
444            Scope::Personal,
445            Surface::Knowledge,
446            cipher.encrypt(&secret1).unwrap(),
447        );
448        let op2 = dev.append(
449            Scope::Personal,
450            Surface::Knowledge,
451            cipher.encrypt(&secret2).unwrap(),
452        );
453
454        // The ciphertext chain verifies (op_id covers the ciphertext payload).
455        verify_log(&[op1.clone(), op2.clone()]).unwrap();
456        assert!(op1.id_valid());
457
458        // The wire form leaks nothing: no "body"/"id" fields, only the envelope.
459        for op in [&op1, &op2] {
460            assert!(Envelope::is_envelope(&op.payload));
461            assert!(op.payload.get("body").is_none());
462            assert!(op.payload.get("id").is_none());
463        }
464
465        // A peer holding the key recovers the plaintext.
466        assert_eq!(cipher.decrypt(&op1.payload).unwrap(), secret1);
467        assert_eq!(cipher.decrypt(&op2.payload).unwrap(), secret2);
468    }
469
470    #[test]
471    fn tampered_ciphertext_is_rejected() {
472        let cipher = LocalKeyCipher::generate();
473        let env = cipher.encrypt(&json!({"x": 1})).unwrap();
474
475        // Flip one hex nibble of the ciphertext → AEAD tag mismatch → refusal.
476        let mut tampered = env.clone();
477        let ct = tampered["ct"].as_str().unwrap().to_string();
478        let flipped: String = {
479            let mut chars: Vec<char> = ct.chars().collect();
480            chars[0] = if chars[0] == '0' { '1' } else { '0' };
481            chars.into_iter().collect()
482        };
483        tampered["ct"] = json!(flipped);
484        assert!(matches!(
485            cipher.decrypt(&tampered),
486            Err(CryptoError::Decrypt)
487        ));
488    }
489
490    #[test]
491    fn wrong_key_cannot_decrypt() {
492        let cipher = LocalKeyCipher::generate();
493        let other = LocalKeyCipher::generate();
494        let env = cipher.encrypt(&json!({"x": 1})).unwrap();
495        assert!(matches!(other.decrypt(&env), Err(CryptoError::Decrypt)));
496    }
497
498    #[test]
499    fn load_or_generate_persists_and_reloads_the_same_key() {
500        let dir = tempfile::tempdir().unwrap();
501        let path = dir.path().join("sync").join("personal.key");
502        let a = LocalKeyCipher::load_or_generate(&path).unwrap();
503        assert!(path.exists());
504        let b = LocalKeyCipher::load_or_generate(&path).unwrap();
505        assert_eq!(
506            a.key_hex(),
507            b.key_hex(),
508            "the persisted key reloads identically"
509        );
510
511        // And the reloaded key decrypts the first cipher's output (same key).
512        let env = a.encrypt(&json!({"k": "v"})).unwrap();
513        assert_eq!(b.decrypt(&env).unwrap(), json!({"k": "v"}));
514    }
515
516    #[test]
517    fn scope_maps_to_a_single_encryption_audience() {
518        assert_eq!(encryption_audience(&Scope::Personal), "personal");
519        assert_eq!(
520            encryption_audience(&Scope::Shared { org: "acme".into() }),
521            "org:acme"
522        );
523    }
524
525    #[test]
526    fn same_login_master_derives_interoperable_keys_across_devices() {
527        // Mac and phone each build a provider from the SAME login master. A
528        // payload the Mac encrypts under `Personal` must decrypt on the phone —
529        // the whole "my devices share config after one login" property.
530        let master = b"parslee-issued-per-user-sync-secret";
531        let mac = DerivedKeyProvider::new(master.to_vec());
532        let phone = DerivedKeyProvider::new(master.to_vec());
533
534        let secret = json!({"messaging_allowlist": ["+15551234567"]});
535        let env = mac.cipher_for(&Scope::Personal).encrypt(&secret).unwrap();
536        assert_eq!(
537            phone.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
538            secret
539        );
540    }
541
542    #[test]
543    fn personal_and_org_audiences_are_cryptographically_isolated() {
544        let p = DerivedKeyProvider::new(b"master".to_vec());
545        let env = p
546            .cipher_for(&Scope::Personal)
547            .encrypt(&json!({"x": 1}))
548            .unwrap();
549        // The org key cannot read a personal-audience ciphertext.
550        assert!(matches!(
551            p.cipher_for(&Scope::Shared { org: "acme".into() })
552                .decrypt(&env),
553            Err(CryptoError::Decrypt)
554        ));
555    }
556
557    #[test]
558    fn passphrase_derives_the_same_keys_on_every_device_zero_knowledge() {
559        // The zero-knowledge path: same passphrase + user → same keys, so the
560        // phone reads what the Mac wrote, with no server ever holding the key.
561        let mac = DerivedKeyProvider::from_passphrase("correct horse battery staple", "user-1");
562        let phone = DerivedKeyProvider::from_passphrase("correct horse battery staple", "user-1");
563        let env = mac
564            .cipher_for(&Scope::Personal)
565            .encrypt(&json!({"s": 1}))
566            .unwrap();
567        assert_eq!(
568            phone.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
569            json!({"s": 1})
570        );
571        // A wrong passphrase cannot read it.
572        let wrong = DerivedKeyProvider::from_passphrase("hunter2", "user-1");
573        assert!(matches!(
574            wrong.cipher_for(&Scope::Personal).decrypt(&env),
575            Err(CryptoError::Decrypt)
576        ));
577    }
578
579    #[test]
580    fn a_different_login_cannot_decrypt() {
581        let mine = DerivedKeyProvider::new(b"my-secret".to_vec());
582        let theirs = DerivedKeyProvider::new(b"their-secret".to_vec());
583        let env = mine
584            .cipher_for(&Scope::Personal)
585            .encrypt(&json!({"x": 1}))
586            .unwrap();
587        assert!(matches!(
588            theirs.cipher_for(&Scope::Personal).decrypt(&env),
589            Err(CryptoError::Decrypt)
590        ));
591    }
592
593    #[test]
594    fn from_login_secret_is_stable_per_user_and_distinct_across_users() {
595        let raw = b"raw-oauth-derived-material";
596        // Two sign-ins for the same user → same keys (idempotent onboarding).
597        let a = DerivedKeyProvider::from_login_secret(raw, "user-1");
598        let b = DerivedKeyProvider::from_login_secret(raw, "user-1");
599        let env = a
600            .cipher_for(&Scope::Personal)
601            .encrypt(&json!({"k": "v"}))
602            .unwrap();
603        assert_eq!(
604            b.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
605            json!({"k": "v"})
606        );
607        // A different user derives a different master → cannot decrypt.
608        let other = DerivedKeyProvider::from_login_secret(raw, "user-2");
609        assert!(matches!(
610            other.cipher_for(&Scope::Personal).decrypt(&env),
611            Err(CryptoError::Decrypt)
612        ));
613    }
614}