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}