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}