Skip to main content

car_sync/
crypto.rs

1//! End-to-end payload encryption boundary (slice B6 of
2//! `docs/proposals/multi-device-sync.md`, §"Transport: Parslee-hosted relay,
3//! E2E for personal scope").
4//!
5//! The proposal's trust posture: **the relay is a dumb, untrusted ordered-log
6//! store.** Personal-scope payloads are encrypted end-to-end, always; the
7//! relay stores only ciphertext and "can route and dedup on `op_id` and `hlc`
8//! (which stay cleartext) but **cannot read conversations, memory, or
9//! secrets**." This module is the encrypt/decrypt boundary that realizes it.
10//!
11//! ## The design that keeps the shipped oplog intact
12//!
13//! An op's `op_id` is the SHA-256 content address over `device_id ‖ seq ‖ prev
14//! ‖ hlc ‖ scope ‖ surface ‖ canonical(payload)` (see [`crate::oplog`]). To
15//! keep `op_id`/`seq`/`prev`/`hlc`/`scope`/`surface` **cleartext metadata** —
16//! exactly what B3's relay chain-verification and dedup rely on — the
17//! encryption is applied to the **payload only, at authoring time**: a device
18//! that wants E2E authors its op with `cipher.encrypt(plaintext)` as the
19//! payload, so the canonical op the whole system carries is ciphertext-native.
20//! The chain hashes over ciphertext, [`crate::oplog::verify_log`] verifies it,
21//! and the relay sees only the [`Envelope`]. A peer holding the same key
22//! recovers the plaintext with [`PayloadCipher::decrypt`]. No change to the
23//! `OpRecord` shape, the journal, the relay, or the fold — the ciphertext is
24//! just a `serde_json::Value` like any other payload.
25//!
26//! ## Real crypto, not a placeholder
27//!
28//! [`LocalKeyCipher`] is a genuine AEAD: **ChaCha20-Poly1305** with a random
29//! 96-bit nonce per op (RustCrypto `chacha20poly1305`). Confidentiality AND
30//! integrity — a tampered ciphertext fails the Poly1305 tag and
31//! [`PayloadCipher::decrypt`] returns [`CryptoError::Decrypt`], never silently
32//! wrong plaintext. The key is a user-held 256-bit secret
33//! ([`LocalKeyCipher::load_or_generate`] persists it `0600` under
34//! `~/.car/sync/`), never transmitted — the proposal's "the key is user-held,
35//! derived at Parslee login, never transmitted."
36//!
37//! ## What's wired, what remains
38//!
39//! Shipped + tested here and in the session:
40//!
41//! - **Decrypt-before-fold** — [`crate::session::SyncSession::with_key_provider`]
42//!   encrypts each payload at `append` (ciphertext-native, `op_id` over
43//!   ciphertext) and decrypts at `state()` *after* the chain verifies and
44//!   *before* the fold groups on `payload["id"]`/`fold_key`. Op identity stays
45//!   the cleartext-metadata `op_id`.
46//! - **Login-derived key distribution** — [`DerivedKeyProvider`] HKDF-derives
47//!   per-audience keys from one master; [`DerivedKeyProvider::from_passphrase`]
48//!   is the zero-knowledge cross-device source (same passphrase → same keys on
49//!   every device, never transmitted). [`LocalKeyCipher`] remains the raw
50//!   single-key reference. Per-audience isolation via [`encryption_audience`].
51//! - **Checkpoints under E2E** — [`crate::session::SyncSession::publish_checkpoint`]
52//!   is guarded off under a key provider: a ciphertext-folded checkpoint would
53//!   form an inconsistent decrypt base, and a cleartext one would leak. The
54//!   encrypted op log is retained and cold bootstrap replays it.
55//!
56//! - **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}