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