Skip to main content

wire/enc/
wire_x25519.rs

1//! `wire-x25519.v1` — NIP-44 v2's symmetric envelope over an X25519 IKM.
2//!
3//! This is the D1 DM-encryption crypto core. It reuses NIP-44 v2's vetted
4//! symmetric construction (HKDF → ChaCha20 + HMAC-SHA256, encrypt-then-MAC,
5//! length-hiding padding) but derives the conversation key from an **X25519**
6//! ECDH (wire identities are Ed25519 → X25519 via the same-curve map) with a
7//! **wire-specific HKDF salt** (`wire-x25519-v1`). It is therefore *not*
8//! Nostr-wire-compatible NIP-44 — the discriminator is `wire-x25519.v1`, never
9//! `nip44.v2`, so a Nostr reader never mis-decrypts a wire body.
10//!
11//! Design + rationale: `docs/rfc/0006-d1-nip44-design.md`.
12//!
13//! Security notes (load-bearing):
14//! - **No standalone authenticity.** This symmetric layer has no sender/
15//!   recipient/direction authenticity — that comes from the outer Ed25519
16//!   signature. `open()` MUST NOT be called on an event that has not passed
17//!   `verify_message_v31`. The `(from, to)` context bound into the HKDF `info`
18//!   is defence-in-depth (reflection resistance), not a substitute. The
19//!   integration MUST make verify-before-open structural (a `VerifiedEvent`
20//!   newtype or a `decrypt_verified_event` wrapper that re-verifies), not a
21//!   call-site convention.
22//! - **Canonical identity form (NORMATIVE).** `from`/`to` MUST be the VERBATIM
23//!   `from`/`to` DID strings as they appear on the signed event (which the
24//!   `event_id`/signature already commit to). Readers MUST decrypt from the
25//!   persisted signed line and MUST NOT re-resolve/normalize identities — a
26//!   spelling mismatch (bare handle vs `did:wire:h` vs `did:wire:h-<8hex>`)
27//!   between seal and open silently breaks decryption (→ `MacFail`).
28//! - **No forward secrecy / no post-compromise security.** The conversation
29//!   key is static per identity-pair; an Ed25519-seed compromise retroactively
30//!   decrypts every message ever exchanged. Treat the seed as a long-term root
31//!   secret. (Inherited NIP-44 property; FS would need an epoch/ephemeral input.)
32//!
33//! INTEGRATION GATE (status as of the wiring PR):
34//!   1. [CRITICAL — CLOSED] Verbatim signed-event `from`/`to` are bound INSIDE
35//!      [`seal_event_body`] / [`open_event_body`]; raw `seal`/`open` are
36//!      `pub(crate)`, so no call site can stringify identities.
37//!   2. [CRITICAL — CLOSED] Verify-before-open is STRUCTURAL: `open` is
38//!      `pub(crate)`; the only public decrypt path, `open_event_body`, re-runs
39//!      `verify_message_v31` first and is opaque on post-MAC failure.
40//!   3. [Satisfied by construction] "Never downgrade a dh-capable peer": the
41//!      encrypt decision keys off the peer's PINNED, SELF-SIGNED card's
42//!      `dh_pubkey` — a MITM cannot strip it (breaks the card signature), and a
43//!      legitimate peer's card always carries it (injected in `sign_agent_card`).
44//!      A peer omitting its own `dh_pubkey` only de-protects its own inbox.
45//!   4. [Partial] `Zeroizing` wraps the long-lived scalar + conversation key in
46//!      the event API; the per-message `okm`/chacha/hmac locals in `seal`/`open`
47//!      are still bare (short-lived) — a follow-up nicety, not a gate item.
48
49use chacha20::ChaCha20;
50use chacha20::cipher::{KeyIvInit, StreamCipher};
51use hkdf::Hkdf;
52use hmac::{Hmac, Mac};
53use rand::RngCore;
54use serde_json::{Value, json};
55use sha2::{Digest, Sha256, Sha512};
56use thiserror::Error;
57use x25519_dalek::{PublicKey, StaticSecret};
58use zeroize::Zeroizing;
59
60use crate::signing::{b64decode, b64encode};
61
62/// The `enc` discriminator for this scheme. Deliberately NOT `nip44.v2`.
63pub const ENC_DISCRIMINATOR: &str = "wire-x25519.v1";
64/// HKDF-Extract salt — domain-separated from NIP-44's `nip44-v2` so identical
65/// plaintext never collides with a real NIP-44 keystream.
66const HKDF_SALT: &[u8] = b"wire-x25519-v1";
67const VERSION: u8 = 0x02;
68const MAX_PLAINTEXT: usize = 65535;
69// version(1) + nonce(32) + min-ciphertext(2-byte len prefix + 32 padded) + mac(32)
70const MIN_RAW: usize = 1 + 32 + 34 + 32; // 99
71// version(1) + nonce(32) + max-ciphertext(2-byte len prefix + 65536 padded) + mac(32)
72const MAX_RAW: usize = 1 + 32 + (2 + 65536) + 32; // 65603
73
74type HmacSha256 = Hmac<Sha256>;
75
76#[derive(Debug, Error, PartialEq, Eq)]
77pub enum EncError {
78    #[error("x25519 produced an all-zero shared secret (low-order/contributory point)")]
79    ZeroSharedSecret,
80    #[error("plaintext length {0} out of range 1..=65535")]
81    BadLength(usize),
82    #[error("base64 decode failed")]
83    BadBase64,
84    #[error("payload length out of bounds")]
85    BadPayloadLen,
86    #[error("unsupported version")]
87    BadVersion,
88    #[error("mac verification failed")]
89    MacFail,
90    #[error("invalid padding")]
91    BadPadding,
92    #[error("plaintext is not valid utf-8")]
93    BadUtf8,
94}
95
96// ---------------------------------------------------------------- key derivation
97
98/// Derive the X25519 secret scalar from the 32-byte Ed25519 *seed*:
99/// `clamp(SHA-512(seed)[0..32])` — the exact scalar Ed25519 signs with
100/// (RFC 8032 §5.1.5 expansion + RFC 7748 §5 clamping). Same-curve conversion,
101/// not a cross-curve derivation.
102pub fn x25519_scalar_from_ed25519_seed(seed: &[u8; 32]) -> [u8; 32] {
103    let h = Sha512::digest(seed);
104    let mut s = [0u8; 32];
105    s.copy_from_slice(&h[0..32]);
106    s[0] &= 248;
107    s[31] &= 127;
108    s[31] |= 64;
109    s
110}
111
112/// The X25519 public key corresponding to an Ed25519 seed (for the card's
113/// `dh_pubkey`). `base · clamp(SHA-512(seed)[0..32])`.
114pub fn x25519_pub_from_ed25519_seed(seed: &[u8; 32]) -> [u8; 32] {
115    let secret = StaticSecret::from(x25519_scalar_from_ed25519_seed(seed));
116    PublicKey::from(&secret).to_bytes()
117}
118
119/// `conversation_key = HKDF-Extract(salt = wire-x25519-v1, IKM = X25519(our_scalar, peer_pub))`.
120/// Rejects the all-zero shared secret (RFC 7748 §6.1 contributory-behaviour guard)
121/// before key material is derived. Symmetric: `conv(a, B) == conv(b, A)`.
122pub fn derive_conversation_key(
123    our_scalar: &[u8; 32],
124    peer_pub: &[u8; 32],
125) -> Result<[u8; 32], EncError> {
126    let secret = StaticSecret::from(*our_scalar);
127    let peer = PublicKey::from(*peer_pub);
128    let shared = secret.diffie_hellman(&peer);
129    let shared_bytes = shared.to_bytes();
130    if shared_bytes == [0u8; 32] {
131        return Err(EncError::ZeroSharedSecret);
132    }
133    let (prk, _hk) = Hkdf::<Sha256>::extract(Some(HKDF_SALT), &shared_bytes);
134    let mut ck = [0u8; 32];
135    ck.copy_from_slice(&prk);
136    Ok(ck)
137}
138
139// ------------------------------------------------------------ per-message keys
140
141/// `context_info = nonce(32) ‖ u16_be(len from) ‖ from ‖ u16_be(len to) ‖ to`
142/// — bound into HKDF-Expand so per-message keys are direction-specific
143/// (reflection/cross-direction resistance at the symmetric layer; defence-in-
144/// depth behind the signature). Length-prefixed (not 0x00-separated) so the
145/// framing is injective regardless of the identity charset — the bound `from`/
146/// `to` are full signed-event DIDs, which contain `:` and `-` (review fix #6/#9).
147fn context_info(nonce: &[u8; 32], from: &str, to: &str) -> Vec<u8> {
148    // The u16 length-prefix is injective only for identities ≤ 65535 bytes.
149    // wire DIDs are <100 bytes; assert in dev so a future long identity can't
150    // silently truncate the cast and break the framing (review re-sweep #3).
151    debug_assert!(
152        from.len() <= u16::MAX as usize && to.len() <= u16::MAX as usize,
153        "identity too long for u16 length-prefix framing"
154    );
155    let mut v = Vec::with_capacity(32 + 2 + from.len() + 2 + to.len());
156    v.extend_from_slice(nonce);
157    v.extend_from_slice(&(from.len() as u16).to_be_bytes());
158    v.extend_from_slice(from.as_bytes());
159    v.extend_from_slice(&(to.len() as u16).to_be_bytes());
160    v.extend_from_slice(to.as_bytes());
161    v
162}
163
164/// HKDF-Expand the conversation key into (chacha_key[32], chacha_nonce[12], hmac_key[32]).
165fn message_keys(conversation_key: &[u8; 32], info: &[u8]) -> ([u8; 32], [u8; 12], [u8; 32]) {
166    let hk = Hkdf::<Sha256>::from_prk(conversation_key).expect("32-byte prk is valid");
167    let mut okm = [0u8; 76];
168    hk.expand(info, &mut okm).expect("76 < 255*32");
169    let mut chacha_key = [0u8; 32];
170    chacha_key.copy_from_slice(&okm[0..32]);
171    let mut chacha_nonce = [0u8; 12];
172    chacha_nonce.copy_from_slice(&okm[32..44]);
173    let mut hmac_key = [0u8; 32];
174    hmac_key.copy_from_slice(&okm[44..76]);
175    (chacha_key, chacha_nonce, hmac_key)
176}
177
178// ------------------------------------------------------------------- padding
179
180/// NIP-44 length-hiding padded length for `unpadded` (1..=65535).
181fn calc_padded_len(unpadded: usize) -> usize {
182    if unpadded <= 32 {
183        return 32;
184    }
185    let l = unpadded as u32;
186    // 1 << (floor(log2(L-1)) + 1) == 1 << (32 - (L-1).leading_zeros())
187    let next_power = 1usize << (32 - (l - 1).leading_zeros());
188    let chunk = if next_power <= 256 {
189        32
190    } else {
191        next_power / 8
192    };
193    chunk * (((unpadded - 1) / chunk) + 1)
194}
195
196/// `u16_be(len) ‖ plaintext ‖ zeros` to `2 + calc_padded_len(len)`.
197fn pad(pt: &[u8]) -> Result<Vec<u8>, EncError> {
198    let l = pt.len();
199    if !(1..=MAX_PLAINTEXT).contains(&l) {
200        return Err(EncError::BadLength(l));
201    }
202    let total = 2 + calc_padded_len(l);
203    let mut buf = Vec::with_capacity(total);
204    buf.extend_from_slice(&(l as u16).to_be_bytes());
205    buf.extend_from_slice(pt);
206    buf.resize(total, 0);
207    Ok(buf)
208}
209
210/// Inverse of [`pad`]. All three checks mandatory (length-tamper / oracle guard).
211fn unpad(buf: &[u8]) -> Result<Vec<u8>, EncError> {
212    if buf.len() < 2 {
213        return Err(EncError::BadPadding);
214    }
215    let l = u16::from_be_bytes([buf[0], buf[1]]) as usize;
216    let end = 2usize.checked_add(l).ok_or(EncError::BadPadding)?;
217    if l == 0 || buf.len() < end {
218        return Err(EncError::BadPadding);
219    }
220    let out = &buf[2..end];
221    if out.len() != l || buf.len() != 2 + calc_padded_len(l) {
222        return Err(EncError::BadPadding);
223    }
224    Ok(out.to_vec())
225}
226
227// ------------------------------------------------------------------ seal / open
228
229/// Encrypt `plaintext` for the conversation, bound to `(from, to)`. Returns the
230/// base64 payload `version(0x02) ‖ nonce(32) ‖ ciphertext ‖ mac(32)`.
231///
232/// `pub(crate)`: the only PUBLIC entry is [`seal_event_body`], which binds
233/// `from`/`to` from the event itself (CRITICAL canonicalization gate).
234pub(crate) fn seal(
235    conversation_key: &[u8; 32],
236    plaintext: &[u8],
237    from: &str,
238    to: &str,
239) -> Result<String, EncError> {
240    let mut nonce = [0u8; 32];
241    rand::thread_rng().fill_bytes(&mut nonce);
242    let (chacha_key, chacha_nonce, hmac_key) =
243        message_keys(conversation_key, &context_info(&nonce, from, to));
244
245    let mut ct = pad(plaintext)?;
246    let mut cipher = ChaCha20::new(
247        chacha20::Key::from_slice(&chacha_key),
248        chacha20::Nonce::from_slice(&chacha_nonce),
249    );
250    cipher.apply_keystream(&mut ct);
251
252    let mut mac = HmacSha256::new_from_slice(&hmac_key).expect("hmac accepts any key length");
253    mac.update(&nonce);
254    mac.update(&ct);
255    let tag = mac.finalize().into_bytes();
256
257    let mut payload = Vec::with_capacity(1 + 32 + ct.len() + 32);
258    payload.push(VERSION);
259    payload.extend_from_slice(&nonce);
260    payload.extend_from_slice(&ct);
261    payload.extend_from_slice(&tag);
262    Ok(b64encode(&payload))
263}
264
265/// Decrypt a `wire-x25519.v1` payload. MAC is verified (constant-time) BEFORE
266/// decryption. Caller MUST have verified the outer event signature first.
267///
268/// `pub(crate)`: the only PUBLIC entry is [`open_event_body`], which re-runs
269/// `verify_message_v31` (structural verify-before-open gate) and binds
270/// `from`/`to` from the verified event.
271pub(crate) fn open(
272    conversation_key: &[u8; 32],
273    payload_b64: &str,
274    from: &str,
275    to: &str,
276) -> Result<String, EncError> {
277    // Reserved future non-base64 encoding guard (matches NIP-44's '#').
278    if payload_b64.as_bytes().first() == Some(&b'#') {
279        return Err(EncError::BadVersion);
280    }
281    // Bound the INPUT length before decoding (decode-bomb / OOM guard, review
282    // fix #4): base64 allocates ~3/4 of the input up front, so cap the encoded
283    // string at the max-payload's base64 size before paying that allocation.
284    if payload_b64.len() > MAX_RAW * 4 / 3 + 4 {
285        return Err(EncError::BadPayloadLen);
286    }
287    let raw = b64decode(payload_b64).map_err(|_| EncError::BadBase64)?;
288    if !(MIN_RAW..=MAX_RAW).contains(&raw.len()) {
289        return Err(EncError::BadPayloadLen);
290    }
291    if raw[0] != VERSION {
292        return Err(EncError::BadVersion);
293    }
294    let mut nonce = [0u8; 32];
295    nonce.copy_from_slice(&raw[1..33]);
296    let mac_start = raw.len() - 32;
297    let ct = &raw[33..mac_start];
298    let tag = &raw[mac_start..];
299
300    let (chacha_key, chacha_nonce, hmac_key) =
301        message_keys(conversation_key, &context_info(&nonce, from, to));
302
303    let mut mac = HmacSha256::new_from_slice(&hmac_key).expect("hmac accepts any key length");
304    mac.update(&nonce);
305    mac.update(ct);
306    mac.verify_slice(tag).map_err(|_| EncError::MacFail)?; // constant-time, BEFORE decrypt
307
308    let mut buf = ct.to_vec();
309    let mut cipher = ChaCha20::new(
310        chacha20::Key::from_slice(&chacha_key),
311        chacha20::Nonce::from_slice(&chacha_nonce),
312    );
313    cipher.apply_keystream(&mut buf);
314    let out = unpad(&buf)?;
315    String::from_utf8(out).map_err(|_| EncError::BadUtf8)
316}
317
318// ----------------------------------------------------------- event-level API
319// The ONLY public entry points. They close the two CRITICAL integration-gate
320// items structurally: (1) `from`/`to` are read from the event itself, never
321// stringified by a caller; (2) `open_event_body` re-runs `verify_message_v31`
322// before decrypting, so decryption cannot run on an unverified event.
323
324/// Base64 X25519 public key for the agent card's `dh_pubkey`, derived from the
325/// Ed25519 seed (same-curve map). Emitted on the card at build/sign time.
326pub fn self_dh_pubkey_b64(seed: &[u8; 32]) -> String {
327    b64encode(&x25519_pub_from_ed25519_seed(seed))
328}
329
330fn decode_dh(b64: &str) -> Option<[u8; 32]> {
331    let v = b64decode(b64).ok()?;
332    let arr: [u8; 32] = v.try_into().ok()?;
333    Some(arr)
334}
335
336/// Read a peer's pinned `dh_pubkey` from the trust map (`agents[<handle>].card`).
337/// `None` ⇒ legacy/unenrolled peer ⇒ caller falls back to plaintext.
338pub fn peer_dh_pubkey(trust: &Value, peer_did_or_handle: &str) -> Option<[u8; 32]> {
339    let handle = crate::agent_card::display_handle_from_did(peer_did_or_handle);
340    let b64 = trust
341        .get("agents")?
342        .get(handle)?
343        .get("card")?
344        .get("dh_pubkey")?
345        .as_str()?;
346    decode_dh(b64)
347}
348
349/// Seal an outbound event's `body` IN PLACE, binding the event's own `from`/`to`
350/// (CRITICAL #1: identities are read here, never stringified by a caller). Sets
351/// `enc = "wire-x25519.v1"` and replaces `body` with `{"ct": <base64>}`. Call
352/// only when the peer is dh-capable (see [`peer_dh_pubkey`]); MUST run BEFORE
353/// the event is signed so the signature covers the ciphertext body.
354pub fn seal_event_body(
355    event: &mut Value,
356    peer_dh_pubkey: &[u8; 32],
357    our_seed: &[u8; 32],
358) -> anyhow::Result<()> {
359    let from = event
360        .get("from")
361        .and_then(Value::as_str)
362        .ok_or_else(|| anyhow::anyhow!("event missing `from`"))?
363        .to_string();
364    let to = event
365        .get("to")
366        .and_then(Value::as_str)
367        .ok_or_else(|| anyhow::anyhow!("encryption requires a `to` recipient on the event"))?
368        .to_string();
369    let our_scalar = Zeroizing::new(x25519_scalar_from_ed25519_seed(our_seed));
370    let ck = Zeroizing::new(
371        derive_conversation_key(&our_scalar, peer_dh_pubkey)
372            .map_err(|e| anyhow::anyhow!("derive conversation key: {e}"))?,
373    );
374    let pt = serde_json::to_vec(event.get("body").unwrap_or(&Value::Null))?;
375    let ct = seal(&ck, &pt, &from, &to).map_err(|e| anyhow::anyhow!("seal: {e}"))?;
376    let obj = event
377        .as_object_mut()
378        .ok_or_else(|| anyhow::anyhow!("event is not a JSON object"))?;
379    obj.insert("enc".into(), json!(ENC_DISCRIMINATOR));
380    obj.insert("body".into(), json!({ "ct": ct }));
381    Ok(())
382}
383
384/// VERIFY-GATED decrypt of an inbound event's body.
385/// - `Ok(None)` — not `enc`-bearing (plaintext) OR an `enc` scheme we don't know;
386/// - `Ok(Some(body))` — the decrypted body `Value`;
387/// - `Err(_)` — signature verification failed, peer has no `dh_pubkey`, or
388///   decryption failed (opaque — post-MAC errors are not distinguishable, no oracle).
389///
390/// CRITICAL #2: re-runs `verify_message_v31` before any decryption, and binds
391/// `from`/`to` from the verified event. Decryption is unreachable for an
392/// unverified event by construction (`open` is `pub(crate)`).
393pub fn open_event_body(
394    event: &Value,
395    trust: &Value,
396    our_seed: &[u8; 32],
397) -> anyhow::Result<Option<Value>> {
398    match event.get("enc").and_then(Value::as_str) {
399        Some(ENC_DISCRIMINATOR) => {}
400        Some(_) | None => return Ok(None),
401    }
402    // STRUCTURAL verify-before-open gate.
403    crate::signing::verify_message_v31(event, trust)
404        .map_err(|e| anyhow::anyhow!("refusing to decrypt unverified event: {e}"))?;
405
406    let from = event
407        .get("from")
408        .and_then(Value::as_str)
409        .ok_or_else(|| anyhow::anyhow!("event missing `from`"))?;
410    let to = event
411        .get("to")
412        .and_then(Value::as_str)
413        .ok_or_else(|| anyhow::anyhow!("encrypted event missing `to`"))?;
414    let ct = event
415        .get("body")
416        .and_then(|b| b.get("ct"))
417        .and_then(Value::as_str)
418        .ok_or_else(|| anyhow::anyhow!("enc event body missing `ct`"))?;
419
420    let peer_dh = peer_dh_pubkey(trust, from)
421        .ok_or_else(|| anyhow::anyhow!("sender has no pinned dh_pubkey — cannot decrypt"))?;
422    let our_scalar = Zeroizing::new(x25519_scalar_from_ed25519_seed(our_seed));
423    let ck = Zeroizing::new(
424        derive_conversation_key(&our_scalar, &peer_dh)
425            .map_err(|e| anyhow::anyhow!("derive conversation key: {e}"))?,
426    );
427    // Opaque on failure (verify already passed → a failure here is key-mismatch
428    // or corruption, never an attacker-driven oracle).
429    let pt = open(&ck, ct, from, to).map_err(|_| anyhow::anyhow!("decryption failed"))?;
430    let body: Value = serde_json::from_str(&pt)
431        .map_err(|_| anyhow::anyhow!("decrypted body is not valid json"))?;
432    Ok(Some(body))
433}
434
435/// Best-effort load of our Ed25519 seed for read-surface decryption. `None`
436/// when uninitialized — callers then render ciphertext as-is.
437pub fn self_seed_for_read() -> Option<[u8; 32]> {
438    crate::config::read_private_key()
439        .ok()
440        .and_then(|v| v.get(..32).and_then(|s| <[u8; 32]>::try_from(s).ok()))
441}
442
443/// Return a copy of `event` with its body decrypted IF it is enc-bearing and
444/// verifies (verify-gated via [`open_event_body`]); decrypted events are marked
445/// `dec: true`. Otherwise the event is returned unchanged. For the human/agent
446/// READ surfaces (`wire tail`, `wire_tail`, the `wire://inbox` resource). The
447/// persisted JSONL is never touched — this only shapes the response.
448pub fn decrypt_event_for_read(event: &Value, trust: &Value, seed: &[u8; 32]) -> Value {
449    if event.get("enc").and_then(Value::as_str) == Some(ENC_DISCRIMINATOR)
450        && let Ok(Some(plain)) = open_event_body(event, trust, seed)
451    {
452        let mut e = event.clone();
453        if let Some(obj) = e.as_object_mut() {
454            obj.insert("body".into(), plain);
455            obj.insert("dec".into(), json!(true));
456        }
457        return e;
458    }
459    event.clone()
460}
461
462/// True iff `event` still carries an `enc` envelope that was NOT opened for this
463/// read — i.e. ciphertext we cannot render as plaintext. [`decrypt_event_for_read`]
464/// marks a successful open with `dec:true` (leaving `enc` in place), so "`enc`
465/// present without `dec:true`" means encrypted-and-unreadable here: decrypt
466/// failed, we had no key, or it's an `enc` scheme this build doesn't support.
467///
468/// Single source of truth for the read surfaces (`wire tail`, the `wire_tail`
469/// MCP tool, the `wire://inbox` resource) so none of them present raw ciphertext
470/// as if it were the message body (#281). Call AFTER `decrypt_event_for_read`.
471pub fn is_encrypted_unreadable(event: &Value) -> bool {
472    event.get("enc").and_then(Value::as_str).is_some()
473        && event.get("dec").and_then(Value::as_bool) != Some(true)
474}
475
476/// Human/agent-facing placeholder string for an `enc` body this build could not
477/// decrypt — names the scheme and points at the fix, never leaks ciphertext.
478pub fn undecryptable_body_placeholder(event: &Value) -> String {
479    let enc = event.get("enc").and_then(Value::as_str).unwrap_or("?");
480    format!(
481        "<encrypted DM (enc={enc}) — this wire build could not decrypt it; run `wire upgrade`, or check that the peer pinned your current key>"
482    )
483}
484
485#[cfg(test)]
486mod tests {
487    use super::*;
488
489    #[test]
490    fn is_encrypted_unreadable_flags_enc_without_dec() {
491        // enc present, no dec:true → unreadable (#281).
492        let ev = json!({"enc": ENC_DISCRIMINATOR, "body": {"ct": "AAAA"}});
493        assert!(is_encrypted_unreadable(&ev));
494        // enc + dec:true (decrypt_event_for_read marked it) → readable.
495        let ev_ok = json!({"enc": ENC_DISCRIMINATOR, "dec": true, "body": "hi"});
496        assert!(!is_encrypted_unreadable(&ev_ok));
497        // plaintext (no enc) → not flagged.
498        let ev_plain = json!({"body": "hi"});
499        assert!(!is_encrypted_unreadable(&ev_plain));
500        // unknown future enc scheme this build can't open → flagged.
501        let ev_future = json!({"enc": "future.v9", "body": {"ct": "BBBB"}});
502        assert!(is_encrypted_unreadable(&ev_future));
503    }
504
505    #[test]
506    fn undecryptable_placeholder_names_scheme_and_hides_ciphertext() {
507        let ev = json!({"enc": ENC_DISCRIMINATOR, "body": {"ct": "SECRETCIPHERTEXT"}});
508        let p = undecryptable_body_placeholder(&ev);
509        assert!(p.contains(ENC_DISCRIMINATOR), "names scheme: {p}");
510        assert!(p.contains("wire upgrade"), "points at fix: {p}");
511        assert!(!p.contains("SECRETCIPHERTEXT"), "never leaks ct: {p}");
512    }
513
514    // Two fixed seeds → deterministic identities for the golden + symmetry tests.
515    const SEED_A: [u8; 32] = [1u8; 32];
516    const SEED_B: [u8; 32] = [2u8; 32];
517
518    fn conv(seed_self: &[u8; 32], seed_peer: &[u8; 32]) -> [u8; 32] {
519        let our = x25519_scalar_from_ed25519_seed(seed_self);
520        let peer_pub = x25519_pub_from_ed25519_seed(seed_peer);
521        derive_conversation_key(&our, &peer_pub).unwrap()
522    }
523
524    fn hex_to_32(h: &str) -> [u8; 32] {
525        let v = hex::decode(h).expect("valid hex");
526        let mut a = [0u8; 32];
527        a.copy_from_slice(&v);
528        a
529    }
530
531    #[test]
532    fn round_trips_with_production_did_identity_form() {
533        // Canonicalization guard (review findings #1/#2): the bound `from`/`to`
534        // are the FULL signed-event DIDs (`did:wire:<handle>-<8hex>`), which
535        // contain `:` and `-`. Exercise that production spelling, not "alice".
536        let ck = conv(&SEED_A, &SEED_B);
537        let from = "did:wire:alice-1b1b58dd";
538        let to = "did:wire:bob-60346e7c";
539        let payload = seal(&ck, b"production-form message", from, to).unwrap();
540        assert_eq!(
541            open(&ck, &payload, from, to).unwrap(),
542            "production-form message"
543        );
544        // A different DID spelling for the same party fails — this is exactly
545        // the silent-decryption-outage the integration MUST avoid by binding
546        // the verbatim event DID on both ends.
547        assert_eq!(
548            open(&ck, &payload, "alice", to).unwrap_err(),
549            EncError::MacFail
550        );
551    }
552
553    #[test]
554    fn oversized_input_rejected_without_large_alloc() {
555        // Decode-bomb guard (review finding #4): a multi-MB payload is rejected
556        // by the pre-decode length cap, not after allocating a ~3/4-size Vec.
557        let ck = conv(&SEED_A, &SEED_B);
558        let bomb = "A".repeat(10_000_000);
559        assert_eq!(
560            open(&ck, &bomb, "a", "b").unwrap_err(),
561            EncError::BadPayloadLen
562        );
563    }
564
565    #[test]
566    fn truncated_payload_rejected() {
567        let ck = conv(&SEED_A, &SEED_B);
568        let payload = seal(&ck, b"hi", "a", "b").unwrap();
569        let raw = b64decode(&payload).unwrap();
570        let truncated = b64encode(&raw[..raw.len() - 40]); // below MIN_RAW
571        assert_eq!(
572            open(&ck, &truncated, "a", "b").unwrap_err(),
573            EncError::BadPayloadLen
574        );
575    }
576
577    #[test]
578    fn zero_shared_secret_is_rejected() {
579        // The all-zero u-coordinate is a low-order Curve25519 point; X25519
580        // against it yields an all-zero shared secret. Must be rejected before
581        // key derivation (C3 / RFC 7748 §6.1) — coverage for the guard path.
582        let our = x25519_scalar_from_ed25519_seed(&SEED_A);
583        assert_eq!(
584            derive_conversation_key(&our, &[0u8; 32]).unwrap_err(),
585            EncError::ZeroSharedSecret
586        );
587    }
588
589    #[test]
590    fn decode_bomb_cap_boundary() {
591        // One char over the base64 ceiling for a max payload → rejected pre-decode;
592        // a legitimately-sized payload still round-trips (the cap is not too tight).
593        let ck = conv(&SEED_A, &SEED_B);
594        let over = "A".repeat(MAX_RAW * 4 / 3 + 5);
595        assert_eq!(
596            open(&ck, &over, "a", "b").unwrap_err(),
597            EncError::BadPayloadLen
598        );
599        // a real near-max payload (~64KB plaintext) seals + opens under the cap
600        let big = vec![b'z'; 60000];
601        let payload = seal(&ck, &big, "a", "b").unwrap();
602        assert!(
603            payload.len() < MAX_RAW * 4 / 3 + 5,
604            "real payload is under the cap"
605        );
606        assert_eq!(open(&ck, &payload, "a", "b").unwrap().len(), 60000);
607    }
608
609    #[test]
610    fn calc_padded_len_conformance_nip44_vectors() {
611        // EXTERNAL ANCHOR — the official NIP-44 v2 `calc_padded_len` vectors
612        // (github.com/paulmillr/nip44 nip44.vectors.json). Salt/curve/info-
613        // independent, so they apply verbatim. Catches a wrong-but-self-
614        // consistent padding formula that round-trip + golden would miss
615        // (review finding #5a). Plus a couple of small cases (1, 32).
616        let nip44: &[(usize, usize)] = &[
617            (1, 32),
618            (16, 32),
619            (32, 32),
620            (33, 64),
621            (37, 64),
622            (45, 64),
623            (49, 64),
624            (64, 64),
625            (65, 96),
626            (100, 128),
627            (111, 128),
628            (200, 224),
629            (250, 256),
630            (320, 320),
631            (383, 384),
632            (384, 384),
633            (400, 448),
634            (500, 512),
635            (512, 512),
636            (515, 640),
637            (700, 768),
638            (800, 896),
639            (900, 1024),
640            (1020, 1024),
641            (65536, 65536),
642        ];
643        for &(unpadded, padded) in nip44 {
644            assert_eq!(
645                calc_padded_len(unpadded),
646                padded,
647                "calc_padded_len({unpadded})"
648            );
649        }
650    }
651
652    #[test]
653    fn message_keys_conformance_nip44_vector() {
654        // EXTERNAL ANCHOR for the HKDF-Expand split (review finding #5b):
655        // the official NIP-44 v2 `get_message_keys` vector. NIP-44 derives
656        // per-message keys as HKDF-Expand(prk=conversation_key, info=nonce, 76)
657        // split 32/12/32. Our `message_keys` takes arbitrary `info`; feeding
658        // info = the 32-byte nonce reproduces NIP-44 exactly, anchoring the
659        // okm offsets to an external authority (catches an okm[40..72] bug that
660        // self-consistent tests cannot).
661        let conversation_key =
662            hex_to_32("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54");
663        let nonce = hex_to_32("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72");
664        let (chacha_key, chacha_nonce, hmac_key) = message_keys(&conversation_key, &nonce);
665        assert_eq!(
666            hex::encode(chacha_key),
667            "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76"
668        );
669        assert_eq!(hex::encode(chacha_nonce), "c4ad129bb01180c0933a160c");
670        assert_eq!(
671            hex::encode(hmac_key),
672            "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
673        );
674    }
675
676    #[test]
677    fn conversation_key_is_symmetric() {
678        // conv(a, B) == conv(b, A) — role-independent.
679        assert_eq!(conv(&SEED_A, &SEED_B), conv(&SEED_B, &SEED_A));
680    }
681
682    #[test]
683    fn derivation_is_deterministic() {
684        assert_eq!(
685            x25519_pub_from_ed25519_seed(&SEED_A),
686            x25519_pub_from_ed25519_seed(&SEED_A)
687        );
688    }
689
690    #[test]
691    fn golden_seed_to_pub_and_conv_key() {
692        // GOLDEN VECTOR — locks the §1a Ed25519→X25519 derivation + the
693        // X25519+wire-salt conversation key so a dalek-version bump or a
694        // divergent re-implementation fails IN CI, not silently in the field.
695        // (A wrong-but-stable derivation passes symmetry/round-trip; only a
696        // committed literal catches it.)
697        let pub_a = x25519_pub_from_ed25519_seed(&SEED_A);
698        let pub_b = x25519_pub_from_ed25519_seed(&SEED_B);
699        assert_eq!(hex::encode(pub_a), GOLDEN_PUB_A);
700        assert_eq!(hex::encode(pub_b), GOLDEN_PUB_B);
701        assert_eq!(hex::encode(conv(&SEED_A, &SEED_B)), GOLDEN_CONV_AB);
702    }
703
704    #[test]
705    fn round_trip_across_lengths() {
706        let ck = conv(&SEED_A, &SEED_B);
707        for &len in &[1usize, 31, 32, 33, 256, 257, 1000, 65535] {
708            let pt = "x".repeat(len);
709            let payload = seal(&ck, pt.as_bytes(), "alice", "bob").unwrap();
710            let got = open(&ck, &payload, "alice", "bob").unwrap();
711            assert_eq!(got, pt, "round-trip failed at len {len}");
712        }
713    }
714
715    #[test]
716    fn direction_binding_rejects_reflection() {
717        // A→B ciphertext opened as B→A (swapped context) MUST fail the MAC,
718        // even with the same (symmetric) conversation key.
719        let ck = conv(&SEED_A, &SEED_B);
720        let payload = seal(&ck, b"secret", "alice", "bob").unwrap();
721        assert_eq!(
722            open(&ck, &payload, "bob", "alice").unwrap_err(),
723            EncError::MacFail
724        );
725    }
726
727    #[test]
728    fn tamper_is_rejected_before_decrypt() {
729        let ck = conv(&SEED_A, &SEED_B);
730        let payload = seal(&ck, b"hello world", "alice", "bob").unwrap();
731        let raw = b64decode(&payload).unwrap();
732
733        // flip a ciphertext byte
734        let mut t = raw.clone();
735        t[40] ^= 0x01;
736        assert_eq!(
737            open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
738            EncError::MacFail
739        );
740
741        // flip a nonce byte
742        let mut t = raw.clone();
743        t[1] ^= 0x01;
744        assert_eq!(
745            open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
746            EncError::MacFail
747        );
748
749        // flip a mac byte
750        let n = raw.len();
751        let mut t = raw.clone();
752        t[n - 1] ^= 0x01;
753        assert_eq!(
754            open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
755            EncError::MacFail
756        );
757
758        // bad version (clone, not in-place — avoids order-coupling footgun)
759        let mut t = raw.clone();
760        t[0] = 0x01;
761        assert_eq!(
762            open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
763            EncError::BadVersion
764        );
765    }
766
767    #[test]
768    fn plaintext_bounds_enforced() {
769        let ck = conv(&SEED_A, &SEED_B);
770        assert_eq!(
771            seal(&ck, b"", "a", "b").unwrap_err(),
772            EncError::BadLength(0)
773        );
774        let too_big = vec![0u8; 65536];
775        assert_eq!(
776            seal(&ck, &too_big, "a", "b").unwrap_err(),
777            EncError::BadLength(65536)
778        );
779    }
780
781    #[test]
782    fn wrong_conversation_key_fails() {
783        let ck = conv(&SEED_A, &SEED_B);
784        let payload = seal(&ck, b"secret", "alice", "bob").unwrap();
785        let other = x25519_scalar_from_ed25519_seed(&[9u8; 32]);
786        let wrong =
787            derive_conversation_key(&other, &x25519_pub_from_ed25519_seed(&SEED_B)).unwrap();
788        assert_eq!(
789            open(&wrong, &payload, "alice", "bob").unwrap_err(),
790            EncError::MacFail
791        );
792    }
793
794    #[test]
795    fn event_level_round_trip_and_verify_gate() {
796        // End-to-end integration proof of the public event API: A seals a body
797        // for B, signs it; B verify-gates + decrypts; tamper is refused by the
798        // gate; a plaintext event passes through as None.
799        use crate::signing::{generate_keypair, make_key_id, sign_message_v31};
800
801        let (a_seed, a_pk) = generate_keypair();
802        let (b_seed, b_pk) = generate_keypair();
803        let a_did = "did:wire:alice-1b1b58dd";
804        let b_did = "did:wire:bob-60346e7c";
805        let b_dh = x25519_pub_from_ed25519_seed(&b_seed);
806        let a_dh = x25519_pub_from_ed25519_seed(&a_seed);
807        let _ = &b_pk;
808
809        // B's trust pins A: A's verify key + A's card (carrying A's dh_pubkey).
810        let trust_b = json!({"agents": {"alice": {
811            "public_keys": [{"key_id": make_key_id("alice", &a_pk), "key": b64encode(&a_pk), "active": true}],
812            "card": {"dh_pubkey": b64encode(&a_dh)},
813        }}});
814
815        // A: build → seal (binds the event's own from/to) → sign.
816        let mut event = json!({
817            "from": a_did, "to": b_did, "type": "decision", "kind": 1000,
818            "body": "secret hello",
819        });
820        seal_event_body(&mut event, &b_dh, &a_seed).unwrap();
821        assert_eq!(event["enc"], json!(ENC_DISCRIMINATOR));
822        assert!(
823            event["body"]["ct"].is_string(),
824            "body replaced with ciphertext"
825        );
826        assert_ne!(event["body"], json!("secret hello"));
827        let signed = sign_message_v31(&event, &a_seed, &a_pk, "alice").unwrap();
828
829        // B: verify-gated decrypt recovers the plaintext body.
830        assert_eq!(
831            open_event_body(&signed, &trust_b, &b_seed).unwrap(),
832            Some(json!("secret hello"))
833        );
834
835        // Verify-gate: tampering the ciphertext breaks the signature → refused
836        // BEFORE any decrypt attempt.
837        let mut tampered = signed.clone();
838        tampered["body"]["ct"] = json!("AAAAAAAA");
839        assert!(open_event_body(&tampered, &trust_b, &b_seed).is_err());
840
841        // Plaintext event → None (caller renders the body as-is).
842        let plain = json!({"from": a_did, "to": b_did, "body": "hi"});
843        assert_eq!(open_event_body(&plain, &trust_b, &b_seed).unwrap(), None);
844    }
845
846    #[test]
847    fn full_card_pin_seal_read_pipeline() {
848        // Exercises the REAL integration chain end-to-end (no WIRE_HOME I/O):
849        // build+sign cards (sign_agent_card injects dh_pubkey) → pin via
850        // add_agent_card_pin → seal+sign a message → read via the public
851        // read-surface helper → plaintext. Catches breakage across
852        // agent_card + trust + enc that the in-module unit tests can't.
853        use crate::agent_card::{build_agent_card, card_dh_pubkey, sign_agent_card};
854        use crate::signing::{generate_keypair, sign_message_v31};
855        use crate::trust::{add_agent_card_pin, empty_trust};
856
857        let (a_seed, a_pk) = generate_keypair();
858        let (b_seed, b_pk) = generate_keypair();
859        let a_card = sign_agent_card(&build_agent_card("alice", &a_pk, None, None, None), &a_seed);
860        let _b_card = sign_agent_card(&build_agent_card("bob", &b_pk, None, None, None), &b_seed);
861
862        // sign_agent_card injected dh_pubkey, derived from the seed.
863        assert_eq!(
864            card_dh_pubkey(&a_card).unwrap(),
865            b64encode(&x25519_pub_from_ed25519_seed(&a_seed))
866        );
867
868        // B pins A's full card (carrying A's dh_pubkey + verify key).
869        let mut trust_b = empty_trust();
870        add_agent_card_pin(&mut trust_b, &a_card, Some("VERIFIED")).unwrap();
871
872        // A seals + signs a message to B (B's dh from B's card/derivation).
873        let a_handle = a_card["handle"].as_str().unwrap().to_string();
874        let a_did = a_card["did"].as_str().unwrap().to_string();
875        let b_did = _b_card["did"].as_str().unwrap().to_string();
876        let b_dh = x25519_pub_from_ed25519_seed(&b_seed);
877        let mut event = json!({
878            "from": a_did, "to": b_did, "type": "decision", "kind": 1000,
879            "body": "pipeline secret",
880        });
881        seal_event_body(&mut event, &b_dh, &a_seed).unwrap();
882        let signed = sign_message_v31(&event, &a_seed, &a_pk, &a_handle).unwrap();
883
884        // B reads it via the public read-surface helper → decrypted plaintext.
885        let viewed = decrypt_event_for_read(&signed, &trust_b, &b_seed);
886        assert_eq!(viewed["body"], json!("pipeline secret"));
887        assert_eq!(viewed["dec"], json!(true));
888    }
889
890    // Golden literals — captured from this implementation; any drift fails CI.
891    const GOLDEN_PUB_A: &str = "1b1b58dd50ea14b60da17b790cd02754d970c9bab864ebb3c0f3016fe51d3f57";
892    const GOLDEN_PUB_B: &str = "60346e7c911a5f6ba154129174cafe75b294ac3bbd5549632f48cec6266f8410";
893    const GOLDEN_CONV_AB: &str = "9ade86510fe31aa30c0a583c7282a2cce1447103f2cd70e165489ac5b09dbd2e";
894
895    #[test]
896    #[ignore = "run with --ignored --nocapture to (re)capture golden literals"]
897    fn print_golden() {
898        eprintln!(
899            "PUB_A={}",
900            hex::encode(x25519_pub_from_ed25519_seed(&SEED_A))
901        );
902        eprintln!(
903            "PUB_B={}",
904            hex::encode(x25519_pub_from_ed25519_seed(&SEED_B))
905        );
906        eprintln!("CONV_AB={}", hex::encode(conv(&SEED_A, &SEED_B)));
907    }
908}