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//! - **Client-side org-key agreement (authenticated)** — [`wrap_org_key`] /
57//! [`unwrap_org_key`] (ECIES over X25519, [`derive_x25519_identity`]) share ONE
58//! org master key `K_org` across all members so org-scoped ops are mutually
59//! readable, while the relay/platform never sees `K_org`. Each wrap is SIGNED by
60//! the publisher's Ed25519 identity ([`derive_ed25519_identity`]) and unwrap
61//! REFUSES any wrap not signed by a caller-trusted holder — closing the
62//! key-substitution hole. The directory ([`crate::org_key_directory`]),
63//! transport, and the consuming [`crate::org_key_provider::OrgAwareKeyProvider`]
64//! are built on top. It all stays inert; the `login_secret → identity` entropy
65//! dependency MUST be reviewed by a cryptographer before production (see the
66//! security notes above the identity functions).
67//!
68//! Remaining before activation: **per-scope encrypted checkpoint push** — a
69//! whole-chain checkpoint mixes `Personal` + `Shared{org}` audiences, so it must
70//! be split per scope key before it can be pushed to an untrusted relay to
71//! restore relay-side GC; the oplog **epoch field** + fold key-selection (so old
72//! epochs decrypt after **rotation**); the out-of-band `org → K_org` **resolver**
73//! that wires `OrgAwareKeyProvider` into the subsystem; and the cryptographer
74//! audit itself.
75
76use crate::oplog::{canonical_json, Scope};
77use serde::{Deserialize, Serialize};
78use serde_json::Value;
79use std::path::Path;
80
81use chacha20poly1305::aead::{Aead, AeadCore, KeyInit, OsRng, Payload};
82use chacha20poly1305::{ChaCha20Poly1305, Key, Nonce};
83use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
84use sha2::Digest;
85use x25519_dalek::{PublicKey, StaticSecret};
86use zeroize::{Zeroize, Zeroizing};
87
88/// The frozen algorithm tag written into every [`Envelope`] — lets a future
89/// cipher upgrade coexist (a decryptor rejects an unknown tag rather than
90/// mis-decoding).
91pub const ALG_CHACHA20POLY1305: &str = "chacha20poly1305";
92
93/// The ciphertext form of a payload — what the relay stores and sees. Cleartext
94/// `op_id`/`seq`/`hlc`/`scope`/`surface` metadata lives *outside* this, on the
95/// [`crate::oplog::OpRecord`]; the envelope hides only the payload body.
96#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
97pub struct Envelope {
98 /// Algorithm tag ([`ALG_CHACHA20POLY1305`]).
99 pub car_enc: String,
100 /// The 96-bit AEAD nonce, hex (24 chars). Random per encryption, so the
101 /// same plaintext encrypts to distinct ciphertext each time.
102 pub nonce: String,
103 /// The ciphertext ‖ Poly1305 tag, hex.
104 pub ct: String,
105 /// Org key-id (rotation EPOCH). ONLY the multi-epoch org cipher sets it, so a
106 /// remaining member can select the right per-epoch key after a rotation instead
107 /// of trial-decrypting. Personal / wrap envelopes omit it.
108 ///
109 /// `skip_serializing_if` is LOAD-BEARING: personal envelopes stay byte-identical
110 /// (the field never serializes), AND `wrap_org_key` signs `canonical_json(envelope)`
111 /// — so an always-omitted `kid` keeps every Ed25519 wrap signature valid. Do NOT
112 /// drop `skip_serializing_if`, and do NOT add `deny_unknown_fields`.
113 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub kid: Option<u64>,
115}
116
117impl Envelope {
118 /// Is this JSON value a ciphertext envelope (vs. a cleartext payload)?
119 pub fn is_envelope(v: &Value) -> bool {
120 v.get("car_enc").and_then(Value::as_str) == Some(ALG_CHACHA20POLY1305)
121 && v.get("nonce").is_some()
122 && v.get("ct").is_some()
123 }
124}
125
126/// A crypto-boundary failure.
127#[derive(Debug)]
128pub enum CryptoError {
129 /// Serializing the plaintext payload / deserializing the recovered plaintext.
130 Json(serde_json::Error),
131 /// The envelope is malformed, or its algorithm tag is unknown.
132 BadEnvelope(String),
133 /// AEAD open failed — a wrong key OR a tampered ciphertext/nonce (Poly1305
134 /// tag mismatch). Indistinguishable by design; both mean "do not trust".
135 Decrypt,
136 /// The persisted key file is the wrong length or unreadable.
137 Key(String),
138 /// I/O reading/writing the key file.
139 Io(std::io::Error),
140}
141
142impl std::fmt::Display for CryptoError {
143 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
144 match self {
145 CryptoError::Json(e) => write!(f, "crypto payload json error: {e}"),
146 CryptoError::BadEnvelope(d) => write!(f, "crypto envelope malformed: {d}"),
147 CryptoError::Decrypt => {
148 write!(
149 f,
150 "crypto decrypt failed (wrong key or tampered ciphertext)"
151 )
152 }
153 CryptoError::Key(d) => write!(f, "crypto key error: {d}"),
154 CryptoError::Io(e) => write!(f, "crypto io error: {e}"),
155 }
156 }
157}
158
159impl std::error::Error for CryptoError {}
160
161impl From<serde_json::Error> for CryptoError {
162 fn from(e: serde_json::Error) -> Self {
163 CryptoError::Json(e)
164 }
165}
166impl From<std::io::Error> for CryptoError {
167 fn from(e: std::io::Error) -> Self {
168 CryptoError::Io(e)
169 }
170}
171
172/// The encrypt/decrypt boundary. A device authors an E2E op with
173/// `cipher.encrypt(plaintext)` as its payload; a peer holding the key recovers
174/// it with `cipher.decrypt(&op.payload)`. Object-safe so a daemon can hold an
175/// `Arc<dyn PayloadCipher>` (a null/local reference now, a login-derived key
176/// later) without a type change.
177pub trait PayloadCipher: Send + Sync {
178 /// Encrypt a cleartext payload into a ciphertext [`Envelope`] (as a
179 /// `Value`).
180 fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError>;
181 /// Recover the cleartext payload from a ciphertext [`Envelope`]. Fails
182 /// ([`CryptoError::Decrypt`]) on a wrong key or any tamper.
183 fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError>;
184}
185
186/// The single-user reference cipher: a local 256-bit ChaCha20-Poly1305 key.
187///
188/// Genuinely linearizable-free confidentiality + integrity for the personal
189/// multi-device case. NOT a login-derived or org-distributed key — see the
190/// module's key-distribution follow-up.
191#[derive(Clone)]
192pub struct LocalKeyCipher {
193 key: [u8; 32],
194}
195
196impl Drop for LocalKeyCipher {
197 fn drop(&mut self) {
198 // Wipe the key on drop. Load-bearing for the org-key path: an org wrap key
199 // and (transitively) K_org get copied into a `LocalKeyCipher` via
200 // `from_key(*wrap_key)`, so the `Zeroizing` wrapper on the source no longer
201 // covers this copy — this Drop does. (Also the first org-SHARED subkey to
202 // live in a cache; blast radius is every member.)
203 self.key.zeroize();
204 }
205}
206
207impl std::fmt::Debug for LocalKeyCipher {
208 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
209 // Never print the key.
210 f.debug_struct("LocalKeyCipher").finish_non_exhaustive()
211 }
212}
213
214impl LocalKeyCipher {
215 /// Build a cipher over an explicit 256-bit key.
216 pub fn from_key(key: [u8; 32]) -> Self {
217 Self { key }
218 }
219
220 /// Mint a fresh random key (OS CSPRNG). Not persisted — pair with
221 /// [`Self::key_hex`] to store it, or use [`Self::load_or_generate`].
222 pub fn generate() -> Self {
223 let key = ChaCha20Poly1305::generate_key(&mut OsRng);
224 Self { key: key.into() }
225 }
226
227 /// The key as 64 hex chars (for persistence). Handle as a secret.
228 pub fn key_hex(&self) -> String {
229 to_hex(&self.key)
230 }
231
232 /// Parse a 64-hex-char key.
233 pub fn from_key_hex(hex: &str) -> Result<Self, CryptoError> {
234 let bytes = from_hex(hex).map_err(CryptoError::Key)?;
235 let key: [u8; 32] = bytes
236 .try_into()
237 .map_err(|_| CryptoError::Key("key must be 32 bytes (64 hex chars)".into()))?;
238 Ok(Self { key })
239 }
240
241 /// Load the key from `path`, or mint + persist a new one there (`0600` on
242 /// unix). The single-user "the key lives on my devices" story — a device
243 /// gets the key out of band (copy the file / a recovery phrase); this is
244 /// the local reference, not the login-derived distribution (the follow-up).
245 pub fn load_or_generate(path: &Path) -> Result<Self, CryptoError> {
246 if path.exists() {
247 let hex = std::fs::read_to_string(path)?;
248 return Self::from_key_hex(hex.trim());
249 }
250 let cipher = Self::generate();
251 if let Some(parent) = path.parent() {
252 // Owner-private, not `create_dir_all`: on Windows a new directory
253 // inherits its parent's ACLs, and this one is about to hold a
254 // 256-bit AEAD key. No-op difference on unix, where the file mode
255 // below already carries the guarantee.
256 car_secrets::ensure_private_dir(parent)?;
257 }
258 // Create the key file 0600 from the FIRST byte (review): a
259 // `write` + later `chmod` leaves the 256-bit AEAD key in a
260 // world-readable file for the window between the two syscalls,
261 // and a swallowed chmod error would leave it 0600-claimed but
262 // 0644-real forever. `create_new` also refuses a symlink/TOCTOU
263 // swap at the path. The chmod failure is surfaced, never
264 // discarded.
265 #[cfg(unix)]
266 {
267 use std::io::Write;
268 use std::os::unix::fs::OpenOptionsExt;
269 let mut f = std::fs::OpenOptions::new()
270 .write(true)
271 .create_new(true)
272 .mode(0o600)
273 .open(path)?;
274 f.write_all(cipher.key_hex().as_bytes())?;
275 f.sync_all()?;
276 }
277 #[cfg(not(unix))]
278 {
279 use std::io::Write;
280 // `create_private_file`, not `fs::write` + `harden_owner_only`, for
281 // the reason `harden_owner_only`'s own doc gives: it "preserv[es] the
282 // historical best-effort API" and "new privacy-bearing writes should
283 // use the fallible helpers". Best-effort here meant a rejected ACL was
284 // logged at warn and the key kept whatever access it was created with,
285 // three lines below a comment promising the unix mode failure is
286 // "surfaced, never discarded".
287 //
288 // It also creates the file with the owner-only DACL already applied
289 // rather than widening-then-narrowing, and refuses an existing path,
290 // which is the `create_new` TOCTOU refusal the unix branch relies on.
291 let mut f = car_secrets::create_private_file(path)?;
292 f.write_all(cipher.key_hex().as_bytes())?;
293 f.sync_all()?;
294 }
295 Ok(cipher)
296 }
297
298 fn aead(&self) -> ChaCha20Poly1305 {
299 ChaCha20Poly1305::new(Key::from_slice(&self.key))
300 }
301}
302
303impl PayloadCipher for LocalKeyCipher {
304 fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError> {
305 let bytes = serde_json::to_vec(plaintext)?;
306 let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
307 let ct = self
308 .aead()
309 .encrypt(&nonce, bytes.as_ref())
310 .map_err(|_| CryptoError::Decrypt)?;
311 let env = Envelope {
312 car_enc: ALG_CHACHA20POLY1305.to_string(),
313 nonce: to_hex(nonce.as_slice()),
314 ct: to_hex(&ct),
315 // Personal / single-key cipher: no epoch key-id (omitted on the wire).
316 kid: None,
317 };
318 Ok(serde_json::to_value(env)?)
319 }
320
321 fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError> {
322 let env: Envelope = serde_json::from_value(envelope.clone())
323 .map_err(|e| CryptoError::BadEnvelope(e.to_string()))?;
324 if env.car_enc != ALG_CHACHA20POLY1305 {
325 return Err(CryptoError::BadEnvelope(format!(
326 "unknown algorithm tag {:?}",
327 env.car_enc
328 )));
329 }
330 let nonce_bytes = from_hex(&env.nonce).map_err(CryptoError::BadEnvelope)?;
331 if nonce_bytes.len() != 12 {
332 return Err(CryptoError::BadEnvelope("nonce must be 12 bytes".into()));
333 }
334 let ct = from_hex(&env.ct).map_err(CryptoError::BadEnvelope)?;
335 let nonce = Nonce::from_slice(&nonce_bytes);
336 let pt = self
337 .aead()
338 .decrypt(nonce, ct.as_ref())
339 .map_err(|_| CryptoError::Decrypt)?;
340 Ok(serde_json::from_slice(&pt)?)
341 }
342}
343
344/// The ChaCha20-Poly1305 associated data binding an org payload to its exact
345/// `(algorithm, audience, epoch)`. Length-prefixed + domain-tagged so the bytes
346/// are injective and can't be reinterpreted under a future mode — the same
347/// discipline as [`wrap_sign_transcript`]. Authenticated by the AEAD tag: a
348/// tampered `car_enc`/audience/`kid` fails the open. Org path ONLY — personal
349/// envelopes carry no AAD (byte-identical).
350///
351/// The `v2` tag aligns with the wrap transcript version; there is no v1 payload
352/// AAD (the personal path has none), so don't hunt for one. All integers are
353/// little-endian to match [`wrap_sign_transcript`] — one endianness crate-wide.
354fn org_payload_aad(car_enc: &str, audience: &str, kid: u64) -> Vec<u8> {
355 let mut a = b"car-sync:payload:v2\0".to_vec();
356 let mut lp = |field: &[u8]| {
357 a.extend_from_slice(&(field.len() as u64).to_le_bytes());
358 a.extend_from_slice(field);
359 };
360 lp(car_enc.as_bytes());
361 lp(audience.as_bytes());
362 a.extend_from_slice(&kid.to_le_bytes());
363 a
364}
365
366/// A multi-epoch org cipher: holds the per-epoch derived audience keys and, on
367/// decrypt, selects EXACTLY ONE by the envelope's `kid` — it NEVER iterates the
368/// keyring (no trial-decrypt / wrong-key-acceptance). Encrypt always uses the
369/// NEWEST epoch held and stamps `kid`. This is what makes rotation expressible: a
370/// remaining member holding {N, N+1} decrypts old ops under N and new ops under
371/// N+1, deterministically. Fail-closed: an unknown `kid`, a missing `kid`, or an
372/// empty keyring all error (the op stays opaque) — never a wrong key.
373pub struct MultiEpochOrgCipher {
374 audience: String,
375 /// epoch → derived audience key (`derive_key(K_org@epoch, audience)`). BTreeMap
376 /// so `.last_key_value()` is the newest epoch.
377 keys: std::collections::BTreeMap<u64, Zeroizing<[u8; 32]>>,
378}
379
380impl std::fmt::Debug for MultiEpochOrgCipher {
381 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382 f.debug_struct("MultiEpochOrgCipher")
383 .field("audience", &self.audience)
384 .field("epochs", &self.keys.keys().collect::<Vec<_>>())
385 .finish_non_exhaustive()
386 }
387}
388
389impl MultiEpochOrgCipher {
390 /// Build over the per-epoch derived audience keys (see [`OrgAwareKeyProvider`]).
391 pub fn new(
392 audience: impl Into<String>,
393 keys: std::collections::BTreeMap<u64, Zeroizing<[u8; 32]>>,
394 ) -> Self {
395 Self {
396 audience: audience.into(),
397 keys,
398 }
399 }
400
401 fn aead_for(key: &[u8; 32]) -> ChaCha20Poly1305 {
402 ChaCha20Poly1305::new(Key::from_slice(key))
403 }
404}
405
406impl PayloadCipher for MultiEpochOrgCipher {
407 fn encrypt(&self, plaintext: &Value) -> Result<Value, CryptoError> {
408 // Newest epoch held. Empty keyring → fail closed (member holds no key).
409 let (&kid, key) = self
410 .keys
411 .last_key_value()
412 .ok_or_else(|| CryptoError::Key("org cipher has no epoch keys".into()))?;
413 let bytes = serde_json::to_vec(plaintext)?;
414 let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
415 let aad = org_payload_aad(ALG_CHACHA20POLY1305, &self.audience, kid);
416 let ct = Self::aead_for(key)
417 .encrypt(
418 &nonce,
419 Payload {
420 msg: bytes.as_ref(),
421 aad: &aad,
422 },
423 )
424 .map_err(|_| CryptoError::Decrypt)?;
425 let env = Envelope {
426 car_enc: ALG_CHACHA20POLY1305.to_string(),
427 nonce: to_hex(nonce.as_slice()),
428 ct: to_hex(&ct),
429 kid: Some(kid),
430 };
431 Ok(serde_json::to_value(env)?)
432 }
433
434 fn decrypt(&self, envelope: &Value) -> Result<Value, CryptoError> {
435 let env: Envelope = serde_json::from_value(envelope.clone())
436 .map_err(|e| CryptoError::BadEnvelope(e.to_string()))?;
437 if env.car_enc != ALG_CHACHA20POLY1305 {
438 return Err(CryptoError::BadEnvelope(format!(
439 "unknown algorithm tag {:?}",
440 env.car_enc
441 )));
442 }
443 // Org envelopes MUST carry a kid; select EXACTLY that epoch's key. No kid,
444 // or a kid we don't hold → fail closed. Never iterate / trial-decrypt.
445 let kid = env
446 .kid
447 .ok_or_else(|| CryptoError::BadEnvelope("org envelope missing epoch kid".into()))?;
448 let key = self
449 .keys
450 .get(&kid)
451 .ok_or_else(|| CryptoError::Key(format!("no org key held for epoch {kid}")))?;
452 let nonce_bytes = from_hex(&env.nonce).map_err(CryptoError::BadEnvelope)?;
453 if nonce_bytes.len() != 12 {
454 return Err(CryptoError::BadEnvelope("nonce must be 12 bytes".into()));
455 }
456 let ct = from_hex(&env.ct).map_err(CryptoError::BadEnvelope)?;
457 let nonce = Nonce::from_slice(&nonce_bytes);
458 let aad = org_payload_aad(ALG_CHACHA20POLY1305, &self.audience, kid);
459 let pt = Self::aead_for(key)
460 .decrypt(
461 nonce,
462 Payload {
463 msg: ct.as_ref(),
464 aad: &aad,
465 },
466 )
467 .map_err(|_| CryptoError::Decrypt)?;
468 Ok(serde_json::from_slice(&pt)?)
469 }
470}
471
472/// The encryption **audience** a scope maps to — the set of principals whose
473/// key a payload under this scope is encrypted to. `Personal` → the user's own
474/// key; `Shared{org}` → the org key. The B4-pinned rule ("scopes are
475/// encryption audiences") uses this: a single ciphertext must have a single
476/// audience, so a whole-chain checkpoint mixing scopes cannot be one ciphertext.
477pub fn encryption_audience(scope: &Scope) -> String {
478 match scope {
479 Scope::Personal => "personal".to_string(),
480 Scope::Shared { org } => format!("org:{org}"),
481 }
482}
483
484// ---------------------------------------------------------------------------
485// Login-derived key distribution (B6).
486//
487// A single-user reference key (`LocalKeyCipher::load_or_generate`) doesn't scale
488// to "onboard once": a new device would have to copy a key file out of band.
489// Instead every device HKDF-derives its per-audience AEAD keys from ONE master
490// secret it gets from the Parslee login (per-user) / entitlements (per-org).
491// Same login → same master → same derived keys on every device (so they decrypt
492// each other), while "personal" and "org:<id>" audiences stay cryptographically
493// independent. The master never leaves the authenticated client — the relay
494// only ever holds ciphertext under a key it does not possess.
495// ---------------------------------------------------------------------------
496
497/// HKDF-SHA256 info prefix for CAR sync AEAD keys — bump `v1` on a KDF change.
498const KDF_INFO_PREFIX: &[u8] = b"car-sync/v1/aead/";
499/// A fixed (non-secret) HKDF salt. A constant makes derivation deterministic
500/// across a user's devices from the same master — the whole point.
501const KDF_SALT: &[u8] = b"car-sync/v1/salt";
502
503/// Derive a 256-bit AEAD key for `audience` from a login/entitlement `master`
504/// secret via HKDF-SHA256. Deterministic: the same `(master, audience)` yields
505/// the same key on every device (a user's Mac and phone decrypt each other's
506/// ops); distinct audiences ("personal" vs "org:<id>") yield independent keys.
507pub fn derive_key(master: &[u8], audience: &str) -> [u8; 32] {
508 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(KDF_SALT), master);
509 let mut info = KDF_INFO_PREFIX.to_vec();
510 info.extend_from_slice(audience.as_bytes());
511 let mut okm = [0u8; 32];
512 hk.expand(&info, &mut okm)
513 .expect("32 bytes is a valid HKDF-SHA256 output length");
514 okm
515}
516
517/// The KDF profile a [`StretchedMaster`] was minted under. Versioned so that
518/// RAISING the Argon2id params later is an explicit re-key epoch (it changes every
519/// downstream key, so it folds into org-key rotation) — never a silent key fork.
520/// A device records which profile minted its current keys.
521#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
522pub enum KdfProfile {
523 /// Argon2id V0x13, m=64 MiB, t=3, p=1 — the launch profile for passphrases.
524 Argon2idV1,
525 /// HKDF-only — a Parslee-issued ≥256-bit secret (no stretch needed).
526 IssuedHkdfV1,
527}
528
529// Launch Argon2id params (OWASP-defensible; won't OOM an older phone).
530const ARGON2_M_COST_KIB: u32 = 65536; // 64 MiB
531const ARGON2_T_COST: u32 = 3;
532const ARGON2_P_COST: u32 = 1;
533/// Prefix for the DETERMINISTIC per-user Argon2id salt. Deterministic (not random)
534/// so a passphrase yields the same keys on every device with no server state; the
535/// salt is domain separation across users, NOT secrecy (the work factor is the
536/// memory-hardness). `user_id` MUST be canonical upstream (else two spellings fork
537/// keys and cross-device sync silently breaks).
538const KDF_ARGON2_SALT_PREFIX: &[u8] = b"car-sync/v1/argon2-salt/user:";
539
540fn argon2_salt(user_id: &str) -> [u8; 16] {
541 let mut h = sha2::Sha256::new();
542 h.update(KDF_ARGON2_SALT_PREFIX);
543 h.update(user_id.as_bytes());
544 let digest = h.finalize();
545 let mut salt = [0u8; 16];
546 salt.copy_from_slice(&digest[..16]);
547 salt
548}
549
550/// A high-entropy 32-byte master for ALL of a user's key derivations — the
551/// per-audience AEAD keys AND the X25519/Ed25519 identities derive from THIS (via
552/// HKDF), never from a raw passphrase. The type is the gate: an identity or
553/// audience key cannot be derived from an unstretched password, because those
554/// functions take `&StretchedMaster`, and the only ways to build one are the two
555/// constructors below.
556pub struct StretchedMaster {
557 bytes: Zeroizing<[u8; 32]>,
558 profile: KdfProfile,
559}
560
561impl std::fmt::Debug for StretchedMaster {
562 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563 f.debug_struct("StretchedMaster")
564 .field("profile", &self.profile)
565 .finish_non_exhaustive()
566 }
567}
568
569impl StretchedMaster {
570 /// Argon2id-stretch a (password-equivalent) passphrase into the master,
571 /// deterministically per user (see [`argon2_salt`]).
572 ///
573 /// SECURITY — mitigation, NOT closure: this raises the per-guess cost of a weak
574 /// passphrase but cannot mint entropy it never had. The user's identity PUBLIC
575 /// key is published (the platform maps `account_id → pubkey`), which is an
576 /// OFFLINE verification oracle — an attacker guesses a passphrase, stretches,
577 /// derives, and compares to the published key with NO network. Argon2id's
578 /// per-guess cost is then the entire wall; a short password still falls. A PAKE
579 /// removes the oracle and is the strictly stronger path. Passphrase policy,
580 /// param benchmarking, and the PAKE alternative still require a cryptographer
581 /// before production.
582 pub fn from_passphrase(passphrase: &[u8], user_id: &str) -> Self {
583 let salt = argon2_salt(user_id);
584 let params = argon2::Params::new(ARGON2_M_COST_KIB, ARGON2_T_COST, ARGON2_P_COST, Some(32))
585 .expect("fixed Argon2idV1 params are valid");
586 let argon =
587 argon2::Argon2::new(argon2::Algorithm::Argon2id, argon2::Version::V0x13, params);
588 let mut bytes = Zeroizing::new([0u8; 32]);
589 argon
590 .hash_password_into(passphrase, &salt, bytes.as_mut_slice())
591 .expect("argon2 with valid params + 32-byte output does not fail");
592 Self {
593 bytes,
594 profile: KdfProfile::Argon2idV1,
595 }
596 }
597
598 /// From a Parslee-issued high-entropy secret (the caller CERTIFIES it is
599 /// ≥256-bit). Skips Argon2id — stretching a strong key is pointless cost —
600 /// and HKDF-binds it to `user_id` for per-user domain separation.
601 pub fn from_issued_high_entropy(secret: &[u8], user_id: &str) -> Self {
602 Self {
603 bytes: Zeroizing::new(derive_key(secret, &format!("user/{user_id}"))),
604 profile: KdfProfile::IssuedHkdfV1,
605 }
606 }
607
608 /// The profile that minted this master (for the rotation/migration machinery).
609 pub fn profile(&self) -> KdfProfile {
610 self.profile
611 }
612
613 fn as_bytes(&self) -> &[u8; 32] {
614 &self.bytes
615 }
616}
617
618/// Supplies the [`PayloadCipher`] for a scope's encryption audience. The daemon
619/// holds one and asks for a cipher per op-scope, so a remote relay only ever
620/// sees ciphertext under the right (personal / org) key.
621pub trait SyncKeyProvider: Send + Sync {
622 fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher>;
623}
624
625/// Derives per-audience [`LocalKeyCipher`]s from one login-derived master secret
626/// (HKDF-SHA256), caching by audience. The master comes from the Parslee login
627/// (per-user) / entitlements (per-org); it is NEVER sent to the relay.
628pub struct DerivedKeyProvider {
629 master: Zeroizing<Vec<u8>>,
630 cache: std::sync::Mutex<std::collections::HashMap<String, std::sync::Arc<LocalKeyCipher>>>,
631}
632
633impl std::fmt::Debug for DerivedKeyProvider {
634 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
635 f.debug_struct("DerivedKeyProvider").finish_non_exhaustive()
636 }
637}
638
639impl DerivedKeyProvider {
640 /// Build over a raw 32-byte master (already high-entropy + stable across the
641 /// user's devices). Prefer [`Self::from_master`] with a [`StretchedMaster`].
642 pub fn new(master: impl Into<Vec<u8>>) -> Self {
643 Self {
644 master: Zeroizing::new(master.into()),
645 cache: std::sync::Mutex::new(std::collections::HashMap::new()),
646 }
647 }
648
649 /// Build the per-audience AEAD keys from a [`StretchedMaster`] — the same
650 /// master that mints the identity keys, so Argon2id runs ONCE at open and both
651 /// paths share it.
652 pub fn from_master(master: &StretchedMaster) -> Self {
653 Self::new(master.as_bytes().to_vec())
654 }
655
656 /// Build from a Parslee-issued high-entropy secret bound to `user_id` (no
657 /// Argon2id — the secret is already strong). The issued-key alternative to a
658 /// passphrase; both converge on a [`StretchedMaster`].
659 pub fn from_login_secret(login_secret: &[u8], user_id: &str) -> Self {
660 Self::from_master(&StretchedMaster::from_issued_high_entropy(
661 login_secret,
662 user_id,
663 ))
664 }
665
666 /// Build from a user **sync passphrase** — the zero-knowledge cross-device key
667 /// source that needs NO server key distribution: every device on which the
668 /// user enters the same passphrase derives the same keys, and Parslee (relay +
669 /// platform) never sees it. The passphrase is Argon2id-STRETCHED (see
670 /// [`StretchedMaster::from_passphrase`]) before any key derivation.
671 pub fn from_passphrase(passphrase: &str, user_id: &str) -> Self {
672 Self::from_master(&StretchedMaster::from_passphrase(
673 passphrase.as_bytes(),
674 user_id,
675 ))
676 }
677
678 fn cipher_for_audience(&self, audience: &str) -> std::sync::Arc<dyn PayloadCipher> {
679 let mut cache = self.cache.lock().expect("key cache poisoned");
680 if let Some(c) = cache.get(audience) {
681 return c.clone();
682 }
683 let cipher = std::sync::Arc::new(LocalKeyCipher::from_key(derive_key(
684 self.master.as_slice(),
685 audience,
686 )));
687 cache.insert(audience.to_string(), cipher.clone());
688 cipher
689 }
690}
691
692impl SyncKeyProvider for DerivedKeyProvider {
693 fn cipher_for(&self, scope: &Scope) -> std::sync::Arc<dyn PayloadCipher> {
694 self.cipher_for_audience(&encryption_audience(scope))
695 }
696}
697
698pub(crate) fn to_hex(bytes: &[u8]) -> String {
699 bytes.iter().map(|b| format!("{b:02x}")).collect()
700}
701
702pub(crate) fn from_hex(s: &str) -> Result<Vec<u8>, String> {
703 if !s.len().is_multiple_of(2) {
704 return Err("hex length must be even".into());
705 }
706 (0..s.len())
707 .step_by(2)
708 .map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|e| e.to_string()))
709 .collect()
710}
711
712// ---------------------------------------------------------------------------
713// Client-side ORG-key agreement (ECIES over X25519).
714//
715// The gap this closes: `DerivedKeyProvider` derives the org audience from each
716// member's OWN login master, so members end up with DIFFERENT org keys and
717// cannot read each other's org-scoped ops. The fix is a SHARED org master key
718// `K_org` that every member obtains — but the relay/platform must NEVER see it.
719//
720// So `K_org` is wrapped for each member with public-key encryption (ECIES): a
721// fresh ephemeral X25519 exchange to the member's identity key, HKDF over the
722// shared secret WITH THE FULL TRANSCRIPT BOUND (org, epoch, recipient, both
723// public keys), then the existing AEAD. The relay stores only the wrapped blob;
724// only the member's identity secret unwraps it. Established primitives, standard
725// construction — nothing invented.
726//
727// SECURITY — MUST be reviewed by a cryptographer before production:
728// * the login_secret → identity derivation (see `derive_x25519_identity`);
729// * whether org/epoch also belong in the AEAD AAD (not only the HKDF info);
730// * secret zeroization + at-rest protection of the identity secret;
731// * rotation-under-compromise and key recovery/escrow.
732// This module gives the pure wrap/unwrap + identity primitive; the relay org-key
733// surface, the oplog epoch field, rotation orchestration, and the
734// `SyncKeyProvider` swap-in are follow-ups.
735// ---------------------------------------------------------------------------
736
737/// HKDF info prefix for the deterministic per-user X25519 identity key. Its OWN
738/// namespace — an asymmetric identity key must not share the `derive_key`
739/// (`.../aead/`) domain-separation namespace with the symmetric AEAD keys.
740const KDF_INFO_X25519_ID: &[u8] = b"car-sync/v1/x25519-identity/v1/user:";
741/// HKDF domain for the Ed25519 SIGNING identity. DISTINCT label from the X25519
742/// identity domain above — HKDF-expand with different `info` yields independent
743/// sibling subkeys (no key reuse between the DH key and the signing key). The two
744/// labels must never collide.
745const KDF_INFO_ED25519_ID: &[u8] = b"car-sync/v1/ed25519-identity/v1/user:";
746
747/// Wire tag for a wrapped org-key blob. **v2** adds the publisher signature:
748/// every wrap is signed by the publisher's Ed25519 identity, and [`unwrap_org_key`]
749/// REFUSES any wrap not signed by a caller-trusted holder — closing the key
750/// SUBSTITUTION hole (v1 sealed a key TO a recipient but authenticated NO ONE, so
751/// any member could wrap an attacker-chosen `K_org'` to a victim). There is NO v1
752/// accept path in live unwrap: a downgrade to an unsigned wrap is structurally
753/// impossible, not policy-gated.
754pub const ALG_ORG_KEY_WRAP: &str = "org-key-wrap/v2";
755
756/// Derive a user's deterministic X25519 identity secret from their
757/// [`StretchedMaster`], so every device reconstructs the SAME keypair (the public
758/// key is published; the secret never leaves the device). 32 bytes of HKDF-SHA256
759/// output are a valid X25519 scalar (dalek clamps at DH time).
760///
761/// Takes a `&StretchedMaster` (not raw bytes) BY DESIGN: a password-equivalent
762/// passphrase must be Argon2id-stretched first (the memory-hard work factor the
763/// audit required), and the type forbids feeding an unstretched password here.
764/// SECURITY residual: the published public key is still an offline guessing oracle
765/// — see [`StretchedMaster::from_passphrase`]. Cryptographer review still required.
766pub fn derive_x25519_identity(master: &StretchedMaster, user_id: &str) -> StaticSecret {
767 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(KDF_SALT), master.as_bytes());
768 let mut info = KDF_INFO_X25519_ID.to_vec();
769 info.extend_from_slice(user_id.as_bytes());
770 let mut sk = [0u8; 32];
771 hk.expand(&info, &mut sk)
772 .expect("32 is a valid HKDF-SHA256 output length");
773 let secret = StaticSecret::from(sk);
774 sk.zeroize();
775 secret
776}
777
778/// The publishable public identity for a user (the platform maps
779/// `account_id → this` so a wrapper can find each member's key).
780pub fn x25519_public(secret: &StaticSecret) -> PublicKey {
781 PublicKey::from(secret)
782}
783
784/// Derive a user's deterministic Ed25519 SIGNING identity from their
785/// [`StretchedMaster`], under a domain distinct from the X25519 identity (see
786/// [`KDF_INFO_ED25519_ID`]). The publisher signs each wrap with this key so a
787/// recipient can reject wraps not authored by a trusted holder. The verifying key
788/// ([`ed25519_verifying`]) is published beside the X25519 public key.
789///
790/// Takes a `&StretchedMaster` for the same reason as [`derive_x25519_identity`] —
791/// the Argon2id stretch is enforced by the type, and the same offline-oracle
792/// residual applies.
793pub fn derive_ed25519_identity(master: &StretchedMaster, user_id: &str) -> SigningKey {
794 let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(KDF_SALT), master.as_bytes());
795 let mut info = KDF_INFO_ED25519_ID.to_vec();
796 info.extend_from_slice(user_id.as_bytes());
797 let mut seed = [0u8; 32];
798 hk.expand(&info, &mut seed)
799 .expect("32 is a valid HKDF-SHA256 output length");
800 let signing = SigningKey::from_bytes(&seed);
801 seed.zeroize();
802 signing
803}
804
805/// The publishable Ed25519 verifying key for a signing identity.
806pub fn ed25519_verifying(signing: &SigningKey) -> VerifyingKey {
807 signing.verifying_key()
808}
809
810/// An org id usable in a wrap: rejected (never rewritten) unless it is a strict
811/// ASCII slug `[A-Za-z0-9._-]+`. This blocks Unicode confusables and
812/// delimiter-injection at the crypto boundary and pins the exact bytes bound into
813/// BOTH the KDF transcript and the signature — so a caller cannot sign a different
814/// transcript for "Acme" than for "acme". Case-folding is deliberately NOT done
815/// here (the Turkish-İ / locale trap is itself a canonicalization bypass); the
816/// platform MUST mint ONE canonical opaque org id upstream (a tenant id), and this
817/// only enforces that it is well-formed and bound verbatim.
818pub fn require_canonical_org(org: &str) -> Result<(), CryptoError> {
819 if org.is_empty() {
820 return Err(CryptoError::Key("org id is empty".into()));
821 }
822 if !org
823 .bytes()
824 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
825 {
826 return Err(CryptoError::Key(format!(
827 "org id {org:?} is not a canonical ASCII slug [A-Za-z0-9._-]"
828 )));
829 }
830 Ok(())
831}
832
833/// The HKDF info that binds the FULL transcript of a wrap — org, epoch,
834/// recipient, the ephemeral pubkey, and the recipient pubkey. Binding all five
835/// makes each wrap blob non-transplantable across members/epochs/orgs and pins
836/// it to this exact exchange: a substituted `E` or `P` derives a different key,
837/// so the AEAD open fails. Under-binding here passes every functional test while
838/// silently breaking security — so it is bound in exactly one place, here.
839///
840/// The embedded `org-key-wrap/v1` label is the KDF-CONSTRUCTION version and is
841/// deliberately FROZEN — decoupled from the `v2` wire tag ([`ALG_ORG_KEY_WRAP`]).
842/// v1→v2 only ADDED the signature layer; the ECIES/KDF derivation is unchanged, so
843/// bumping this label would gratuitously break key derivation for no benefit.
844fn org_wrap_info(
845 org: &str,
846 epoch: u64,
847 recipient_user_id: &str,
848 e_pub: &PublicKey,
849 p_pub: &PublicKey,
850) -> String {
851 // LENGTH-PREFIX the free-form fields (`org`, `recipient_user_id`) so the
852 // transcript is injective: a reader consumes exactly N bytes after `=N:`, so
853 // no (org, epoch, recipient) tuple can collide with another via delimiter
854 // injection (e.g. an org id containing "/recipient:..."). Naive `/`+`:`
855 // interpolation is NOT injective when the ids are attacker-influenced.
856 // `epoch` is decimal-only and `E`/`P` are fixed-width hex, so they're safe.
857 format!(
858 "org-key-wrap/v1|org={}:{}|epoch={}|recipient={}:{}|E={}|P={}",
859 org.len(),
860 org,
861 epoch,
862 recipient_user_id.len(),
863 recipient_user_id,
864 to_hex(e_pub.as_bytes()),
865 to_hex(p_pub.as_bytes()),
866 )
867}
868
869/// The exact bytes the publisher SIGNS. Binds every field + the AEAD envelope +
870/// the publisher id, so no field/ciphertext is substitutable under a copied
871/// signature and the wrap is attributed non-repudiably to `publisher`. Each
872/// variable-length field is length-prefixed (8-byte LE) so the encoding is
873/// injective; `epoch` is fixed-width. The alg tag is included so a v2 signature
874/// can never be replayed under another version. Signs the AEAD ENVELOPE (its
875/// canonical JSON — nonce+ct+tag), NOT the plaintext. NOTE: what defends against
876/// key substitution is the SIGNATURE plus the per-wrap unique key (a fresh
877/// ephemeral DH → a distinct `wrap_key` per blob, and each blob is
878/// single-recipient) — NOT any commitment property of the AEAD: ChaCha20-Poly1305
879/// is NOT key-committing (Poly1305 is not collision-resistant on the key). Signing
880/// the ciphertext is sufficient here because the signature binds it; do not
881/// refactor this to lean on "the AEAD commits `k_org`" — it does not.
882fn wrap_sign_transcript(
883 org: &str,
884 epoch: u64,
885 recipient_user_id: &str,
886 publisher_user_id: &str,
887 e_pub: &PublicKey,
888 p_pub: &PublicKey,
889 envelope: &Value,
890) -> Vec<u8> {
891 fn lp(buf: &mut Vec<u8>, field: &[u8]) {
892 buf.extend_from_slice(&(field.len() as u64).to_le_bytes());
893 buf.extend_from_slice(field);
894 }
895 let mut t = Vec::new();
896 lp(&mut t, ALG_ORG_KEY_WRAP.as_bytes());
897 lp(&mut t, org.as_bytes());
898 t.extend_from_slice(&epoch.to_le_bytes());
899 lp(&mut t, recipient_user_id.as_bytes());
900 lp(&mut t, publisher_user_id.as_bytes());
901 lp(&mut t, e_pub.as_bytes());
902 lp(&mut t, p_pub.as_bytes());
903 lp(&mut t, canonical_json(envelope).as_bytes());
904 t
905}
906
907/// A shared org master key wrapped for one member — AUTHENTICATED ECIES over
908/// X25519. Sealed to the member's X25519 key (only their secret unwraps it) AND
909/// signed by the publisher's Ed25519 identity (only a caller-trusted publisher is
910/// accepted). Every member unwraps the SAME `k_org` and derives the org-audience
911/// AEAD key from it, so org-scoped ops become mutually readable.
912#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
913pub struct WrappedOrgKey {
914 /// Algorithm tag ([`ALG_ORG_KEY_WRAP`], "org-key-wrap/v2").
915 pub car_wrap: String,
916 pub org: String,
917 /// The key-rotation epoch this `k_org` belongs to (member removal bumps it).
918 pub epoch: u64,
919 /// The member this blob is addressed to. ADVISORY ONLY — not
920 /// integrity-protected for ROUTING: unwrap binds the CALLER's own `my_user_id`
921 /// into both the KDF and the signature transcript, not this field. Do not
922 /// route or authorize on it.
923 pub recipient: String,
924 /// The publisher (signer) user id. ADVISORY — a routing/label hint. unwrap
925 /// authorizes on the injected TRUSTED verifying-key set, NEVER on this field.
926 pub publisher: String,
927 /// Ephemeral X25519 public key (hex) — re-fed into the KDF on unwrap.
928 pub ephemeral_pub: String,
929 /// Recipient's X25519 public key (hex) — bound into the KDF.
930 pub recipient_pub: String,
931 /// AEAD envelope over `{ "k_org": <hex> }`.
932 pub envelope: Value,
933 /// Ed25519 signature (hex, 64 bytes) by the publisher over
934 /// [`wrap_sign_transcript`]. Verified with `verify_strict` against a trusted
935 /// key on unwrap, BEFORE any decrypt.
936 pub signature: String,
937}
938
939/// Mint a fresh org master key `K_org` from the OS CSPRNG. Returned in
940/// [`Zeroizing`] so it wipes on drop.
941///
942/// CRITICAL for rotation: a NEW epoch's `K_org` MUST come from here — an
943/// independent random draw — NEVER a hash/KDF chain from the previous epoch's
944/// key. A removed member still holds the old `K_org`; if `K_org@(N+1)` were any
945/// function of `K_org@N`, they could derive the new key and rotation would be
946/// theatre. There is deliberately no `derive_next_org_key(old)` API.
947pub fn generate_org_key() -> Zeroizing<[u8; 32]> {
948 Zeroizing::new(ChaCha20Poly1305::generate_key(&mut OsRng).into())
949}
950
951/// Wrap the shared org master key `k_org` for `recipient_pub` in `(org, epoch)`,
952/// SIGNED by `publisher_user_id`'s `signer`. A fresh ephemeral keypair per wrap;
953/// the full transcript is bound into the KDF; a low-order (degenerate) recipient
954/// key is rejected via the contributory check; the whole blob (incl. the AEAD
955/// envelope + publisher id) is signed so a recipient can reject wraps not authored
956/// by a trusted holder. `org` must be a canonical ASCII slug (see
957/// [`require_canonical_org`]).
958pub fn wrap_org_key(
959 k_org: &[u8; 32],
960 org: &str,
961 epoch: u64,
962 recipient_user_id: &str,
963 recipient_pub: &PublicKey,
964 publisher_user_id: &str,
965 signer: &SigningKey,
966) -> Result<WrappedOrgKey, CryptoError> {
967 require_canonical_org(org)?;
968 // Fresh ephemeral keypair (reuse the OS-CSPRNG path the AEAD already uses).
969 let mut e_bytes: [u8; 32] = ChaCha20Poly1305::generate_key(&mut OsRng).into();
970 let e_secret = StaticSecret::from(e_bytes);
971 e_bytes.zeroize();
972 let e_pub = PublicKey::from(&e_secret);
973
974 let shared = e_secret.diffie_hellman(recipient_pub);
975 if !shared.was_contributory() {
976 // Low-order / degenerate recipient key → all-zero shared secret. Refuse
977 // (dalek does NOT reject it for us).
978 return Err(CryptoError::Key(
979 "recipient public key is low-order (non-contributory DH)".into(),
980 ));
981 }
982 let wrap_key = zeroize::Zeroizing::new(derive_key(
983 shared.as_bytes(),
984 &org_wrap_info(org, epoch, recipient_user_id, &e_pub, recipient_pub),
985 ));
986 let envelope = LocalKeyCipher::from_key(*wrap_key).encrypt(&serde_json::json!({
987 "k_org": to_hex(k_org),
988 }))?;
989 // Sign the whole transcript (fields + envelope + publisher id).
990 let signature = signer.sign(&wrap_sign_transcript(
991 org,
992 epoch,
993 recipient_user_id,
994 publisher_user_id,
995 &e_pub,
996 recipient_pub,
997 &envelope,
998 ));
999 Ok(WrappedOrgKey {
1000 car_wrap: ALG_ORG_KEY_WRAP.to_string(),
1001 org: org.to_string(),
1002 epoch,
1003 recipient: recipient_user_id.to_string(),
1004 publisher: publisher_user_id.to_string(),
1005 ephemeral_pub: to_hex(e_pub.as_bytes()),
1006 recipient_pub: to_hex(recipient_pub.as_bytes()),
1007 envelope,
1008 signature: to_hex(&signature.to_bytes()),
1009 })
1010}
1011
1012/// Unwrap a [`WrappedOrgKey`] addressed to this member with their identity
1013/// secret — ONLY if it is signed by a key in `trusted`. Order is
1014/// verify-BEFORE-decrypt: the signature (over the full transcript, bound to THIS
1015/// caller's `my_user_id`) is checked with `verify_strict` against each trusted
1016/// verifying key FIRST; if none accept it, the blob is refused and no decrypt
1017/// runs. Then the transcript-bound key is re-derived from the PUBLIC blob and
1018/// `my_user_id`, so a wrong recipient / tampered ephemeral or recipient pubkey /
1019/// altered org/epoch also yield a different key and the AEAD open fails.
1020///
1021/// `trusted` is the caller's policy — the set of holders authorized to grant this
1022/// org's key (admin-designated granters; NOT all current holders). The blob's
1023/// advisory `publisher`/`recipient` fields are NEVER authorized on. There is no
1024/// v1 (unsigned) accept path — a downgrade is structurally impossible.
1025pub fn unwrap_org_key(
1026 wrapped: &WrappedOrgKey,
1027 my_secret: &StaticSecret,
1028 my_user_id: &str,
1029 trusted: &[VerifyingKey],
1030) -> Result<[u8; 32], CryptoError> {
1031 if wrapped.car_wrap != ALG_ORG_KEY_WRAP {
1032 return Err(CryptoError::BadEnvelope(format!(
1033 "unknown wrap tag {:?} (expected {ALG_ORG_KEY_WRAP})",
1034 wrapped.car_wrap
1035 )));
1036 }
1037 require_canonical_org(&wrapped.org)?;
1038 let e_pub = parse_x25519_pub(&wrapped.ephemeral_pub)?;
1039 let recipient_pub = parse_x25519_pub(&wrapped.recipient_pub)?;
1040
1041 // AUTHENTICATE FIRST — reject any wrap not signed by a trusted holder, before
1042 // touching the DH / AEAD. The transcript binds MY user id (not the blob's
1043 // advisory recipient), so a wrap the publisher signed for a DIFFERENT
1044 // recipient won't verify here.
1045 let sig_bytes: [u8; 64] = from_hex(&wrapped.signature)
1046 .map_err(CryptoError::Key)?
1047 .try_into()
1048 .map_err(|_| CryptoError::Key("signature must be 64 bytes".into()))?;
1049 let signature = Signature::from_bytes(&sig_bytes);
1050 let transcript = wrap_sign_transcript(
1051 &wrapped.org,
1052 wrapped.epoch,
1053 my_user_id,
1054 &wrapped.publisher,
1055 &e_pub,
1056 &recipient_pub,
1057 &wrapped.envelope,
1058 );
1059 let authenticated = trusted
1060 .iter()
1061 .any(|vk| vk.verify_strict(&transcript, &signature).is_ok());
1062 if !authenticated {
1063 return Err(CryptoError::Key(
1064 "wrap is not signed by any trusted holder — refusing (possible key substitution)"
1065 .into(),
1066 ));
1067 }
1068
1069 let shared = my_secret.diffie_hellman(&e_pub);
1070 if !shared.was_contributory() {
1071 return Err(CryptoError::Key(
1072 "ephemeral public key is low-order (non-contributory DH)".into(),
1073 ));
1074 }
1075 // Bind OUR user id (not the blob's claimed recipient): a blob is unwrappable
1076 // only if its wrapper used this exact user id AND our secret matches the
1077 // bound recipient pubkey.
1078 let wrap_key = zeroize::Zeroizing::new(derive_key(
1079 shared.as_bytes(),
1080 &org_wrap_info(
1081 &wrapped.org,
1082 wrapped.epoch,
1083 my_user_id,
1084 &e_pub,
1085 &recipient_pub,
1086 ),
1087 ));
1088 let pt = LocalKeyCipher::from_key(*wrap_key).decrypt(&wrapped.envelope)?;
1089 let k_hex = pt
1090 .get("k_org")
1091 .and_then(Value::as_str)
1092 .ok_or_else(|| CryptoError::BadEnvelope("wrapped payload missing k_org".into()))?;
1093 let bytes = from_hex(k_hex).map_err(CryptoError::Key)?;
1094 bytes
1095 .try_into()
1096 .map_err(|_| CryptoError::Key("k_org must be 32 bytes".into()))
1097}
1098
1099/// Parse a 64-hex-char X25519 public key — e.g. a member's published identity
1100/// pubkey ([`crate::org_key_directory::MemberPublicKey::public_hex`]) that a
1101/// granter wraps `K_org` against.
1102///
1103/// This validates ONLY the encoding (32 bytes of hex); it does NOT reject a
1104/// low-order / non-contributory point (`PublicKey::from([u8;32])` is infallible).
1105/// That rejection happens later, at wrap time, via `wrap_org_key`'s contributory
1106/// DH check — so callers must treat a successful parse as "well-encoded", not
1107/// "safe to wrap to".
1108pub fn parse_x25519_pub(hex: &str) -> Result<PublicKey, CryptoError> {
1109 let bytes = from_hex(hex).map_err(CryptoError::Key)?;
1110 let arr: [u8; 32] = bytes
1111 .try_into()
1112 .map_err(|_| CryptoError::Key("x25519 public key must be 32 bytes".into()))?;
1113 Ok(PublicKey::from(arr))
1114}
1115
1116/// Parse a 64-hex-char Ed25519 verifying (public) key — e.g. a configured trusted
1117/// granter key for the `trusted` set of [`unwrap_org_key`]. Rejects a bad length
1118/// or a non-canonical / small-order point (`VerifyingKey::from_bytes` validates).
1119pub fn parse_ed25519_verifying(hex: &str) -> Result<VerifyingKey, CryptoError> {
1120 let bytes = from_hex(hex).map_err(CryptoError::Key)?;
1121 let arr: [u8; 32] = bytes
1122 .try_into()
1123 .map_err(|_| CryptoError::Key("ed25519 verifying key must be 32 bytes".into()))?;
1124 VerifyingKey::from_bytes(&arr)
1125 .map_err(|e| CryptoError::Key(format!("invalid ed25519 verifying key: {e}")))
1126}
1127
1128#[cfg(test)]
1129mod org_key_tests {
1130 use super::*;
1131 use serde_json::json;
1132
1133 // Fast identity helpers for tests: issued-high-entropy (skips Argon2id) so the
1134 // suite stays fast, then the real derivation. Same `(secret, user)` shape as
1135 // the old raw-bytes fns, so the call sites below are a pure rename.
1136 fn x25519_id(secret: &[u8], user: &str) -> StaticSecret {
1137 crate::crypto::derive_x25519_identity(
1138 &StretchedMaster::from_issued_high_entropy(secret, user),
1139 user,
1140 )
1141 }
1142 fn ed25519_id(secret: &[u8], user: &str) -> SigningKey {
1143 crate::crypto::derive_ed25519_identity(
1144 &StretchedMaster::from_issued_high_entropy(secret, user),
1145 user,
1146 )
1147 }
1148
1149 // A trusted granter — the admin-designated holder authorized to grant this
1150 // org's key. Its verifying key is the recipient's `trusted` policy.
1151 fn granter() -> SigningKey {
1152 ed25519_id(b"granter-login-secret", "acc_granter")
1153 }
1154 fn trusted() -> Vec<VerifyingKey> {
1155 vec![ed25519_verifying(&granter())]
1156 }
1157 // Wrap authored by the trusted granter (the common happy-path publisher).
1158 fn wrap_by_granter(
1159 k_org: &[u8; 32],
1160 org: &str,
1161 epoch: u64,
1162 recipient: &str,
1163 recipient_pub: &PublicKey,
1164 ) -> WrappedOrgKey {
1165 wrap_org_key(
1166 k_org,
1167 org,
1168 epoch,
1169 recipient,
1170 recipient_pub,
1171 "acc_granter",
1172 &granter(),
1173 )
1174 .unwrap()
1175 }
1176
1177 #[test]
1178 fn org_key_wrap_round_trips_for_the_recipient() {
1179 let alice = x25519_id(b"alice-login-secret", "acc_alice");
1180 let k_org = [7u8; 32];
1181 let wrapped = wrap_by_granter(&k_org, "acme", 1, "acc_alice", &x25519_public(&alice));
1182 assert_eq!(
1183 unwrap_org_key(&wrapped, &alice, "acc_alice", &trusted()).unwrap(),
1184 k_org
1185 );
1186 }
1187
1188 #[test]
1189 fn substitution_by_untrusted_publisher_is_refused() {
1190 // THE FIX. Mallory is a real org member (so the backend lets her publish),
1191 // and she seals an attacker-chosen K_org' to Alice's REAL pubkey — the
1192 // AEAD would open fine. But Mallory's signing key is NOT in Alice's trusted
1193 // set, so unwrap refuses BEFORE decrypting. Under v1 Alice would have
1194 // adopted the attacker's key.
1195 let alice = x25519_id(b"alice", "acc_alice");
1196 let mallory = ed25519_id(b"mallory-login", "acc_mallory");
1197 let k_org_evil = [0xEEu8; 32];
1198 let poisoned = wrap_org_key(
1199 &k_org_evil,
1200 "acme",
1201 1,
1202 "acc_alice",
1203 &x25519_public(&alice),
1204 "acc_mallory",
1205 &mallory,
1206 )
1207 .unwrap();
1208 let err = unwrap_org_key(&poisoned, &alice, "acc_alice", &trusted()).unwrap_err();
1209 assert!(matches!(err, CryptoError::Key(_)));
1210 // ...and it WOULD have opened if Mallory were trusted (proves the AEAD
1211 // itself doesn't distinguish the keys — only the signature policy does).
1212 let mallory_trusted = vec![ed25519_verifying(&mallory)];
1213 assert_eq!(
1214 unwrap_org_key(&poisoned, &alice, "acc_alice", &mallory_trusted).unwrap(),
1215 k_org_evil
1216 );
1217 }
1218
1219 #[test]
1220 fn wrap_for_a_different_recipient_cannot_be_replayed() {
1221 // The granter signs a wrap FOR bob; alice (trusting the granter) must not
1222 // be able to unwrap it as herself — the signature transcript binds the
1223 // recipient, and unwrap rebuilds it with the CALLER's id.
1224 let alice = x25519_id(b"alice", "acc_alice");
1225 let bob = x25519_id(b"bob", "acc_bob");
1226 let for_bob = wrap_by_granter(&[9u8; 32], "acme", 1, "acc_bob", &x25519_public(&bob));
1227 assert!(unwrap_org_key(&for_bob, &alice, "acc_alice", &trusted()).is_err());
1228 }
1229
1230 #[test]
1231 fn org_key_does_not_unwrap_for_a_different_member_or_id() {
1232 let alice = x25519_id(b"alice-secret", "acc_alice");
1233 let bob = x25519_id(b"bob-secret", "acc_bob");
1234 let k_org = [9u8; 32];
1235 let wrapped = wrap_by_granter(&k_org, "acme", 1, "acc_alice", &x25519_public(&alice));
1236 // Bob's secret can't unwrap Alice's blob (and his id fails the sig transcript).
1237 assert!(unwrap_org_key(&wrapped, &bob, "acc_bob", &trusted()).is_err());
1238 // Nor can Alice unwrap it while claiming a different id (id binding).
1239 assert!(unwrap_org_key(&wrapped, &alice, "acc_bob", &trusted()).is_err());
1240 }
1241
1242 #[test]
1243 fn org_key_unwrap_rejects_tampered_transcript_and_ciphertext() {
1244 let alice = x25519_id(b"alice-secret", "acc_alice");
1245 let k_org = [3u8; 32];
1246 let base = wrap_by_granter(&k_org, "acme", 1, "acc_alice", &x25519_public(&alice));
1247 let bob_pub = x25519_public(&x25519_id(b"bob", "acc_bob"));
1248 let unwrap = |t: &WrappedOrgKey| unwrap_org_key(t, &alice, "acc_alice", &trusted());
1249
1250 // Every field is under the signature now — any tamper fails verify first.
1251 // Tamper the AEAD ciphertext (flip the last hex char).
1252 let mut t = base.clone();
1253 let ct = t.envelope["ct"].as_str().unwrap().to_string();
1254 let mut chars: Vec<char> = ct.chars().collect();
1255 let last = chars.len() - 1;
1256 chars[last] = if chars[last] == '0' { '1' } else { '0' };
1257 t.envelope["ct"] = json!(chars.into_iter().collect::<String>());
1258 assert!(unwrap(&t).is_err());
1259
1260 // Tamper the ephemeral / recipient pubkey, epoch, org, publisher label —
1261 // every one is under the signature, so each fails verify.
1262 let mut t = base.clone();
1263 t.ephemeral_pub = to_hex(bob_pub.as_bytes());
1264 assert!(unwrap(&t).is_err());
1265 let mut t = base.clone();
1266 t.recipient_pub = to_hex(bob_pub.as_bytes());
1267 assert!(unwrap(&t).is_err());
1268 let mut t = base.clone();
1269 t.epoch = 2;
1270 assert!(unwrap(&t).is_err());
1271 let mut t = base.clone();
1272 t.org = "evil".into();
1273 assert!(unwrap(&t).is_err());
1274 let mut t = base.clone();
1275 t.publisher = "acc_mallory".into();
1276 assert!(unwrap(&t).is_err());
1277
1278 // Tamper the signature itself.
1279 let mut t = base.clone();
1280 let mut sig: Vec<char> = t.signature.chars().collect();
1281 sig[0] = if sig[0] == '0' { '1' } else { '0' };
1282 t.signature = sig.into_iter().collect();
1283 assert!(unwrap(&t).is_err());
1284 }
1285
1286 #[test]
1287 fn identities_are_deterministic_and_domain_separated() {
1288 // Same login+user → same X25519 AND same Ed25519 key; the two are derived
1289 // under DISTINCT HKDF domains, so neither reveals the other.
1290 let a = x25519_id(b"same-login", "acc_x");
1291 let b = x25519_id(b"same-login", "acc_x");
1292 assert_eq!(x25519_public(&a).as_bytes(), x25519_public(&b).as_bytes());
1293 let s1 = ed25519_id(b"same-login", "acc_x");
1294 let s2 = ed25519_id(b"same-login", "acc_x");
1295 assert_eq!(
1296 ed25519_verifying(&s1).to_bytes(),
1297 ed25519_verifying(&s2).to_bytes()
1298 );
1299 // Different user / different login → different keys, both curves.
1300 assert_ne!(
1301 x25519_public(&a).as_bytes(),
1302 x25519_public(&x25519_id(b"same-login", "acc_y")).as_bytes()
1303 );
1304 assert_ne!(
1305 ed25519_verifying(&s1).to_bytes(),
1306 ed25519_verifying(&ed25519_id(b"other-login", "acc_x")).to_bytes()
1307 );
1308 // The X25519 and Ed25519 32-byte public encodings must not coincide.
1309 assert_ne!(
1310 x25519_public(&a).as_bytes(),
1311 &ed25519_verifying(&s1).to_bytes()
1312 );
1313 }
1314
1315 #[test]
1316 fn argon2id_stretch_is_deterministic_and_domain_separated() {
1317 // Cross-device determinism: same passphrase + user → same stretched master
1318 // → same published identity keys (the property the whole scheme rests on).
1319 let a = StretchedMaster::from_passphrase(b"correct horse battery staple", "acc_u1");
1320 let b = StretchedMaster::from_passphrase(b"correct horse battery staple", "acc_u1");
1321 assert_eq!(a.profile(), KdfProfile::Argon2idV1);
1322 assert_eq!(
1323 x25519_public(&derive_x25519_identity(&a, "acc_u1")).as_bytes(),
1324 x25519_public(&derive_x25519_identity(&b, "acc_u1")).as_bytes(),
1325 "same passphrase+user must derive the same identity on every device"
1326 );
1327 // Different passphrase OR different user → different keys (salt + info).
1328 let diff_pass = StretchedMaster::from_passphrase(b"hunter2", "acc_u1");
1329 assert_ne!(
1330 x25519_public(&derive_x25519_identity(&a, "acc_u1")).as_bytes(),
1331 x25519_public(&derive_x25519_identity(&diff_pass, "acc_u1")).as_bytes()
1332 );
1333 let diff_user = StretchedMaster::from_passphrase(b"correct horse battery staple", "acc_u2");
1334 assert_ne!(
1335 x25519_public(&derive_x25519_identity(&a, "acc_u1")).as_bytes(),
1336 x25519_public(&derive_x25519_identity(&diff_user, "acc_u2")).as_bytes(),
1337 "the per-user Argon2id salt + HKDF info domain-separate users"
1338 );
1339 // The issued-high-entropy path carries a distinct profile and (being a
1340 // different derivation) yields different keys than the Argon2id path.
1341 let issued =
1342 StretchedMaster::from_issued_high_entropy(b"correct horse battery staple", "acc_u1");
1343 assert_eq!(issued.profile(), KdfProfile::IssuedHkdfV1);
1344 assert_ne!(
1345 x25519_public(&derive_x25519_identity(&a, "acc_u1")).as_bytes(),
1346 x25519_public(&derive_x25519_identity(&issued, "acc_u1")).as_bytes()
1347 );
1348 }
1349
1350 #[test]
1351 fn wrap_rejects_low_order_recipient_key() {
1352 // The all-zero X25519 point is low-order → non-contributory DH.
1353 let low_order = PublicKey::from([0u8; 32]);
1354 assert!(wrap_org_key(
1355 &[1u8; 32],
1356 "acme",
1357 1,
1358 "acc_x",
1359 &low_order,
1360 "acc_granter",
1361 &granter()
1362 )
1363 .is_err());
1364 }
1365
1366 #[test]
1367 fn non_canonical_org_is_rejected_on_wrap_and_unwrap() {
1368 let alice = x25519_id(b"alice", "acc_alice");
1369 // Wrap refuses a non-slug org (Unicode / delimiter / space).
1370 for bad in ["Acme corp", "org/evil", "acmé", ""] {
1371 assert!(
1372 wrap_org_key(
1373 &[1u8; 32],
1374 bad,
1375 1,
1376 "acc_alice",
1377 &x25519_public(&alice),
1378 "acc_granter",
1379 &granter()
1380 )
1381 .is_err(),
1382 "wrap must reject non-canonical org {bad:?}"
1383 );
1384 }
1385 // A blob whose org is mutated after signing is refused. ("Acme" is itself
1386 // a valid slug — uppercase is allowed — so this is caught by the SIGNATURE
1387 // layer, not require_canonical_org: the transcript diverges. Upstream the
1388 // platform must mint ONE canonical org id so "Acme"/"acme" never coexist.)
1389 let mut t = wrap_by_granter(&[1u8; 32], "acme", 1, "acc_alice", &x25519_public(&alice));
1390 t.org = "Acme".into();
1391 assert!(unwrap_org_key(&t, &alice, "acc_alice", &trusted()).is_err());
1392 }
1393
1394 #[test]
1395 fn org_wrap_info_is_injective_under_delimiter_injection() {
1396 let e = x25519_public(&x25519_id(b"e", "e"));
1397 let p = x25519_public(&x25519_id(b"p", "p"));
1398 // These two (org, recipient) tuples collide under naive `/`+`:`
1399 // interpolation; length-prefixing must keep the transcript distinct.
1400 let a = org_wrap_info("acme/epoch:1/recipient:mallory", 1, "acc_alice", &e, &p);
1401 let b = org_wrap_info("acme", 1, "mallory/epoch:1/recipient:acc_alice", &e, &p);
1402 assert_ne!(a, b, "delimiter injection must not collide the transcript");
1403 }
1404
1405 #[test]
1406 fn all_members_derive_the_same_org_audience_key() {
1407 // The whole point: with a SHARED k_org, every member derives the SAME
1408 // org-audience AEAD key — closing the gap the old per-user derivation
1409 // left (where each member got a different, non-interoperable org key).
1410 let alice = x25519_id(b"alice", "acc_alice");
1411 let bob = x25519_id(b"bob", "acc_bob");
1412 let k_org = [42u8; 32];
1413 let ka = unwrap_org_key(
1414 &wrap_by_granter(&k_org, "acme", 1, "acc_alice", &x25519_public(&alice)),
1415 &alice,
1416 "acc_alice",
1417 &trusted(),
1418 )
1419 .unwrap();
1420 let kb = unwrap_org_key(
1421 &wrap_by_granter(&k_org, "acme", 1, "acc_bob", &x25519_public(&bob)),
1422 &bob,
1423 "acc_bob",
1424 &trusted(),
1425 )
1426 .unwrap();
1427 assert_eq!(ka, kb);
1428 assert_eq!(
1429 derive_key(&ka, "org:acme/epoch:1"),
1430 derive_key(&kb, "org:acme/epoch:1")
1431 );
1432 }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437 use super::*;
1438 use crate::oplog::{logical_clock, verify_log, DeviceLog, Surface};
1439 use serde_json::json;
1440
1441 #[test]
1442 fn personal_envelope_omits_kid_on_the_wire() {
1443 // LOAD-BEARING: personal (LocalKeyCipher) envelopes must serialize WITHOUT a
1444 // `kid` field, byte-identical to pre-org-scope envelopes. `wrap_org_key`
1445 // signs `canonical_json(envelope)`, so a stray always-null `kid` would break
1446 // every existing wrap signature. This pins `skip_serializing_if`.
1447 let cipher = LocalKeyCipher::generate();
1448 let env = cipher.encrypt(&json!({"x": 1})).unwrap();
1449 let obj = env.as_object().unwrap();
1450 assert!(!obj.contains_key("kid"), "personal envelope must omit kid");
1451 assert_eq!(
1452 obj.keys()
1453 .cloned()
1454 .collect::<std::collections::BTreeSet<_>>(),
1455 ["car_enc", "ct", "nonce"]
1456 .into_iter()
1457 .map(String::from)
1458 .collect::<std::collections::BTreeSet<_>>(),
1459 "exactly the legacy three fields"
1460 );
1461 }
1462
1463 #[test]
1464 fn multi_epoch_org_cipher_selects_by_kid_and_fails_closed() {
1465 // Two epochs held. Encrypt uses the NEWEST; each envelope carries its kid;
1466 // decrypt selects EXACTLY that epoch's key — never trial-decrypts. An
1467 // envelope whose kid we don't hold fails closed.
1468 let mut keys = std::collections::BTreeMap::new();
1469 keys.insert(1u64, Zeroizing::new([1u8; 32]));
1470 keys.insert(2u64, Zeroizing::new([2u8; 32]));
1471 let cipher = MultiEpochOrgCipher::new("org:acme", keys);
1472
1473 let msg = json!({"shared": "brain"});
1474 let env = cipher.encrypt(&msg).unwrap();
1475 assert_eq!(
1476 env.get("kid").and_then(|v| v.as_u64()),
1477 Some(2),
1478 "encrypts under the newest epoch"
1479 );
1480 assert_eq!(cipher.decrypt(&env).unwrap(), msg);
1481
1482 // Only epoch 1 held → an op stamped kid=2 is opaque (fail closed).
1483 let mut only1 = std::collections::BTreeMap::new();
1484 only1.insert(1u64, Zeroizing::new([1u8; 32]));
1485 let e1 = MultiEpochOrgCipher::new("org:acme", only1);
1486 assert!(matches!(e1.decrypt(&env), Err(CryptoError::Key(_))));
1487 }
1488
1489 #[test]
1490 fn org_cipher_aad_binds_epoch_and_audience() {
1491 // The AAD binds (algorithm, audience, kid). Tampering the kid on the wire, or
1492 // replaying the ciphertext under a cipher for a DIFFERENT audience, both fail
1493 // the AEAD open — never a silent wrong-context accept.
1494 let mut keys = std::collections::BTreeMap::new();
1495 keys.insert(5u64, Zeroizing::new([5u8; 32]));
1496 let acme = MultiEpochOrgCipher::new("org:acme", keys.clone());
1497 let env = acme.encrypt(&json!({"m": 1})).unwrap();
1498
1499 // Same key bytes, different audience string → AAD differs → Decrypt error.
1500 let globex = MultiEpochOrgCipher::new("org:globex", keys);
1501 assert!(matches!(globex.decrypt(&env), Err(CryptoError::Decrypt)));
1502
1503 // Flip the kid to an epoch we DO hold but that wasn't used → AAD mismatch.
1504 let mut two = std::collections::BTreeMap::new();
1505 two.insert(5u64, Zeroizing::new([5u8; 32]));
1506 two.insert(6u64, Zeroizing::new([5u8; 32])); // same bytes, different epoch
1507 let acme2 = MultiEpochOrgCipher::new("org:acme", two);
1508 let mut tampered = env.clone();
1509 tampered["kid"] = json!(6);
1510 assert!(matches!(
1511 acme2.decrypt(&tampered),
1512 Err(CryptoError::Decrypt)
1513 ));
1514 }
1515
1516 #[test]
1517 fn local_key_cipher_round_trips() {
1518 let cipher = LocalKeyCipher::generate();
1519 let plaintext = json!({"id": "f1", "secret": "the launch codes", "n": 42});
1520 let env = cipher.encrypt(&plaintext).unwrap();
1521 assert!(Envelope::is_envelope(&env));
1522 assert_eq!(cipher.decrypt(&env).unwrap(), plaintext);
1523
1524 // Randomized nonce: two encryptions of the same plaintext differ.
1525 let env2 = cipher.encrypt(&plaintext).unwrap();
1526 assert_ne!(env, env2, "each encryption uses a fresh nonce");
1527 assert_eq!(cipher.decrypt(&env2).unwrap(), plaintext);
1528 }
1529
1530 #[test]
1531 fn encrypted_op_chain_verifies_and_relay_sees_only_ciphertext() {
1532 // A device authors two ops with ENCRYPTED payloads. The op_id chain is
1533 // ciphertext-native, so verify_log passes and the relay (which only
1534 // ever holds op.payload) sees no plaintext — exactly the proposal's
1535 // "the relay stores only ciphertext; op_id/hlc stay cleartext".
1536 let cipher = LocalKeyCipher::generate();
1537 let mut dev = DeviceLog::new("mac-a");
1538 dev.set_wall_clock(logical_clock());
1539
1540 let secret1 = json!({"id": "f1", "body": "the sky is blue"});
1541 let secret2 = json!({"id": "f2", "body": "water is wet"});
1542 let op1 = dev.append(
1543 Scope::Personal,
1544 Surface::Knowledge,
1545 cipher.encrypt(&secret1).unwrap(),
1546 );
1547 let op2 = dev.append(
1548 Scope::Personal,
1549 Surface::Knowledge,
1550 cipher.encrypt(&secret2).unwrap(),
1551 );
1552
1553 // The ciphertext chain verifies (op_id covers the ciphertext payload).
1554 verify_log(&[op1.clone(), op2.clone()]).unwrap();
1555 assert!(op1.id_valid());
1556
1557 // The wire form leaks nothing: no "body"/"id" fields, only the envelope.
1558 for op in [&op1, &op2] {
1559 assert!(Envelope::is_envelope(&op.payload));
1560 assert!(op.payload.get("body").is_none());
1561 assert!(op.payload.get("id").is_none());
1562 }
1563
1564 // A peer holding the key recovers the plaintext.
1565 assert_eq!(cipher.decrypt(&op1.payload).unwrap(), secret1);
1566 assert_eq!(cipher.decrypt(&op2.payload).unwrap(), secret2);
1567 }
1568
1569 #[test]
1570 fn tampered_ciphertext_is_rejected() {
1571 let cipher = LocalKeyCipher::generate();
1572 let env = cipher.encrypt(&json!({"x": 1})).unwrap();
1573
1574 // Flip one hex nibble of the ciphertext → AEAD tag mismatch → refusal.
1575 let mut tampered = env.clone();
1576 let ct = tampered["ct"].as_str().unwrap().to_string();
1577 let flipped: String = {
1578 let mut chars: Vec<char> = ct.chars().collect();
1579 chars[0] = if chars[0] == '0' { '1' } else { '0' };
1580 chars.into_iter().collect()
1581 };
1582 tampered["ct"] = json!(flipped);
1583 assert!(matches!(
1584 cipher.decrypt(&tampered),
1585 Err(CryptoError::Decrypt)
1586 ));
1587 }
1588
1589 #[test]
1590 fn wrong_key_cannot_decrypt() {
1591 let cipher = LocalKeyCipher::generate();
1592 let other = LocalKeyCipher::generate();
1593 let env = cipher.encrypt(&json!({"x": 1})).unwrap();
1594 assert!(matches!(other.decrypt(&env), Err(CryptoError::Decrypt)));
1595 }
1596
1597 #[test]
1598 fn load_or_generate_persists_and_reloads_the_same_key() {
1599 let dir = tempfile::tempdir().unwrap();
1600 let path = dir.path().join("sync").join("personal.key");
1601 let a = LocalKeyCipher::load_or_generate(&path).unwrap();
1602 assert!(path.exists());
1603 let b = LocalKeyCipher::load_or_generate(&path).unwrap();
1604 assert_eq!(
1605 a.key_hex(),
1606 b.key_hex(),
1607 "the persisted key reloads identically"
1608 );
1609
1610 // And the reloaded key decrypts the first cipher's output (same key).
1611 let env = a.encrypt(&json!({"k": "v"})).unwrap();
1612 assert_eq!(b.decrypt(&env).unwrap(), json!({"k": "v"}));
1613 }
1614
1615 #[test]
1616 fn scope_maps_to_a_single_encryption_audience() {
1617 assert_eq!(encryption_audience(&Scope::Personal), "personal");
1618 assert_eq!(
1619 encryption_audience(&Scope::Shared { org: "acme".into() }),
1620 "org:acme"
1621 );
1622 }
1623
1624 #[test]
1625 fn same_login_master_derives_interoperable_keys_across_devices() {
1626 // Mac and phone each build a provider from the SAME login master. A
1627 // payload the Mac encrypts under `Personal` must decrypt on the phone —
1628 // the whole "my devices share config after one login" property.
1629 let master = b"parslee-issued-per-user-sync-secret";
1630 let mac = DerivedKeyProvider::new(master.to_vec());
1631 let phone = DerivedKeyProvider::new(master.to_vec());
1632
1633 let secret = json!({"messaging_allowlist": ["+15551234567"]});
1634 let env = mac.cipher_for(&Scope::Personal).encrypt(&secret).unwrap();
1635 assert_eq!(
1636 phone.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
1637 secret
1638 );
1639 }
1640
1641 #[test]
1642 fn personal_and_org_audiences_are_cryptographically_isolated() {
1643 let p = DerivedKeyProvider::new(b"master".to_vec());
1644 let env = p
1645 .cipher_for(&Scope::Personal)
1646 .encrypt(&json!({"x": 1}))
1647 .unwrap();
1648 // The org key cannot read a personal-audience ciphertext.
1649 assert!(matches!(
1650 p.cipher_for(&Scope::Shared { org: "acme".into() })
1651 .decrypt(&env),
1652 Err(CryptoError::Decrypt)
1653 ));
1654 }
1655
1656 #[test]
1657 fn passphrase_derives_the_same_keys_on_every_device_zero_knowledge() {
1658 // The zero-knowledge path: same passphrase + user → same keys, so the
1659 // phone reads what the Mac wrote, with no server ever holding the key.
1660 let mac = DerivedKeyProvider::from_passphrase("correct horse battery staple", "user-1");
1661 let phone = DerivedKeyProvider::from_passphrase("correct horse battery staple", "user-1");
1662 let env = mac
1663 .cipher_for(&Scope::Personal)
1664 .encrypt(&json!({"s": 1}))
1665 .unwrap();
1666 assert_eq!(
1667 phone.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
1668 json!({"s": 1})
1669 );
1670 // A wrong passphrase cannot read it.
1671 let wrong = DerivedKeyProvider::from_passphrase("hunter2", "user-1");
1672 assert!(matches!(
1673 wrong.cipher_for(&Scope::Personal).decrypt(&env),
1674 Err(CryptoError::Decrypt)
1675 ));
1676 }
1677
1678 #[test]
1679 fn a_different_login_cannot_decrypt() {
1680 let mine = DerivedKeyProvider::new(b"my-secret".to_vec());
1681 let theirs = DerivedKeyProvider::new(b"their-secret".to_vec());
1682 let env = mine
1683 .cipher_for(&Scope::Personal)
1684 .encrypt(&json!({"x": 1}))
1685 .unwrap();
1686 assert!(matches!(
1687 theirs.cipher_for(&Scope::Personal).decrypt(&env),
1688 Err(CryptoError::Decrypt)
1689 ));
1690 }
1691
1692 #[test]
1693 fn from_login_secret_is_stable_per_user_and_distinct_across_users() {
1694 let raw = b"raw-oauth-derived-material";
1695 // Two sign-ins for the same user → same keys (idempotent onboarding).
1696 let a = DerivedKeyProvider::from_login_secret(raw, "user-1");
1697 let b = DerivedKeyProvider::from_login_secret(raw, "user-1");
1698 let env = a
1699 .cipher_for(&Scope::Personal)
1700 .encrypt(&json!({"k": "v"}))
1701 .unwrap();
1702 assert_eq!(
1703 b.cipher_for(&Scope::Personal).decrypt(&env).unwrap(),
1704 json!({"k": "v"})
1705 );
1706 // A different user derives a different master → cannot decrypt.
1707 let other = DerivedKeyProvider::from_login_secret(raw, "user-2");
1708 assert!(matches!(
1709 other.cipher_for(&Scope::Personal).decrypt(&env),
1710 Err(CryptoError::Decrypt)
1711 ));
1712 }
1713}