Skip to main content

nostro2_nips/nip_104/
mod.rs

1//! NIP-104 — Double Ratchet end-to-end encrypted direct messages.
2//!
3//! This is a native, dependency-light port of the 1:1 ratchet session used by
4//! [`mmalmi/nostr-double-ratchet`](https://github.com/mmalmi/nostr-double-ratchet)
5//! (the implementation `chat.iris.to` runs). It is built **entirely** on this
6//! crate's spec-compliant NIP-44 v2 primitives ([`crate::Nip44`]) plus
7//! HKDF-SHA256, so it pulls in no third-party `nostr` stack.
8//!
9//! Scope: the core symmetric/DH double ratchet — `init`, `plan_send`,
10//! `plan_receive`, chain stepping, and skipped-message-key handling. The
11//! multi-device `AppKeys` / invite / session-manager layers are intentionally
12//! out of scope here.
13//!
14//! ## Crypto equivalence with the reference
15//!
16//! | Reference (`nostr-double-ratchet`)            | Here                                        |
17//! |-----------------------------------------------|---------------------------------------------|
18//! | `kdf(ikm, salt, n)` (HKDF-SHA256, info=`[i]`) | [`Nip104Crypto::kdf`]                        |
19//! | `ConversationKey::derive(sk, pk)`             | `conversation_key_v2(ecdh_x)`               |
20//! | `ConversationKey::new(message_key)`           | `message_key` used directly as conv-key     |
21//! | `encrypt_to_bytes` + base64                   | [`Nip44::encrypt_v2`] (identical layout)    |
22//! | `nip44::encrypt(sk, pk, json)`                | [`Nip44::nip_44_encrypt`]                   |
23//!
24//! Because every primitive is byte-identical, sessions established here
25//! interoperate with Iris's ratchet.
26
27mod group;
28mod invite;
29mod manager;
30mod sender_key;
31
32pub use group::*;
33pub use invite::*;
34pub use manager::*;
35pub use sender_key::*;
36
37use crate::Nip44;
38use base64::engine::{Engine as _, general_purpose};
39use nostro2_traits::{NostrKeypair, SignerError, hex::Hexable as _};
40use std::collections::BTreeMap;
41use zeroize::Zeroize;
42
43/// Maximum number of skipped message keys retained per chain. Matches the
44/// reference implementation's `MAX_SKIP`.
45pub const MAX_SKIP: usize = 1000;
46
47/// Nostr event kind carrying a double-ratchet message.
48///
49/// Mirrors the reference `MESSAGE_EVENT_KIND`. The event is signed by the
50/// sender's *current ephemeral* key (not their identity key), its `content`
51/// is the ratchet ciphertext, and the encrypted header rides in a
52/// `["header", …]` tag.
53pub const MESSAGE_EVENT_KIND: u32 = 1060;
54
55/// Tag name under which the NIP-44-encrypted ratchet header is carried.
56const HEADER_TAG: &str = "header";
57
58/// Errors raised by the double-ratchet session.
59#[derive(Debug)]
60pub enum Nip104Error {
61    /// The session is not yet in a state that permits sending.
62    CannotSendYet,
63    /// Required key material is missing from the session state.
64    SessionNotReady,
65    /// The envelope's sender does not match any known chain.
66    UnexpectedSender,
67    /// More than [`MAX_SKIP`] messages would have to be skipped.
68    TooManySkippedMessages,
69    /// The encrypted header could not be decrypted with any of our keys.
70    InvalidHeader,
71    /// An invite or invite-response was malformed, unsigned, or failed to
72    /// decrypt at one of its layers.
73    InvalidInvite(String),
74    /// A send was requested for a peer the manager has no session with.
75    UnknownPeer(String),
76    /// Underlying signer / key error.
77    Signer(SignerError),
78    /// NIP-44 layer error.
79    Nip44(crate::Nip44Error),
80    /// JSON (de)serialization error.
81    Json(String),
82    /// Base64 decoding error.
83    Base64(base64::DecodeError),
84}
85
86impl std::fmt::Display for Nip104Error {
87    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88        match self {
89            Self::CannotSendYet => f.write_str("session cannot send yet"),
90            Self::SessionNotReady => f.write_str("session not ready: missing key material"),
91            Self::UnexpectedSender => f.write_str("envelope sender matches no known chain"),
92            Self::TooManySkippedMessages => f.write_str("too many skipped messages"),
93            Self::InvalidHeader => f.write_str("could not decrypt message header"),
94            Self::InvalidInvite(e) => write!(f, "invalid invite: {e}"),
95            Self::UnknownPeer(p) => write!(f, "no session with peer {p}"),
96            Self::Signer(e) => write!(f, "signer error: {e}"),
97            Self::Nip44(e) => write!(f, "nip-44 error: {e}"),
98            Self::Json(e) => write!(f, "json error: {e}"),
99            Self::Base64(e) => write!(f, "base64 error: {e}"),
100        }
101    }
102}
103
104impl std::error::Error for Nip104Error {}
105
106impl From<SignerError> for Nip104Error {
107    fn from(e: SignerError) -> Self {
108        Self::Signer(e)
109    }
110}
111impl From<crate::Nip44Error> for Nip104Error {
112    fn from(e: crate::Nip44Error) -> Self {
113        Self::Nip44(e)
114    }
115}
116impl From<json_bourne::Error> for Nip104Error {
117    fn from(e: json_bourne::Error) -> Self {
118        Self::Json(format!("{e:?}"))
119    }
120}
121impl From<base64::DecodeError> for Nip104Error {
122    fn from(e: base64::DecodeError) -> Self {
123        Self::Base64(e)
124    }
125}
126
127type Result<T> = std::result::Result<T, Nip104Error>;
128
129/// A persisted ephemeral keypair: x-only public key plus its 32-byte secret.
130#[derive(Debug, Clone, PartialEq, Eq)]
131pub struct KeyPairBytes {
132    /// x-only public key, 64-char lowercase hex.
133    pub public_key: String,
134    /// raw secret key, 64-char lowercase hex.
135    pub private_key: String,
136}
137
138impl KeyPairBytes {
139    fn secret_bytes(&self) -> Result<[u8; 32]> {
140        let mut buf = [0_u8; 32];
141        nostro2_traits::hex::FromHex::decode_hex_to_slice(self.private_key.as_str(), &mut buf)
142            .map_err(|_| Nip104Error::Signer(SignerError::InvalidPublicKey))?;
143        Ok(buf)
144    }
145    fn from_secret<K: NostrKeypair>(secret: &[u8; 32]) -> Result<Self> {
146        let kp = K::from_secret_bytes(secret)?;
147        Ok(Self {
148            public_key: kp.public_key(),
149            private_key: secret.to_hex(),
150        })
151    }
152}
153
154/// Scrub the raw secret key on drop; `public_key` is not sensitive.
155impl Drop for KeyPairBytes {
156    fn drop(&mut self) {
157        self.private_key.zeroize();
158    }
159}
160
161/// The plaintext ratchet header, transmitted NIP-44-encrypted in each
162/// message. Wire field names are camelCase to match the reference
163/// implementation's JSON exactly (this is the interop-critical type).
164#[derive(Debug, Clone, PartialEq, Eq, json_bourne::FromJson, json_bourne::ToJson)]
165pub struct Header {
166    number: u32,
167    #[bourne(rename = "previousChainLength")]
168    previous_chain_length: u32,
169    #[bourne(rename = "nextPublicKey")]
170    next_public_key: String,
171}
172
173/// Per-sender map of skipped message keys (index → 32-byte key, hex).
174#[derive(Debug, Clone, Default, PartialEq, Eq)]
175pub struct SkippedKeysEntry {
176    /// message index → message key (hex).
177    pub message_keys: BTreeMap<u32, String>,
178}
179
180/// Scrub every banked message key on drop.
181impl Drop for SkippedKeysEntry {
182    fn drop(&mut self) {
183        for key in self.message_keys.values_mut() {
184            key.zeroize();
185        }
186    }
187}
188
189/// The full double-ratchet session state for one 1:1 channel.
190///
191/// Held in memory and cloned for the plan/apply transaction model. JSON
192/// persistence is a separate concern layered on later; the wire-critical
193/// [`Header`] is the only type with a fixed external encoding.
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct SessionState {
196    /// Root chain key (hex).
197    pub root_key: String,
198    /// Peer's current DH public key (x-only hex), if known.
199    pub their_current_nostr_public_key: Option<String>,
200    /// Peer's next DH public key (x-only hex), if known.
201    pub their_next_nostr_public_key: Option<String>,
202    /// Our previous DH keypair (kept to decrypt late-arriving messages).
203    pub our_previous_nostr_key: Option<KeyPairBytes>,
204    /// Our current DH keypair.
205    pub our_current_nostr_key: Option<KeyPairBytes>,
206    /// Our next DH keypair (advertised in outgoing headers).
207    pub our_next_nostr_key: KeyPairBytes,
208    /// Receiving chain key (hex), if a receiving chain exists.
209    pub receiving_chain_key: Option<String>,
210    /// Sending chain key (hex), if a sending chain exists.
211    pub sending_chain_key: Option<String>,
212    /// Next index to use on the sending chain.
213    pub sending_chain_message_number: u32,
214    /// Next index expected on the receiving chain.
215    pub receiving_chain_message_number: u32,
216    /// Number of messages sent on the previous sending chain.
217    pub previous_sending_chain_message_count: u32,
218    /// Skipped message keys, keyed by sender DH pubkey (hex).
219    pub skipped_keys: BTreeMap<String, SkippedKeysEntry>,
220}
221
222/// Scrub the chain/root secrets on drop. `our_*_nostr_key` (`KeyPairBytes`)
223/// and `skipped_keys` (`SkippedKeysEntry`) values scrub themselves via their
224/// own `Drop` impls when this struct's fields are subsequently dropped.
225impl Drop for SessionState {
226    fn drop(&mut self) {
227        self.root_key.zeroize();
228        if let Some(k) = self.receiving_chain_key.as_mut() {
229            k.zeroize();
230        }
231        if let Some(k) = self.sending_chain_key.as_mut() {
232            k.zeroize();
233        }
234    }
235}
236
237/// A ready-to-publish encrypted message: the NIP-44-encrypted header and the
238/// double-ratcheted ciphertext, plus the sender DH pubkey the recipient uses
239/// to locate the chain.
240#[derive(Debug, Clone, PartialEq, Eq)]
241pub struct MessageEnvelope {
242    /// Sender's current DH public key (x-only hex).
243    pub sender: String,
244    /// NIP-44 v2 encrypted, base64 `Header` JSON.
245    pub encrypted_header: String,
246    /// Base64 NIP-44 v2 ciphertext of the message payload.
247    pub ciphertext: String,
248}
249
250#[derive(Debug, Clone, Copy, PartialEq, Eq)]
251#[allow(clippy::redundant_pub_crate)]
252pub(crate) enum HeaderTarget {
253    Current,
254    Next,
255    Previous,
256}
257
258/// A 1:1 double-ratchet session. Generic over the in-process keypair type `K`
259/// so it works with the production `K256Keypair` and any test signer alike.
260#[derive(Debug, Clone)]
261pub struct Session<K: NostrKeypair> {
262    /// The serializable ratchet state.
263    pub state: SessionState,
264    _marker: std::marker::PhantomData<fn() -> K>,
265}
266
267impl<K: NostrKeypair> Session<K> {
268    /// Wrap an existing [`SessionState`] (e.g. loaded from storage).
269    #[must_use]
270    pub fn from_state(state: SessionState) -> Self {
271        Self {
272            state,
273            _marker: std::marker::PhantomData,
274        }
275    }
276
277    /// Initiator-side session bootstrap.
278    ///
279    /// `their_ephemeral_pubkey` is the peer's ephemeral x-only key, `our_secret`
280    /// our ephemeral secret, and `shared_secret` the X3DH output.
281    ///
282    /// # Errors
283    /// Propagates key-construction and NIP-44 derivation failures.
284    pub fn new_initiator(
285        their_ephemeral_pubkey: &[u8; 32],
286        our_secret: &[u8; 32],
287        shared_secret: &[u8; 32],
288    ) -> Result<Self> {
289        Self::init(their_ephemeral_pubkey, our_secret, true, shared_secret)
290    }
291
292    /// Responder-side session bootstrap. See [`Session::new_initiator`].
293    ///
294    /// # Errors
295    /// Propagates key-construction and NIP-44 derivation failures.
296    pub fn new_responder(
297        their_ephemeral_pubkey: &[u8; 32],
298        our_secret: &[u8; 32],
299        shared_secret: &[u8; 32],
300    ) -> Result<Self> {
301        Self::init(their_ephemeral_pubkey, our_secret, false, shared_secret)
302    }
303
304    fn init(
305        their_ephemeral_pubkey: &[u8; 32],
306        our_secret: &[u8; 32],
307        is_initiator: bool,
308        shared_secret: &[u8; 32],
309    ) -> Result<Self> {
310        let our_current = KeyPairBytes::from_secret::<K>(our_secret)?;
311        let their_next_hex = their_ephemeral_pubkey.to_hex();
312
313        let state = if is_initiator {
314            let our_next_secret = K::generate().secret_bytes();
315            let our_next = KeyPairBytes::from_secret::<K>(&our_next_secret)?;
316            // root/sending chains seeded from a DH between our *next* key and
317            // the peer's ephemeral key, mixed into the shared secret.
318            let conv = K::derive_conv_key(&our_next_secret, their_ephemeral_pubkey)?;
319            let outs = K::kdf(shared_secret, &conv, 2);
320            SessionState {
321                root_key: outs[0].to_hex(),
322                their_current_nostr_public_key: None,
323                their_next_nostr_public_key: Some(their_next_hex),
324                our_previous_nostr_key: None,
325                our_current_nostr_key: Some(our_current),
326                our_next_nostr_key: our_next,
327                receiving_chain_key: None,
328                sending_chain_key: Some(outs[1].to_hex()),
329                sending_chain_message_number: 0,
330                receiving_chain_message_number: 0,
331                previous_sending_chain_message_count: 0,
332                skipped_keys: BTreeMap::new(),
333            }
334        } else {
335            SessionState {
336                root_key: shared_secret.to_hex(),
337                their_current_nostr_public_key: None,
338                their_next_nostr_public_key: Some(their_next_hex),
339                our_previous_nostr_key: None,
340                our_current_nostr_key: None,
341                our_next_nostr_key: our_current,
342                receiving_chain_key: None,
343                sending_chain_key: None,
344                sending_chain_message_number: 0,
345                receiving_chain_message_number: 0,
346                previous_sending_chain_message_count: 0,
347                skipped_keys: BTreeMap::new(),
348            }
349        };
350
351        Ok(Self::from_state(state))
352    }
353
354    /// Whether the session currently holds enough state to encrypt a message.
355    #[must_use]
356    pub const fn can_send(&self) -> bool {
357        self.state.their_next_nostr_public_key.is_some()
358            && self.state.our_current_nostr_key.is_some()
359    }
360
361    fn matches_sender(&self, sender: &str) -> bool {
362        self.state.their_current_nostr_public_key.as_deref() == Some(sender)
363            || self.state.their_next_nostr_public_key.as_deref() == Some(sender)
364            || self.state.skipped_keys.contains_key(sender)
365    }
366
367    /// The peer DH public keys (x-only hex) this session will currently accept
368    /// as a message `sender` — exactly the set [`matches_sender`](Self::matches_sender)
369    /// tests: the peer's current and next ratchet keys plus every banked
370    /// skipped-chain key.
371    ///
372    /// This set changes **only when a message is received** (the DH ratchet
373    /// turns or a skipped key is banked); sending never alters it. A router can
374    /// therefore index sessions by these keys and refresh the index after each
375    /// [`plan_receive`](Self::plan_receive)/[`apply`](Self::apply), giving O(1)
376    /// inbound routing instead of trial-decrypting every session.
377    #[must_use]
378    pub fn accepted_senders(&self) -> Vec<String> {
379        let mut out = Vec::with_capacity(2 + self.state.skipped_keys.len());
380        if let Some(cur) = self.state.their_current_nostr_public_key.as_deref() {
381            out.push(cur.to_owned());
382        }
383        if let Some(next) = self.state.their_next_nostr_public_key.as_deref()
384            && Some(next) != self.state.their_current_nostr_public_key.as_deref()
385        {
386            out.push(next.to_owned());
387        }
388        for k in self.state.skipped_keys.keys() {
389            if Some(k.as_str()) != self.state.their_current_nostr_public_key.as_deref()
390                && Some(k.as_str()) != self.state.their_next_nostr_public_key.as_deref()
391            {
392                out.push(k.clone());
393            }
394        }
395        out
396    }
397
398    /// Encrypt `payload`, returning the envelope and the post-send state.
399    ///
400    /// The session is **not** mutated; call [`Session::apply`] with the returned
401    /// state to commit. This separation lets callers persist atomically.
402    ///
403    /// # Errors
404    /// [`Nip104Error::CannotSendYet`] if no sending chain exists, plus any
405    /// crypto failure.
406    pub fn plan_send(&self, payload: &[u8]) -> Result<(SessionState, MessageEnvelope)> {
407        if !self.can_send() {
408            return Err(Nip104Error::CannotSendYet);
409        }
410        let mut next = self.state.clone();
411        let (header, ciphertext) = K::ratchet_encrypt(&mut next, payload)?;
412
413        let our_current = self
414            .state
415            .our_current_nostr_key
416            .as_ref()
417            .ok_or(Nip104Error::SessionNotReady)?;
418        let their_next = self
419            .state
420            .their_next_nostr_public_key
421            .as_deref()
422            .ok_or(Nip104Error::SessionNotReady)?;
423
424        let our_kp = K::from_secret_bytes(&our_current.secret_bytes()?)?;
425        let header_json = json_bourne::to_string(&header)?;
426        let encrypted_header = our_kp
427            .nip_44_encrypt(&header_json, their_next)?
428            .into_owned();
429
430        Ok((
431            next,
432            MessageEnvelope {
433                sender: our_current.public_key.clone(),
434                encrypted_header,
435                ciphertext,
436            },
437        ))
438    }
439
440    /// Decrypt `envelope`, returning the plaintext and the post-receive state.
441    /// The session is not mutated; commit with [`Session::apply`].
442    ///
443    /// # Errors
444    /// [`Nip104Error::UnexpectedSender`] if the sender is unknown, plus any
445    /// crypto or ratchet failure.
446    pub fn plan_receive(&self, envelope: &MessageEnvelope) -> Result<(SessionState, Vec<u8>)> {
447        if !self.matches_sender(&envelope.sender) {
448            return Err(Nip104Error::UnexpectedSender);
449        }
450        let mut next = self.state.clone();
451        let previous_chain_sender = next
452            .their_current_nostr_public_key
453            .clone()
454            .or_else(|| next.their_next_nostr_public_key.clone());
455
456        let (header, target) =
457            K::decrypt_header(&next, &envelope.encrypted_header, &envelope.sender)?;
458        let should_ratchet = target == HeaderTarget::Next;
459
460        if should_ratchet
461            && next.their_next_nostr_public_key.as_ref() != Some(&header.next_public_key)
462        {
463            next.their_current_nostr_public_key = next.their_next_nostr_public_key.take();
464            next.their_next_nostr_public_key = Some(header.next_public_key.clone());
465        }
466
467        if should_ratchet {
468            if next.receiving_chain_key.is_some() {
469                let skipped_sender = previous_chain_sender.ok_or(Nip104Error::SessionNotReady)?;
470                K::skip_message_keys(&mut next, header.previous_chain_length, &skipped_sender)?;
471            }
472            K::ratchet_step(&mut next)?;
473        }
474
475        let payload =
476            K::ratchet_decrypt(&mut next, &header, &envelope.ciphertext, &envelope.sender)?;
477        Ok((next, payload))
478    }
479
480    /// Commit a planned state transition produced by [`Session::plan_send`] or
481    /// [`Session::plan_receive`].
482    pub fn apply(&mut self, next: SessionState) {
483        self.state = next;
484    }
485
486    /// Like [`plan_send`](Self::plan_send), but also renders a ready-to-publish,
487    /// signed kind-[`MESSAGE_EVENT_KIND`] Nostr event.
488    ///
489    /// The event is signed by the sender's *current ephemeral* key (the one
490    /// driving the ratchet), exactly as the reference implementation does — so
491    /// the published event interoperates with Iris. The session is not
492    /// mutated; commit the returned state with [`apply`](Self::apply).
493    ///
494    /// `created_at` is the Unix timestamp to stamp on the event.
495    ///
496    /// # Errors
497    /// Propagates [`plan_send`](Self::plan_send) failures plus any signing
498    /// error.
499    pub fn plan_send_event(
500        &self,
501        payload: &[u8],
502        created_at: i64,
503    ) -> Result<(SessionState, nostro2::NostrNote)> {
504        let (next, envelope) = self.plan_send(payload)?;
505        let our_current = self
506            .state
507            .our_current_nostr_key
508            .as_ref()
509            .ok_or(Nip104Error::SessionNotReady)?;
510        let signer = K::from_secret_bytes(&our_current.secret_bytes()?)?;
511        let event = envelope.to_event(&signer, created_at)?;
512        Ok((next, event))
513    }
514
515    /// Like [`plan_receive`](Self::plan_receive), but takes a raw kind-1060
516    /// Nostr event, verifies it, and extracts the envelope before decrypting.
517    ///
518    /// # Errors
519    /// [`Nip104Error::InvalidHeader`] if the event is malformed or fails
520    /// signature verification, plus any [`plan_receive`](Self::plan_receive)
521    /// failure.
522    pub fn plan_receive_event(
523        &self,
524        event: &nostro2::NostrNote,
525    ) -> Result<(SessionState, Vec<u8>)> {
526        let envelope = MessageEnvelope::from_event(event)?;
527        self.plan_receive(&envelope)
528    }
529}
530
531impl MessageEnvelope {
532    /// Render this envelope as a signed kind-[`MESSAGE_EVENT_KIND`] Nostr event.
533    ///
534    /// `signer` must be the sender's current ephemeral keypair (its public key
535    /// must equal [`self.sender`](Self::sender)); the reference implementation
536    /// signs ratchet messages with that key, and the recipient locates the
537    /// chain by the event's `pubkey`.
538    ///
539    /// # Errors
540    /// Returns [`Nip104Error::Signer`] if signing fails.
541    pub fn to_event<S: nostro2::NostrSigner>(
542        &self,
543        signer: &S,
544        created_at: i64,
545    ) -> Result<nostro2::NostrNote> {
546        let mut tags = nostro2::NostrTags::new();
547        tags.add_custom_tag(HEADER_TAG, &self.encrypted_header);
548        let mut note = nostro2::NostrNote {
549            kind: MESSAGE_EVENT_KIND,
550            content: self.ciphertext.clone(),
551            created_at,
552            tags,
553            ..Default::default()
554        };
555        note.sign_with(signer)
556            .map_err(|_| Nip104Error::Signer(SignerError::InvalidSignature))?;
557        Ok(note)
558    }
559
560    /// Parse and verify a kind-[`MESSAGE_EVENT_KIND`] Nostr event into an
561    /// envelope. The event's `pubkey` becomes the [`sender`](Self::sender),
562    /// its `content` the ciphertext, and the `["header", …]` tag the encrypted
563    /// header.
564    ///
565    /// # Errors
566    /// [`Nip104Error::InvalidHeader`] if the kind is wrong, the signature is
567    /// invalid, or the `header` tag is missing.
568    pub fn from_event(event: &nostro2::NostrNote) -> Result<Self> {
569        use nostro2::NostrEvent as _;
570        if event.kind != MESSAGE_EVENT_KIND {
571            return Err(Nip104Error::InvalidHeader);
572        }
573        if !event.verify() {
574            return Err(Nip104Error::InvalidHeader);
575        }
576        let encrypted_header = event
577            .tags
578            .find_tags_ref(HEADER_TAG)
579            .into_iter()
580            .next()
581            .ok_or(Nip104Error::InvalidHeader)?
582            .to_owned();
583        Ok(Self {
584            sender: event.pubkey.clone(),
585            encrypted_header,
586            ciphertext: event.content.clone(),
587        })
588    }
589}
590
591// ── Ratchet internals (faithful port of session.rs) ───────────────────
592
593/// Crate-internal extension trait gathering the double-ratchet crypto glue
594/// that is generic over the in-process keypair `K`. Mirrors the structure of
595/// [`crate::Nip44`]: a blanket-implemented trait on `NostrKeypair`, so no
596/// free functions leak into the module. Every method is an associated function
597/// (no `self`); `K` is the implementing key type.
598#[allow(clippy::redundant_pub_crate)]
599pub(crate) trait Nip104Crypto: NostrKeypair + Sized {
600    /// HKDF-SHA256 KDF used for all chain stepping.
601    ///
602    /// Mirrors the reference `kdf(input1, input2, num_outputs)`: `input2` is the
603    /// salt, `input1` the IKM, and each output `i` (1-based) is
604    /// `HKDF-Expand(info = [i])` truncated to 32 bytes.
605    fn kdf(input1: &[u8], input2: &[u8], num_outputs: usize) -> Vec<[u8; 32]> {
606        let hk = hkdf::Hkdf::<sha2::Sha256>::new(Some(input2), input1);
607        let mut outputs = Vec::with_capacity(num_outputs);
608        for i in 1..=num_outputs {
609            let mut okm = [0_u8; 32];
610            hk.expand(&[u8::try_from(i).unwrap_or(u8::MAX)], &mut okm)
611                .expect("32 bytes is a valid HKDF length");
612            outputs.push(okm);
613        }
614        outputs
615    }
616
617    /// Decode a 64-char lowercase-hex string into 32 raw bytes.
618    fn decode_hex_32(s: &str) -> Result<[u8; 32]> {
619        let mut buf = [0_u8; 32];
620        nostro2_traits::hex::FromHex::decode_hex_to_slice(s, &mut buf)
621            .map_err(|_| Nip104Error::Signer(SignerError::InvalidPublicKey))?;
622        Ok(buf)
623    }
624
625    /// `ConversationKey::derive(sk, pk)` — ECDH x-coordinate fed through NIP-44
626    /// v2 conversation-key derivation (HKDF-extract, salt `"nip44-v2"`).
627    fn derive_conv_key(sk: &[u8; 32], pk: &[u8; 32]) -> Result<[u8; 32]> {
628        let kp = Self::from_secret_bytes(sk)?;
629        let shared = kp.ecdh_x(pk)?;
630        let conv = <Self as Nip44>::conversation_key_v2(zeroize::Zeroizing::new(shared))?;
631        Ok(*conv)
632    }
633
634    /// Encrypt with a raw 32-byte message key as the NIP-44 v2 conversation
635    /// key, returning the standard base64 payload (`Ag…`). Equivalent to the
636    /// reference's `ConversationKey::new(mk)` + `encrypt_to_bytes` + base64.
637    fn encrypt_with_message_key(message_key: &[u8; 32], plaintext: &[u8]) -> Result<String> {
638        let nonce = Self::generate_nonce_32();
639        Ok(<Self as Nip44>::encrypt_v2(message_key, &nonce, plaintext)?)
640    }
641
642    /// Inverse of [`encrypt_with_message_key`](Self::encrypt_with_message_key),
643    /// returning the raw plaintext bytes.
644    fn decrypt_with_message_key(message_key: &[u8; 32], ciphertext_b64: &str) -> Result<Vec<u8>> {
645        let decoded = general_purpose::STANDARD.decode(ciphertext_b64)?;
646        let s = <Self as Nip44>::decrypt_v2_bytes(message_key, &decoded)?;
647        Ok(s)
648    }
649
650    /// Drop a fresh message key off the sending chain, advancing it, and return
651    /// the header + ciphertext for `plaintext`.
652    fn ratchet_encrypt(state: &mut SessionState, plaintext: &[u8]) -> Result<(Header, String)> {
653        let sending_chain_key = Self::decode_hex_32(
654            state
655                .sending_chain_key
656                .as_deref()
657                .ok_or(Nip104Error::SessionNotReady)?,
658        )?;
659        let outs = Self::kdf(&sending_chain_key, &[1_u8], 2);
660        state.sending_chain_key = Some(outs[0].to_hex());
661        let message_key = outs[1];
662
663        let header = Header {
664            number: state.sending_chain_message_number,
665            next_public_key: state.our_next_nostr_key.public_key.clone(),
666            previous_chain_length: state.previous_sending_chain_message_count,
667        };
668        state.sending_chain_message_number += 1;
669
670        let ciphertext = Self::encrypt_with_message_key(&message_key, plaintext)?;
671        Ok((header, ciphertext))
672    }
673
674    /// Pull the matching message key off the receiving chain (or the skipped
675    /// store) and decrypt `ciphertext`.
676    fn ratchet_decrypt(
677        state: &mut SessionState,
678        header: &Header,
679        ciphertext: &str,
680        sender: &str,
681    ) -> Result<Vec<u8>> {
682        if let Some(pt) = Self::try_skipped_message_keys(state, header, ciphertext, sender)? {
683            return Ok(pt);
684        }
685        if state.receiving_chain_key.is_none() {
686            return Err(Nip104Error::SessionNotReady);
687        }
688        Self::skip_message_keys(state, header.number, sender)?;
689
690        let receiving_chain_key = Self::decode_hex_32(
691            state
692                .receiving_chain_key
693                .as_deref()
694                .ok_or(Nip104Error::SessionNotReady)?,
695        )?;
696        let outs = Self::kdf(&receiving_chain_key, &[1_u8], 2);
697        state.receiving_chain_key = Some(outs[0].to_hex());
698        let message_key = outs[1];
699        state.receiving_chain_message_number += 1;
700
701        Self::decrypt_with_message_key(&message_key, ciphertext)
702    }
703
704    /// Perform the DH ratchet step: derive a new receiving chain from the
705    /// peer's next key, then a fresh sending chain + root from a new DH key.
706    fn ratchet_step(state: &mut SessionState) -> Result<()> {
707        state.previous_sending_chain_message_count = state.sending_chain_message_number;
708        state.sending_chain_message_number = 0;
709        state.receiving_chain_message_number = 0;
710
711        let their_next = state
712            .their_next_nostr_public_key
713            .as_deref()
714            .ok_or(Nip104Error::SessionNotReady)?;
715        let their_next_bytes = Self::decode_hex_32(their_next)?;
716        let root_key = Self::decode_hex_32(&state.root_key)?;
717
718        // First DH: our_next × their_next → new receiving chain.
719        let conv1 =
720            Self::derive_conv_key(&state.our_next_nostr_key.secret_bytes()?, &their_next_bytes)?;
721        let outs1 = Self::kdf(&root_key, &conv1, 2);
722        state.receiving_chain_key = Some(outs1[1].to_hex());
723        state.our_previous_nostr_key = state.our_current_nostr_key.take();
724        state.our_current_nostr_key = Some(state.our_next_nostr_key.clone());
725
726        // Fresh DH key, second DH → new root + sending chain.
727        let our_next_secret = Self::generate().secret_bytes();
728        state.our_next_nostr_key = KeyPairBytes::from_secret::<Self>(&our_next_secret)?;
729        let conv2 = Self::derive_conv_key(&our_next_secret, &their_next_bytes)?;
730        let outs2 = Self::kdf(&outs1[0], &conv2, 2);
731        state.root_key = outs2[0].to_hex();
732        state.sending_chain_key = Some(outs2[1].to_hex());
733        Ok(())
734    }
735
736    /// Advance the receiving chain to `until`, banking each skipped message key
737    /// under `sender` for out-of-order delivery.
738    fn skip_message_keys(state: &mut SessionState, until: u32, sender: &str) -> Result<()> {
739        if until <= state.receiving_chain_message_number {
740            return Ok(());
741        }
742        if (until - state.receiving_chain_message_number) as usize > MAX_SKIP {
743            return Err(Nip104Error::TooManySkippedMessages);
744        }
745        let entry = state.skipped_keys.entry(sender.to_owned()).or_default();
746        while state.receiving_chain_message_number < until {
747            let rck = Self::decode_hex_32(
748                state
749                    .receiving_chain_key
750                    .as_deref()
751                    .ok_or(Nip104Error::SessionNotReady)?,
752            )?;
753            let outs = Self::kdf(&rck, &[1_u8], 2);
754            state.receiving_chain_key = Some(outs[0].to_hex());
755            entry
756                .message_keys
757                .insert(state.receiving_chain_message_number, outs[1].to_hex());
758            state.receiving_chain_message_number += 1;
759        }
760        Self::prune_skipped(&mut entry.message_keys);
761        Ok(())
762    }
763
764    /// Try a banked skipped message key for `header.number`; on success consume
765    /// it and return the plaintext.
766    fn try_skipped_message_keys(
767        state: &mut SessionState,
768        header: &Header,
769        ciphertext: &str,
770        sender: &str,
771    ) -> Result<Option<Vec<u8>>> {
772        let Some(entry) = state.skipped_keys.get_mut(sender) else {
773            return Ok(None);
774        };
775        let Some(mk_hex) = entry.message_keys.remove(&header.number) else {
776            return Ok(None);
777        };
778        let message_key = Self::decode_hex_32(&mk_hex)?;
779        let pt = Self::decrypt_with_message_key(&message_key, ciphertext)?;
780        if entry.message_keys.is_empty() {
781            state.skipped_keys.remove(sender);
782        }
783        Ok(Some(pt))
784    }
785
786    /// Decrypt the message header, trying our current/next/previous DH keys and
787    /// reporting which one matched (so the caller knows whether to ratchet).
788    fn decrypt_header(
789        state: &SessionState,
790        encrypted_header: &str,
791        sender: &str,
792    ) -> Result<(Header, HeaderTarget)> {
793        if let Some(current) = &state.our_current_nostr_key
794            && let Ok(h) =
795                Self::try_decrypt_header(&current.secret_bytes()?, sender, encrypted_header)
796        {
797            return Ok((h, HeaderTarget::Current));
798        }
799        if let Ok(h) = Self::try_decrypt_header(
800            &state.our_next_nostr_key.secret_bytes()?,
801            sender,
802            encrypted_header,
803        ) {
804            return Ok((h, HeaderTarget::Next));
805        }
806        if let Some(previous) = &state.our_previous_nostr_key
807            && let Ok(h) =
808                Self::try_decrypt_header(&previous.secret_bytes()?, sender, encrypted_header)
809        {
810            return Ok((h, HeaderTarget::Previous));
811        }
812        Err(Nip104Error::InvalidHeader)
813    }
814
815    /// Decrypt a header with one specific DH secret.
816    fn try_decrypt_header(
817        our_secret: &[u8; 32],
818        sender: &str,
819        encrypted_header: &str,
820    ) -> Result<Header> {
821        let kp = Self::from_secret_bytes(our_secret)?;
822        let json = kp.nip_44_decrypt(encrypted_header, sender)?;
823        Ok(json_bourne::parse_str(&json)?)
824    }
825
826    /// Bound the skipped-key store to [`MAX_SKIP`], evicting the oldest.
827    fn prune_skipped(map: &mut BTreeMap<u32, String>) {
828        while map.len() > MAX_SKIP {
829            let Some(first) = map.keys().next().copied() else {
830                break;
831            };
832            map.remove(&first);
833        }
834    }
835}
836
837impl<K: NostrKeypair> Nip104Crypto for K {}
838
839#[cfg(test)]
840mod tests {
841    use super::*;
842
843    use nostro2_traits::NostrSigner as _;
844
845    type K = crate::tests::NipTester;
846
847    fn shared_secret() -> [u8; 32] {
848        [7_u8; 32]
849    }
850
851    #[test]
852    fn kdf_matches_reference_shape() {
853        // Two outputs, salt and ikm distinct, deterministic.
854        let a = K::kdf(&[1_u8; 32], &[2_u8; 32], 2);
855        let b = K::kdf(&[1_u8; 32], &[2_u8; 32], 2);
856        assert_eq!(a, b);
857        assert_eq!(a.len(), 2);
858        assert_ne!(a[0], a[1]);
859    }
860
861    #[test]
862    fn header_json_is_camel_case_and_round_trips() {
863        let h = Header {
864            number: 3,
865            previous_chain_length: 2,
866            next_public_key: "ab".repeat(32),
867        };
868        let s = json_bourne::to_string(&h).unwrap();
869        // Wire format must use camelCase keys to interoperate with Iris.
870        assert!(s.contains("\"previousChainLength\":2"), "got {s}");
871        assert!(s.contains("\"nextPublicKey\":"), "got {s}");
872        assert!(!s.contains("previous_chain_length"), "got {s}");
873        let back: Header = json_bourne::parse_str(&s).unwrap();
874        assert_eq!(back, h);
875    }
876
877    #[test]
878    fn round_trip_single_message() {
879        let alice_secret = [1_u8; 32];
880        let bob_secret = [2_u8; 32];
881        let alice_pub = K::from_secret_bytes(&alice_secret).unwrap().pubkey_bytes();
882        let bob_pub = K::from_secret_bytes(&bob_secret).unwrap().pubkey_bytes();
883
884        let alice = Session::<K>::new_initiator(&bob_pub, &alice_secret, &shared_secret()).unwrap();
885        let mut bob =
886            Session::<K>::new_responder(&alice_pub, &bob_secret, &shared_secret()).unwrap();
887
888        let (alice_next, envelope) = alice.plan_send(b"hello bob").unwrap();
889        let _ = alice_next; // single message; no need to commit
890
891        let (bob_next, plaintext) = bob.plan_receive(&envelope).unwrap();
892        bob.apply(bob_next);
893        assert_eq!(plaintext, b"hello bob");
894    }
895
896    /// Drive a full back-and-forth conversation, which forces the DH ratchet
897    /// to turn on every change of speaker. This is the real test of the
898    /// double ratchet (vs. a single in-band message).
899    #[test]
900    fn bidirectional_conversation_ratchets() {
901        let alice_secret = [1_u8; 32];
902        let bob_secret = [2_u8; 32];
903        let alice_pub = K::from_secret_bytes(&alice_secret).unwrap().pubkey_bytes();
904        let bob_pub = K::from_secret_bytes(&bob_secret).unwrap().pubkey_bytes();
905
906        let mut alice =
907            Session::<K>::new_initiator(&bob_pub, &alice_secret, &shared_secret()).unwrap();
908        let mut bob =
909            Session::<K>::new_responder(&alice_pub, &bob_secret, &shared_secret()).unwrap();
910
911        // Alice -> Bob (two messages on the same chain)
912        for msg in [b"a1".as_slice(), b"a2".as_slice()] {
913            let (an, env) = alice.plan_send(msg).unwrap();
914            alice.apply(an);
915            let (bn, pt) = bob.plan_receive(&env).unwrap();
916            bob.apply(bn);
917            assert_eq!(pt, msg);
918        }
919
920        // Bob -> Alice (sender change: DH ratchet turns)
921        for msg in [b"b1".as_slice(), b"b2".as_slice(), b"b3".as_slice()] {
922            let (bn, env) = bob.plan_send(msg).unwrap();
923            bob.apply(bn);
924            let (an, pt) = alice.plan_receive(&env).unwrap();
925            alice.apply(an);
926            assert_eq!(pt, msg);
927        }
928
929        // Alice -> Bob again (ratchet turns back)
930        let (an, env) = alice.plan_send(b"a3").unwrap();
931        alice.apply(an);
932        let (bn, pt) = bob.plan_receive(&env).unwrap();
933        bob.apply(bn);
934        assert_eq!(pt, b"a3");
935    }
936
937    /// Messages that arrive out of order must still decrypt via skipped keys.
938    #[test]
939    fn out_of_order_delivery_uses_skipped_keys() {
940        let alice_secret = [3_u8; 32];
941        let bob_secret = [4_u8; 32];
942        let alice_pub = K::from_secret_bytes(&alice_secret).unwrap().pubkey_bytes();
943        let bob_pub = K::from_secret_bytes(&bob_secret).unwrap().pubkey_bytes();
944
945        let mut alice =
946            Session::<K>::new_initiator(&bob_pub, &alice_secret, &shared_secret()).unwrap();
947        let mut bob =
948            Session::<K>::new_responder(&alice_pub, &bob_secret, &shared_secret()).unwrap();
949
950        let (a1, env1) = alice.plan_send(b"first").unwrap();
951        alice.apply(a1);
952        let (a2, env2) = alice.plan_send(b"second").unwrap();
953        alice.apply(a2);
954        let (a3, env3) = alice.plan_send(b"third").unwrap();
955        alice.apply(a3);
956
957        // Bob receives 1, then 3 (skips 2), then the late 2.
958        let (bn, pt1) = bob.plan_receive(&env1).unwrap();
959        bob.apply(bn);
960        assert_eq!(pt1, b"first");
961        let (bn, pt3) = bob.plan_receive(&env3).unwrap();
962        bob.apply(bn);
963        assert_eq!(pt3, b"third");
964        let (bn, pt2) = bob.plan_receive(&env2).unwrap();
965        bob.apply(bn);
966        assert_eq!(pt2, b"second");
967    }
968
969    /// Cross-implementation oracle. Bob, reconstructed as the responder from
970    /// the vector's fixed keys, must decrypt the **actual msg1 event produced
971    /// by the reference Rust implementation** (mmalmi/nostr-double-ratchet).
972    ///
973    /// This is the real interop proof: a foreign implementation's ciphertext,
974    /// header, and KDF chain all decrypt under our native ratchet. (Encryption
975    /// is non-reproducible — random next-keys + nonces — so only decryption is
976    /// a pure function of the published inputs.)
977    #[test]
978    fn rust_reference_vector_msg1_decrypts() {
979        let vec_json = include_str!("../../test-vectors/nip104-rust-generated.json");
980
981        // Minimal field extraction (avoids a serde dep; bourne can't do
982        // arbitrary maps). The vector file is fixed, so this is safe.
983        let field = |key: &str| -> String {
984            let needle = format!("\"{key}\":");
985            let start = vec_json.find(&needle).expect("key present") + needle.len();
986            let rest = &vec_json[start..];
987            let q1 = rest.find('"').unwrap() + 1;
988            let q2 = rest[q1..].find('"').unwrap();
989            rest[q1..q1 + q2].to_string()
990        };
991
992        let bob_sk = K::decode_hex_32(&field("bob_ephemeral_sk")).unwrap();
993        let alice_pk = K::decode_hex_32(&field("alice_ephemeral_pk")).unwrap();
994        let shared = K::decode_hex_32(&field("shared_secret")).unwrap();
995        let plaintext = field("plaintext");
996        let sender = field("pubkey");
997        let header = field("header");
998        let content = field("content");
999
1000        let mut bob = Session::<K>::new_responder(&alice_pk, &bob_sk, &shared).unwrap();
1001        let envelope = MessageEnvelope {
1002            sender,
1003            encrypted_header: header,
1004            ciphertext: content,
1005        };
1006
1007        let (next, payload) = bob
1008            .plan_receive(&envelope)
1009            .expect("reference msg1 must decrypt under native ratchet");
1010        bob.apply(next);
1011
1012        // The ratchet payload is a rumor-event JSON; its `content` carries the
1013        // human plaintext. Assert the foreign plaintext survived round-trip.
1014        let decoded = String::from_utf8(payload).expect("payload is UTF-8 JSON");
1015        assert!(
1016            decoded.contains(&plaintext),
1017            "decrypted rumor {decoded:?} must contain plaintext {plaintext:?}"
1018        );
1019    }
1020
1021    /// Cross-implementation codec oracle. Parse the reference Rust impl's
1022    /// **actual signed kind-1060 event** (verifying its real Schnorr
1023    /// signature) and decrypt it end-to-end through our wire codec. Proves the
1024    /// event shape — kind, `header` tag, signing key — matches the reference,
1025    /// on top of the crypto.
1026    #[test]
1027    fn rust_reference_msg1_event_decrypts_via_codec() {
1028        let vec_json = include_str!("../../test-vectors/nip104-rust-generated.json");
1029
1030        let field = |key: &str| -> String {
1031            let needle = format!("\"{key}\":");
1032            let start = vec_json.find(&needle).expect("key present") + needle.len();
1033            let rest = &vec_json[start..];
1034            let q1 = rest.find('"').unwrap() + 1;
1035            let q2 = rest[q1..].find('"').unwrap();
1036            rest[q1..q1 + q2].to_string()
1037        };
1038
1039        let bob_sk = K::decode_hex_32(&field("bob_ephemeral_sk")).unwrap();
1040        let alice_pk = K::decode_hex_32(&field("alice_ephemeral_pk")).unwrap();
1041        let shared = K::decode_hex_32(&field("shared_secret")).unwrap();
1042        let plaintext = field("plaintext");
1043
1044        // Extract the embedded `msg1_event` JSON object and parse it as a note.
1045        let ev_start = vec_json.find("\"msg1_event\":").unwrap() + "\"msg1_event\":".len();
1046        let obj_start = vec_json[ev_start..].find('{').unwrap() + ev_start;
1047        let obj_end = vec_json[obj_start..].find('}').unwrap() + obj_start + 1;
1048        let event: nostro2::NostrNote = vec_json[obj_start..obj_end].parse().unwrap();
1049
1050        let mut bob = Session::<K>::new_responder(&alice_pk, &bob_sk, &shared).unwrap();
1051        let (next, payload) = bob
1052            .plan_receive_event(&event)
1053            .expect("reference event must decrypt via the native codec");
1054        bob.apply(next);
1055
1056        let decoded = String::from_utf8(payload).unwrap();
1057        assert!(decoded.contains(&plaintext), "got {decoded:?}");
1058    }
1059
1060    /// Full transport round-trip: Alice ratchet-encrypts, renders a *signed
1061    /// kind-1060 Nostr event*, and Bob decrypts straight from that event. This
1062    /// exercises the wire codec (kind, `header` tag, ephemeral-key signature)
1063    /// end-to-end, not just the in-memory envelope.
1064    #[test]
1065    fn message_event_codec_round_trips() {
1066        use nostro2::NostrEvent as _;
1067
1068        let alice_secret = [1_u8; 32];
1069        let bob_secret = [2_u8; 32];
1070        let alice_pub = K::from_secret_bytes(&alice_secret).unwrap().pubkey_bytes();
1071        let bob_pub = K::from_secret_bytes(&bob_secret).unwrap().pubkey_bytes();
1072
1073        let mut alice =
1074            Session::<K>::new_initiator(&bob_pub, &alice_secret, &shared_secret()).unwrap();
1075        let mut bob =
1076            Session::<K>::new_responder(&alice_pub, &bob_secret, &shared_secret()).unwrap();
1077
1078        let (anext, event) = alice
1079            .plan_send_event(b"over the wire", 1_700_000_000)
1080            .unwrap();
1081        alice.apply(anext);
1082
1083        // The rendered event must look like a real NIP-104 message.
1084        assert_eq!(event.kind, MESSAGE_EVENT_KIND);
1085        assert!(event.verify(), "event must be self-consistently signed");
1086        assert_eq!(event.created_at, 1_700_000_000);
1087        assert_eq!(event.tags.find_tags_ref(HEADER_TAG).len(), 1);
1088        // Signed by Alice's *current ephemeral* key (the chain locator).
1089        assert_eq!(event.pubkey, alice_pub.to_hex());
1090
1091        // Bob decrypts straight from the wire event.
1092        let (bnext, pt) = bob.plan_receive_event(&event).unwrap();
1093        bob.apply(bnext);
1094        assert_eq!(pt, b"over the wire");
1095    }
1096
1097    /// A tampered ciphertext invalidates the signature, so the codec rejects
1098    /// the event before it ever reaches the ratchet.
1099    #[test]
1100    fn message_event_rejects_tampering() {
1101        let alice_secret = [1_u8; 32];
1102        let bob_secret = [2_u8; 32];
1103        let alice_pub = K::from_secret_bytes(&alice_secret).unwrap().pubkey_bytes();
1104        let bob_pub = K::from_secret_bytes(&bob_secret).unwrap().pubkey_bytes();
1105
1106        let alice = Session::<K>::new_initiator(&bob_pub, &alice_secret, &shared_secret()).unwrap();
1107        let bob = Session::<K>::new_responder(&alice_pub, &bob_secret, &shared_secret()).unwrap();
1108
1109        let (_anext, mut event) = alice.plan_send_event(b"tamper me", 1_700_000_000).unwrap();
1110        event.content.push('A'); // breaks the id/signature
1111
1112        assert!(matches!(
1113            bob.plan_receive_event(&event),
1114            Err(Nip104Error::InvalidHeader)
1115        ));
1116    }
1117
1118    #[test]
1119    fn unknown_sender_rejected() {
1120        let bob_secret = [2_u8; 32];
1121        let alice_pub = K::from_secret_bytes(&[1_u8; 32]).unwrap().pubkey_bytes();
1122        let bob = Session::<K>::new_responder(&alice_pub, &bob_secret, &shared_secret()).unwrap();
1123
1124        let envelope = MessageEnvelope {
1125            sender: "cd".repeat(32),
1126            encrypted_header: "x".into(),
1127            ciphertext: "x".into(),
1128        };
1129        assert!(matches!(
1130            bob.plan_receive(&envelope),
1131            Err(Nip104Error::UnexpectedSender)
1132        ));
1133    }
1134
1135    #[test]
1136    fn error_display_covers_all_variants() {
1137        use std::error::Error as _;
1138
1139        let cases: Vec<Nip104Error> = vec![
1140            Nip104Error::CannotSendYet,
1141            Nip104Error::SessionNotReady,
1142            Nip104Error::UnexpectedSender,
1143            Nip104Error::TooManySkippedMessages,
1144            Nip104Error::InvalidHeader,
1145            Nip104Error::InvalidInvite("bad layer".into()),
1146            Nip104Error::UnknownPeer("npub1xyz".into()),
1147            Nip104Error::Signer(SignerError::InvalidSignature),
1148            Nip104Error::Nip44(crate::Nip44Error::MacMismatch),
1149            Nip104Error::Json("unexpected token".into()),
1150            Nip104Error::Base64(general_purpose::STANDARD.decode("!!!").unwrap_err()),
1151        ];
1152        for err in &cases {
1153            assert!(!format!("{err}").is_empty(), "Display empty for {err:?}");
1154            // The blanket `Error` impl carries no source; just exercise it.
1155            let _ = err.source();
1156        }
1157    }
1158}