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#[cfg(test)]
463mod tests {
464    use super::*;
465
466    // Two fixed seeds → deterministic identities for the golden + symmetry tests.
467    const SEED_A: [u8; 32] = [1u8; 32];
468    const SEED_B: [u8; 32] = [2u8; 32];
469
470    fn conv(seed_self: &[u8; 32], seed_peer: &[u8; 32]) -> [u8; 32] {
471        let our = x25519_scalar_from_ed25519_seed(seed_self);
472        let peer_pub = x25519_pub_from_ed25519_seed(seed_peer);
473        derive_conversation_key(&our, &peer_pub).unwrap()
474    }
475
476    fn hex_to_32(h: &str) -> [u8; 32] {
477        let v = hex::decode(h).expect("valid hex");
478        let mut a = [0u8; 32];
479        a.copy_from_slice(&v);
480        a
481    }
482
483    #[test]
484    fn round_trips_with_production_did_identity_form() {
485        // Canonicalization guard (review findings #1/#2): the bound `from`/`to`
486        // are the FULL signed-event DIDs (`did:wire:<handle>-<8hex>`), which
487        // contain `:` and `-`. Exercise that production spelling, not "alice".
488        let ck = conv(&SEED_A, &SEED_B);
489        let from = "did:wire:alice-1b1b58dd";
490        let to = "did:wire:bob-60346e7c";
491        let payload = seal(&ck, b"production-form message", from, to).unwrap();
492        assert_eq!(
493            open(&ck, &payload, from, to).unwrap(),
494            "production-form message"
495        );
496        // A different DID spelling for the same party fails — this is exactly
497        // the silent-decryption-outage the integration MUST avoid by binding
498        // the verbatim event DID on both ends.
499        assert_eq!(
500            open(&ck, &payload, "alice", to).unwrap_err(),
501            EncError::MacFail
502        );
503    }
504
505    #[test]
506    fn oversized_input_rejected_without_large_alloc() {
507        // Decode-bomb guard (review finding #4): a multi-MB payload is rejected
508        // by the pre-decode length cap, not after allocating a ~3/4-size Vec.
509        let ck = conv(&SEED_A, &SEED_B);
510        let bomb = "A".repeat(10_000_000);
511        assert_eq!(
512            open(&ck, &bomb, "a", "b").unwrap_err(),
513            EncError::BadPayloadLen
514        );
515    }
516
517    #[test]
518    fn truncated_payload_rejected() {
519        let ck = conv(&SEED_A, &SEED_B);
520        let payload = seal(&ck, b"hi", "a", "b").unwrap();
521        let raw = b64decode(&payload).unwrap();
522        let truncated = b64encode(&raw[..raw.len() - 40]); // below MIN_RAW
523        assert_eq!(
524            open(&ck, &truncated, "a", "b").unwrap_err(),
525            EncError::BadPayloadLen
526        );
527    }
528
529    #[test]
530    fn zero_shared_secret_is_rejected() {
531        // The all-zero u-coordinate is a low-order Curve25519 point; X25519
532        // against it yields an all-zero shared secret. Must be rejected before
533        // key derivation (C3 / RFC 7748 §6.1) — coverage for the guard path.
534        let our = x25519_scalar_from_ed25519_seed(&SEED_A);
535        assert_eq!(
536            derive_conversation_key(&our, &[0u8; 32]).unwrap_err(),
537            EncError::ZeroSharedSecret
538        );
539    }
540
541    #[test]
542    fn decode_bomb_cap_boundary() {
543        // One char over the base64 ceiling for a max payload → rejected pre-decode;
544        // a legitimately-sized payload still round-trips (the cap is not too tight).
545        let ck = conv(&SEED_A, &SEED_B);
546        let over = "A".repeat(MAX_RAW * 4 / 3 + 5);
547        assert_eq!(
548            open(&ck, &over, "a", "b").unwrap_err(),
549            EncError::BadPayloadLen
550        );
551        // a real near-max payload (~64KB plaintext) seals + opens under the cap
552        let big = vec![b'z'; 60000];
553        let payload = seal(&ck, &big, "a", "b").unwrap();
554        assert!(
555            payload.len() < MAX_RAW * 4 / 3 + 5,
556            "real payload is under the cap"
557        );
558        assert_eq!(open(&ck, &payload, "a", "b").unwrap().len(), 60000);
559    }
560
561    #[test]
562    fn calc_padded_len_conformance_nip44_vectors() {
563        // EXTERNAL ANCHOR — the official NIP-44 v2 `calc_padded_len` vectors
564        // (github.com/paulmillr/nip44 nip44.vectors.json). Salt/curve/info-
565        // independent, so they apply verbatim. Catches a wrong-but-self-
566        // consistent padding formula that round-trip + golden would miss
567        // (review finding #5a). Plus a couple of small cases (1, 32).
568        let nip44: &[(usize, usize)] = &[
569            (1, 32),
570            (16, 32),
571            (32, 32),
572            (33, 64),
573            (37, 64),
574            (45, 64),
575            (49, 64),
576            (64, 64),
577            (65, 96),
578            (100, 128),
579            (111, 128),
580            (200, 224),
581            (250, 256),
582            (320, 320),
583            (383, 384),
584            (384, 384),
585            (400, 448),
586            (500, 512),
587            (512, 512),
588            (515, 640),
589            (700, 768),
590            (800, 896),
591            (900, 1024),
592            (1020, 1024),
593            (65536, 65536),
594        ];
595        for &(unpadded, padded) in nip44 {
596            assert_eq!(
597                calc_padded_len(unpadded),
598                padded,
599                "calc_padded_len({unpadded})"
600            );
601        }
602    }
603
604    #[test]
605    fn message_keys_conformance_nip44_vector() {
606        // EXTERNAL ANCHOR for the HKDF-Expand split (review finding #5b):
607        // the official NIP-44 v2 `get_message_keys` vector. NIP-44 derives
608        // per-message keys as HKDF-Expand(prk=conversation_key, info=nonce, 76)
609        // split 32/12/32. Our `message_keys` takes arbitrary `info`; feeding
610        // info = the 32-byte nonce reproduces NIP-44 exactly, anchoring the
611        // okm offsets to an external authority (catches an okm[40..72] bug that
612        // self-consistent tests cannot).
613        let conversation_key =
614            hex_to_32("a1a3d60f3470a8612633924e91febf96dc5366ce130f658b1f0fc652c20b3b54");
615        let nonce = hex_to_32("e1e6f880560d6d149ed83dcc7e5861ee62a5ee051f7fde9975fe5d25d2a02d72");
616        let (chacha_key, chacha_nonce, hmac_key) = message_keys(&conversation_key, &nonce);
617        assert_eq!(
618            hex::encode(chacha_key),
619            "f145f3bed47cb70dbeaac07f3a3fe683e822b3715edb7c4fe310829014ce7d76"
620        );
621        assert_eq!(hex::encode(chacha_nonce), "c4ad129bb01180c0933a160c");
622        assert_eq!(
623            hex::encode(hmac_key),
624            "027c1db445f05e2eee864a0975b0ddef5b7110583c8c192de3732571ca5838c4"
625        );
626    }
627
628    #[test]
629    fn conversation_key_is_symmetric() {
630        // conv(a, B) == conv(b, A) — role-independent.
631        assert_eq!(conv(&SEED_A, &SEED_B), conv(&SEED_B, &SEED_A));
632    }
633
634    #[test]
635    fn derivation_is_deterministic() {
636        assert_eq!(
637            x25519_pub_from_ed25519_seed(&SEED_A),
638            x25519_pub_from_ed25519_seed(&SEED_A)
639        );
640    }
641
642    #[test]
643    fn golden_seed_to_pub_and_conv_key() {
644        // GOLDEN VECTOR — locks the §1a Ed25519→X25519 derivation + the
645        // X25519+wire-salt conversation key so a dalek-version bump or a
646        // divergent re-implementation fails IN CI, not silently in the field.
647        // (A wrong-but-stable derivation passes symmetry/round-trip; only a
648        // committed literal catches it.)
649        let pub_a = x25519_pub_from_ed25519_seed(&SEED_A);
650        let pub_b = x25519_pub_from_ed25519_seed(&SEED_B);
651        assert_eq!(hex::encode(pub_a), GOLDEN_PUB_A);
652        assert_eq!(hex::encode(pub_b), GOLDEN_PUB_B);
653        assert_eq!(hex::encode(conv(&SEED_A, &SEED_B)), GOLDEN_CONV_AB);
654    }
655
656    #[test]
657    fn round_trip_across_lengths() {
658        let ck = conv(&SEED_A, &SEED_B);
659        for &len in &[1usize, 31, 32, 33, 256, 257, 1000, 65535] {
660            let pt = "x".repeat(len);
661            let payload = seal(&ck, pt.as_bytes(), "alice", "bob").unwrap();
662            let got = open(&ck, &payload, "alice", "bob").unwrap();
663            assert_eq!(got, pt, "round-trip failed at len {len}");
664        }
665    }
666
667    #[test]
668    fn direction_binding_rejects_reflection() {
669        // A→B ciphertext opened as B→A (swapped context) MUST fail the MAC,
670        // even with the same (symmetric) conversation key.
671        let ck = conv(&SEED_A, &SEED_B);
672        let payload = seal(&ck, b"secret", "alice", "bob").unwrap();
673        assert_eq!(
674            open(&ck, &payload, "bob", "alice").unwrap_err(),
675            EncError::MacFail
676        );
677    }
678
679    #[test]
680    fn tamper_is_rejected_before_decrypt() {
681        let ck = conv(&SEED_A, &SEED_B);
682        let payload = seal(&ck, b"hello world", "alice", "bob").unwrap();
683        let raw = b64decode(&payload).unwrap();
684
685        // flip a ciphertext byte
686        let mut t = raw.clone();
687        t[40] ^= 0x01;
688        assert_eq!(
689            open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
690            EncError::MacFail
691        );
692
693        // flip a nonce byte
694        let mut t = raw.clone();
695        t[1] ^= 0x01;
696        assert_eq!(
697            open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
698            EncError::MacFail
699        );
700
701        // flip a mac byte
702        let n = raw.len();
703        let mut t = raw.clone();
704        t[n - 1] ^= 0x01;
705        assert_eq!(
706            open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
707            EncError::MacFail
708        );
709
710        // bad version (clone, not in-place — avoids order-coupling footgun)
711        let mut t = raw.clone();
712        t[0] = 0x01;
713        assert_eq!(
714            open(&ck, &b64encode(&t), "alice", "bob").unwrap_err(),
715            EncError::BadVersion
716        );
717    }
718
719    #[test]
720    fn plaintext_bounds_enforced() {
721        let ck = conv(&SEED_A, &SEED_B);
722        assert_eq!(
723            seal(&ck, b"", "a", "b").unwrap_err(),
724            EncError::BadLength(0)
725        );
726        let too_big = vec![0u8; 65536];
727        assert_eq!(
728            seal(&ck, &too_big, "a", "b").unwrap_err(),
729            EncError::BadLength(65536)
730        );
731    }
732
733    #[test]
734    fn wrong_conversation_key_fails() {
735        let ck = conv(&SEED_A, &SEED_B);
736        let payload = seal(&ck, b"secret", "alice", "bob").unwrap();
737        let other = x25519_scalar_from_ed25519_seed(&[9u8; 32]);
738        let wrong =
739            derive_conversation_key(&other, &x25519_pub_from_ed25519_seed(&SEED_B)).unwrap();
740        assert_eq!(
741            open(&wrong, &payload, "alice", "bob").unwrap_err(),
742            EncError::MacFail
743        );
744    }
745
746    #[test]
747    fn event_level_round_trip_and_verify_gate() {
748        // End-to-end integration proof of the public event API: A seals a body
749        // for B, signs it; B verify-gates + decrypts; tamper is refused by the
750        // gate; a plaintext event passes through as None.
751        use crate::signing::{generate_keypair, make_key_id, sign_message_v31};
752
753        let (a_seed, a_pk) = generate_keypair();
754        let (b_seed, b_pk) = generate_keypair();
755        let a_did = "did:wire:alice-1b1b58dd";
756        let b_did = "did:wire:bob-60346e7c";
757        let b_dh = x25519_pub_from_ed25519_seed(&b_seed);
758        let a_dh = x25519_pub_from_ed25519_seed(&a_seed);
759        let _ = &b_pk;
760
761        // B's trust pins A: A's verify key + A's card (carrying A's dh_pubkey).
762        let trust_b = json!({"agents": {"alice": {
763            "public_keys": [{"key_id": make_key_id("alice", &a_pk), "key": b64encode(&a_pk), "active": true}],
764            "card": {"dh_pubkey": b64encode(&a_dh)},
765        }}});
766
767        // A: build → seal (binds the event's own from/to) → sign.
768        let mut event = json!({
769            "from": a_did, "to": b_did, "type": "decision", "kind": 1000,
770            "body": "secret hello",
771        });
772        seal_event_body(&mut event, &b_dh, &a_seed).unwrap();
773        assert_eq!(event["enc"], json!(ENC_DISCRIMINATOR));
774        assert!(
775            event["body"]["ct"].is_string(),
776            "body replaced with ciphertext"
777        );
778        assert_ne!(event["body"], json!("secret hello"));
779        let signed = sign_message_v31(&event, &a_seed, &a_pk, "alice").unwrap();
780
781        // B: verify-gated decrypt recovers the plaintext body.
782        assert_eq!(
783            open_event_body(&signed, &trust_b, &b_seed).unwrap(),
784            Some(json!("secret hello"))
785        );
786
787        // Verify-gate: tampering the ciphertext breaks the signature → refused
788        // BEFORE any decrypt attempt.
789        let mut tampered = signed.clone();
790        tampered["body"]["ct"] = json!("AAAAAAAA");
791        assert!(open_event_body(&tampered, &trust_b, &b_seed).is_err());
792
793        // Plaintext event → None (caller renders the body as-is).
794        let plain = json!({"from": a_did, "to": b_did, "body": "hi"});
795        assert_eq!(open_event_body(&plain, &trust_b, &b_seed).unwrap(), None);
796    }
797
798    #[test]
799    fn full_card_pin_seal_read_pipeline() {
800        // Exercises the REAL integration chain end-to-end (no WIRE_HOME I/O):
801        // build+sign cards (sign_agent_card injects dh_pubkey) → pin via
802        // add_agent_card_pin → seal+sign a message → read via the public
803        // read-surface helper → plaintext. Catches breakage across
804        // agent_card + trust + enc that the in-module unit tests can't.
805        use crate::agent_card::{build_agent_card, card_dh_pubkey, sign_agent_card};
806        use crate::signing::{generate_keypair, sign_message_v31};
807        use crate::trust::{add_agent_card_pin, empty_trust};
808
809        let (a_seed, a_pk) = generate_keypair();
810        let (b_seed, b_pk) = generate_keypair();
811        let a_card = sign_agent_card(&build_agent_card("alice", &a_pk, None, None, None), &a_seed);
812        let _b_card = sign_agent_card(&build_agent_card("bob", &b_pk, None, None, None), &b_seed);
813
814        // sign_agent_card injected dh_pubkey, derived from the seed.
815        assert_eq!(
816            card_dh_pubkey(&a_card).unwrap(),
817            b64encode(&x25519_pub_from_ed25519_seed(&a_seed))
818        );
819
820        // B pins A's full card (carrying A's dh_pubkey + verify key).
821        let mut trust_b = empty_trust();
822        add_agent_card_pin(&mut trust_b, &a_card, Some("VERIFIED"));
823
824        // A seals + signs a message to B (B's dh from B's card/derivation).
825        let a_handle = a_card["handle"].as_str().unwrap().to_string();
826        let a_did = a_card["did"].as_str().unwrap().to_string();
827        let b_did = _b_card["did"].as_str().unwrap().to_string();
828        let b_dh = x25519_pub_from_ed25519_seed(&b_seed);
829        let mut event = json!({
830            "from": a_did, "to": b_did, "type": "decision", "kind": 1000,
831            "body": "pipeline secret",
832        });
833        seal_event_body(&mut event, &b_dh, &a_seed).unwrap();
834        let signed = sign_message_v31(&event, &a_seed, &a_pk, &a_handle).unwrap();
835
836        // B reads it via the public read-surface helper → decrypted plaintext.
837        let viewed = decrypt_event_for_read(&signed, &trust_b, &b_seed);
838        assert_eq!(viewed["body"], json!("pipeline secret"));
839        assert_eq!(viewed["dec"], json!(true));
840    }
841
842    // Golden literals — captured from this implementation; any drift fails CI.
843    const GOLDEN_PUB_A: &str = "1b1b58dd50ea14b60da17b790cd02754d970c9bab864ebb3c0f3016fe51d3f57";
844    const GOLDEN_PUB_B: &str = "60346e7c911a5f6ba154129174cafe75b294ac3bbd5549632f48cec6266f8410";
845    const GOLDEN_CONV_AB: &str = "9ade86510fe31aa30c0a583c7282a2cce1447103f2cd70e165489ac5b09dbd2e";
846
847    #[test]
848    #[ignore = "run with --ignored --nocapture to (re)capture golden literals"]
849    fn print_golden() {
850        eprintln!(
851            "PUB_A={}",
852            hex::encode(x25519_pub_from_ed25519_seed(&SEED_A))
853        );
854        eprintln!(
855            "PUB_B={}",
856            hex::encode(x25519_pub_from_ed25519_seed(&SEED_B))
857        );
858        eprintln!("CONV_AB={}", hex::encode(conv(&SEED_A, &SEED_B)));
859    }
860}