Skip to main content

nostro2_nips/nip_104/
invite.rs

1//! NIP-104 — Invite layer (session bootstrap).
2//!
3//! Note: `inviter` / `invitee` differ by two letters by nature; renaming them
4//! for `clippy::similar_names` would only hurt readability against the
5//! reference implementation, so it is allowed module-wide below.
6//!
7//! The double ratchet in [`crate::nip_104`] establishes *forward-secret*
8//! messaging **once both sides share a session**. This module is the
9//! key-exchange that gets them there: a native port of the reference
10//! `Invite` / `inviteUtils` from
11//! [`mmalmi/nostr-double-ratchet`](https://github.com/mmalmi/nostr-double-ratchet)
12//! (what `chat.iris.to` runs).
13//!
14//! ## The handshake
15//!
16//! An **inviter** publishes (or QR/URL-shares) an [`Invite`]: an ephemeral
17//! pubkey plus a random 32-byte *shared secret* (the "link"). An **invitee**
18//! scans it and calls [`Invite::accept`], producing
19//!
20//! 1. a fresh [`Session`](crate::nip_104::Session) (initiator side), and
21//! 2. a signed **invite-response** event (kind
22//!    [`INVITE_RESPONSE_KIND`]) to publish back.
23//!
24//! The inviter consumes that event with [`Invite::receive`] to build the
25//! mirror-image responder session. From there, [`crate::nip_104`] takes over.
26//!
27//! ## Why three encryption layers
28//!
29//! The response wraps the invitee's session key three times, exactly as the
30//! reference does — each layer defends a distinct threat:
31//!
32//! | Layer | Key | Purpose |
33//! |-------|-----|---------|
34//! | inner DH | ECDH(invitee-id, inviter-id) | authenticates the invitee to the inviter |
35//! | shared-secret | the link secret (raw conv-key) | proves possession of the invite |
36//! | envelope | ECDH(random, inviter-ephemeral) | hides the invitee from anyone else holding the link |
37//!
38//! Forward secrecy survives compromise of *either* long-term identity key
39//! *and* the link, as long as the per-session keys stay secret.
40#![allow(clippy::similar_names)]
41
42use super::{Nip104Crypto, Nip104Error, Session};
43use crate::Nip44;
44use nostro2_traits::{NostrKeypair, SignerError, hex::Hexable as _};
45use zeroize::Zeroize;
46
47type Result<T> = std::result::Result<T, Nip104Error>;
48
49/// Nostr event kind for a published invite (parameterized replaceable),
50/// matching the reference `INVITE_EVENT_KIND`.
51pub const INVITE_EVENT_KIND: u32 = 30078;
52
53/// Nostr event kind for an invite *response* (the gift-wrapped acceptance),
54/// matching the reference `INVITE_RESPONSE_KIND`.
55pub const INVITE_RESPONSE_KIND: u32 = 1059;
56
57/// Inner authenticated payload: the invitee's session pubkey plus, for
58/// multi-device users, their owner/identity pubkey. camelCase to match
59/// the reference JSON byte-for-byte.
60#[derive(Debug, Clone, PartialEq, Eq, json_bourne::FromJson, json_bourne::ToJson)]
61struct AcceptPayload {
62    #[bourne(rename = "sessionKey")]
63    session_key: String,
64    #[bourne(rename = "ownerPublicKey")]
65    #[bourne(skip_if_none)]
66    owner_public_key: Option<String>,
67}
68
69/// The unsigned inner event carried inside the envelope. Mirrors the
70/// reference's `{ pubkey, content, created_at }` shape — `pubkey` is the
71/// invitee's identity key (which doubles as their device id).
72#[derive(Debug, Clone, PartialEq, Eq, json_bourne::FromJson, json_bourne::ToJson)]
73struct InnerEvent {
74    pubkey: String,
75    content: String,
76    created_at: i64,
77}
78
79/// What [`Invite::receive`] recovers from an accepted invite.
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct InviteResponse {
82    /// The invitee's identity public key (also their device id).
83    pub invitee_identity: String,
84    /// The invitee's session public key (the ratchet's first DH key).
85    pub invitee_session_pubkey: String,
86    /// The invitee's owner/Nostr identity pubkey, when supplied.
87    pub owner_public_key: Option<String>,
88}
89
90/// A double-ratchet invite: the inviter's ephemeral pubkey and the shared
91/// "link" secret. The ephemeral *secret* is present only on the inviter's own
92/// copy (it is needed to receive responses).
93#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Invite {
95    /// Inviter's ephemeral public key (x-only hex).
96    pub inviter_ephemeral_pubkey: String,
97    /// 32-byte shared secret / link (hex).
98    pub shared_secret: String,
99    /// Inviter's identity public key (x-only hex).
100    pub inviter: String,
101    /// Inviter's ephemeral secret key (hex) — present only on the inviter side.
102    pub inviter_ephemeral_privkey: Option<String>,
103    /// Optional device id (the `d`-tag suffix on a published invite).
104    pub device_id: Option<String>,
105}
106
107/// Scrub the ephemeral secret (when present) on drop. `shared_secret` is not
108/// a long-term key but is sensitive too, so it is wiped alongside it.
109impl Drop for Invite {
110    fn drop(&mut self) {
111        if let Some(sk) = self.inviter_ephemeral_privkey.as_mut() {
112            sk.zeroize();
113        }
114        self.shared_secret.zeroize();
115    }
116}
117
118impl Invite {
119    /// Mint a fresh invite for `inviter` (their identity pubkey). Generates a
120    /// new ephemeral keypair and a random 32-byte shared secret. The returned
121    /// invite holds the ephemeral secret, so keep it private — share only
122    /// [`Self::to_url`] / [`Self::to_event`] output.
123    ///
124    /// # Errors
125    /// Propagates keypair construction failure.
126    pub fn create_new<K: NostrKeypair>(inviter: &str, device_id: Option<&str>) -> Result<Self> {
127        let ephemeral = K::generate();
128        let mut secret = [0_u8; 32];
129        getrandom::fill(&mut secret)
130            .map_err(|e| Nip104Error::Signer(SignerError::Backend(format!("getrandom: {e}"))))?;
131        Ok(Self {
132            inviter_ephemeral_pubkey: ephemeral.public_key(),
133            shared_secret: secret.to_hex(),
134            inviter: inviter.to_owned(),
135            inviter_ephemeral_privkey: Some(ephemeral.secret_key()),
136            device_id: device_id.map(str::to_owned),
137        })
138    }
139
140    /// Render the shareable URL. Invite parameters live in the URL **hash**
141    /// (`#…`) so they never reach the server — matching the reference's
142    /// `getUrl`. `root` is the app origin, e.g. `https://chat.iris.to`.
143    #[must_use]
144    pub fn to_url(&self, root: &str) -> String {
145        // Minimal JSON object, field order matching the reference.
146        let json = format!(
147            r#"{{"inviter":"{}","ephemeralKey":"{}","sharedSecret":"{}"}}"#,
148            self.inviter, self.inviter_ephemeral_pubkey, self.shared_secret
149        );
150        format!("{root}#{}", Self::urlencode(&json))
151    }
152
153    /// Parse an invite from a shared URL (data in the `#` hash).
154    ///
155    /// # Errors
156    /// [`Nip104Error::InvalidInvite`] if the hash is missing, not valid JSON,
157    /// or missing a required field.
158    pub fn from_url(url: &str) -> Result<Self> {
159        let hash = url
160            .split_once('#')
161            .map(|(_, h)| h)
162            .filter(|h| !h.is_empty())
163            .ok_or_else(|| Nip104Error::InvalidInvite("no invite data in URL hash".into()))?;
164        let decoded = Self::urldecode(hash);
165        let inviter = Self::json_str_field(&decoded, "inviter")
166            .ok_or_else(|| Nip104Error::InvalidInvite("missing inviter".into()))?;
167        // The reference accepts either `ephemeralKey` or the older
168        // `inviterEphemeralPublicKey`.
169        let ephemeral = Self::json_str_field(&decoded, "ephemeralKey")
170            .or_else(|| Self::json_str_field(&decoded, "inviterEphemeralPublicKey"))
171            .ok_or_else(|| Nip104Error::InvalidInvite("missing ephemeralKey".into()))?;
172        let shared = Self::json_str_field(&decoded, "sharedSecret")
173            .ok_or_else(|| Nip104Error::InvalidInvite("missing sharedSecret".into()))?;
174        Ok(Self {
175            inviter_ephemeral_pubkey: ephemeral,
176            shared_secret: shared,
177            inviter,
178            inviter_ephemeral_privkey: None,
179            device_id: None,
180        })
181    }
182
183    /// Build the published invite event (kind [`INVITE_EVENT_KIND`]). The
184    /// caller signs it with the inviter's identity key.
185    ///
186    /// # Errors
187    /// [`Nip104Error::InvalidInvite`] if no `device_id` is set.
188    pub fn to_event(&self, created_at: i64) -> Result<nostro2::NostrNote> {
189        let device_id = self
190            .device_id
191            .as_deref()
192            .ok_or_else(|| Nip104Error::InvalidInvite("device id required".into()))?;
193        let mut tags = nostro2::NostrTags::new();
194        tags.add_custom_tag("ephemeralKey", &self.inviter_ephemeral_pubkey);
195        tags.add_custom_tag("sharedSecret", &self.shared_secret);
196        tags.add_custom_tag("d", &format!("double-ratchet/invites/{device_id}"));
197        tags.add_custom_tag("l", "double-ratchet/invites");
198        Ok(nostro2::NostrNote {
199            kind: INVITE_EVENT_KIND,
200            pubkey: self.inviter.clone(),
201            content: String::new(),
202            created_at,
203            tags,
204            ..Default::default()
205        })
206    }
207
208    /// Parse and verify a published invite event (kind [`INVITE_EVENT_KIND`]).
209    /// The resulting invite has no ephemeral secret (receive-side use only via
210    /// the inviter's own copy).
211    ///
212    /// # Errors
213    /// [`Nip104Error::InvalidInvite`] on wrong kind, bad signature, or missing
214    /// tags.
215    pub fn from_event(event: &nostro2::NostrNote) -> Result<Self> {
216        use nostro2::NostrEvent as _;
217        if event.kind != INVITE_EVENT_KIND {
218            return Err(Nip104Error::InvalidInvite("wrong kind".into()));
219        }
220        if !event.verify() {
221            return Err(Nip104Error::InvalidInvite("bad signature".into()));
222        }
223        let ephemeral = Self::first_tag(event, "ephemeralKey")
224            .ok_or_else(|| Nip104Error::InvalidInvite("missing ephemeralKey".into()))?;
225        let shared = Self::first_tag(event, "sharedSecret")
226            .ok_or_else(|| Nip104Error::InvalidInvite("missing sharedSecret".into()))?;
227        // device id is the third segment of `double-ratchet/invites/<id>`.
228        let device_id = Self::first_tag(event, "d")
229            .and_then(|d| d.split('/').nth(2).map(str::to_owned))
230            .filter(|id| id != "public");
231        Ok(Self {
232            inviter_ephemeral_pubkey: ephemeral,
233            shared_secret: shared,
234            inviter: event.pubkey.clone(),
235            inviter_ephemeral_privkey: None,
236            device_id,
237        })
238    }
239
240    /// **Invitee side.** Accept this invite: create the initiator session and
241    /// the signed response event to publish back to the inviter.
242    ///
243    /// `invitee` is the invitee's *identity* keypair (used to authenticate via
244    /// the inner DH layer and as the inner event's `pubkey`). `owner_pubkey`
245    /// is the optional multi-device owner key. `created_at` stamps the events.
246    ///
247    /// Returns the new [`Session`] and a signed kind-[`INVITE_RESPONSE_KIND`]
248    /// event. Publish the event; keep (and [`apply`](Session::apply)-drive)
249    /// the session.
250    ///
251    /// # Errors
252    /// Propagates key, NIP-44, and signing failures.
253    pub fn accept<K: NostrKeypair>(
254        &self,
255        invitee: &K,
256        owner_pubkey: Option<&str>,
257        created_at: i64,
258    ) -> Result<(Session<K>, nostro2::NostrNote)> {
259        let shared_secret = K::decode_hex_32(&self.shared_secret)?;
260        let their_ephemeral = K::decode_hex_32(&self.inviter_ephemeral_pubkey)?;
261
262        // Fresh per-session keypair; its secret seeds the initiator ratchet.
263        let session_kp = K::generate();
264        let session = Session::<K>::new_initiator(
265            &their_ephemeral,
266            &session_kp.secret_bytes(),
267            &shared_secret,
268        )?;
269
270        // Layer 1 (inner DH): authenticate the invitee to the inviter.
271        let payload = AcceptPayload {
272            session_key: session_kp.public_key(),
273            owner_public_key: owner_pubkey.map(str::to_owned),
274        };
275        let payload_json = json_bourne::to_string(&payload)?;
276        let dh_encrypted = invitee
277            .nip_44_encrypt(&payload_json, &self.inviter)?
278            .into_owned();
279
280        // Layer 2 (shared secret): prove possession of the link.
281        let inner_content = K::encrypt_with_message_key(&shared_secret, dh_encrypted.as_bytes())?;
282        let inner_event = InnerEvent {
283            pubkey: invitee.public_key(),
284            content: inner_content,
285            created_at,
286        };
287        let inner_json = json_bourne::to_string(&inner_event)?;
288
289        // Layer 3 (envelope): hide the invitee behind a random sender.
290        let random_sender = K::generate();
291        let envelope_content = random_sender
292            .nip_44_encrypt(&inner_json, &self.inviter_ephemeral_pubkey)?
293            .into_owned();
294        let mut tags = nostro2::NostrTags::new();
295        tags.add_pubkey_tag(&self.inviter_ephemeral_pubkey, None);
296        let mut envelope = nostro2::NostrNote {
297            kind: INVITE_RESPONSE_KIND,
298            content: envelope_content,
299            created_at,
300            tags,
301            ..Default::default()
302        };
303        envelope
304            .sign_with(&random_sender)
305            .map_err(|_| Nip104Error::Signer(SignerError::InvalidSignature))?;
306
307        Ok((session, envelope))
308    }
309
310    /// **Inviter side.** Consume an accepted invite-response event and build
311    /// the mirror responder session.
312    ///
313    /// `event` is the kind-[`INVITE_RESPONSE_KIND`] envelope the invitee
314    /// published. `inviter_identity` is the inviter's *identity* keypair (for
315    /// the inner DH layer). This invite must carry its ephemeral secret
316    /// ([`create_new`](Self::create_new) provides it).
317    ///
318    /// Returns the responder [`Session`] and the decoded [`InviteResponse`]
319    /// (whose `invitee_identity` names the peer).
320    ///
321    /// # Errors
322    /// [`Nip104Error::InvalidInvite`] on wrong kind / bad signature / missing
323    /// ephemeral secret, plus any crypto failure.
324    pub fn receive<K: NostrKeypair>(
325        &self,
326        event: &nostro2::NostrNote,
327        inviter_identity: &K,
328    ) -> Result<(Session<K>, InviteResponse)> {
329        use nostro2::NostrEvent as _;
330        if event.kind != INVITE_RESPONSE_KIND {
331            return Err(Nip104Error::InvalidInvite("wrong kind".into()));
332        }
333        if !event.verify() {
334            return Err(Nip104Error::InvalidInvite("bad signature".into()));
335        }
336        let ephemeral_sk = self
337            .inviter_ephemeral_privkey
338            .as_deref()
339            .ok_or_else(|| Nip104Error::InvalidInvite("ephemeral secret unavailable".into()))?;
340        let ephemeral_kp = K::from_secret_bytes(&K::decode_hex_32(ephemeral_sk)?)?;
341        let shared_secret = K::decode_hex_32(&self.shared_secret)?;
342
343        // Peel layer 3: ephemeral × random-sender.
344        let inner_json = ephemeral_kp.nip_44_decrypt(&event.content, &event.pubkey)?;
345        let inner_event: InnerEvent = json_bourne::parse_str(&inner_json)?;
346        let invitee_identity = inner_event.pubkey;
347
348        // Peel layer 2: the raw shared secret.
349        let dh_encrypted_bytes = K::decrypt_with_message_key(&shared_secret, &inner_event.content)?;
350        let dh_encrypted = String::from_utf8(dh_encrypted_bytes)
351            .map_err(|e| Nip104Error::Json(format!("inner utf8: {e}")))?;
352
353        // Peel layer 1: inviter-id × invitee-id DH.
354        let payload_json = inviter_identity.nip_44_decrypt(&dh_encrypted, &invitee_identity)?;
355        let payload: AcceptPayload = json_bourne::parse_str(&payload_json)?;
356
357        let their_session = K::decode_hex_32(&payload.session_key)?;
358        let session = Session::<K>::new_responder(
359            &their_session,
360            &ephemeral_kp.secret_bytes(),
361            &shared_secret,
362        )?;
363
364        Ok((
365            session,
366            InviteResponse {
367                invitee_identity,
368                invitee_session_pubkey: payload.session_key,
369                owner_public_key: payload.owner_public_key,
370            },
371        ))
372    }
373}
374
375// ── Helpers ───────────────────────────────────────────────────────────
376
377impl Invite {
378    /// First value of the first tag named `name`.
379    fn first_tag(event: &nostro2::NostrNote, name: &str) -> Option<String> {
380        event
381            .tags
382            .iter()
383            .find(|row| row.first().is_some_and(|t| t == name))
384            .and_then(|row| row.get(1).cloned())
385    }
386
387    /// Extract a top-level string field from a flat JSON object by key.
388    /// Adequate for the tiny, well-formed invite hash payloads (not a general
389    /// parser).
390    fn json_str_field(json: &str, key: &str) -> Option<String> {
391        let needle = format!("\"{key}\"");
392        let start = json.find(&needle)? + needle.len();
393        let rest = &json[start..];
394        let colon = rest.find(':')?;
395        let after = &rest[colon + 1..];
396        let q1 = after.find('"')? + 1;
397        let q2 = after[q1..].find('"')?;
398        Some(after[q1..q1 + q2].to_owned())
399    }
400
401    /// Percent-encode the characters the invite hash JSON can contain that are
402    /// unsafe in a URL fragment. Conservative but sufficient (and
403    /// [`urldecode`](Self::urldecode) inverts it).
404    fn urlencode(s: &str) -> String {
405        let mut out = String::with_capacity(s.len());
406        for b in s.bytes() {
407            match b {
408                b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
409                    out.push(b as char);
410                }
411                other => {
412                    const HEX: &[u8; 16] = b"0123456789ABCDEF";
413                    out.push('%');
414                    out.push(HEX[(other >> 4) as usize] as char);
415                    out.push(HEX[(other & 0xf) as usize] as char);
416                }
417            }
418        }
419        out
420    }
421
422    /// Inverse of [`urlencode`](Self::urlencode).
423    fn urldecode(s: &str) -> String {
424        let bytes = s.as_bytes();
425        let mut out = Vec::with_capacity(bytes.len());
426        let mut i = 0;
427        while i < bytes.len() {
428            if bytes[i] == b'%' && i + 2 < bytes.len() {
429                // `to_digit(16)` only ever yields 0..=15, so these casts never
430                // truncate; done on individual nibbles (not a `str` byte
431                // range) so a `%` next to a multi-byte UTF-8 char can never
432                // land mid-codepoint and panic.
433                let hi = (bytes[i + 1] as char)
434                    .to_digit(16)
435                    .and_then(|d| u8::try_from(d).ok());
436                let lo = (bytes[i + 2] as char)
437                    .to_digit(16)
438                    .and_then(|d| u8::try_from(d).ok());
439                if let (Some(hi), Some(lo)) = (hi, lo) {
440                    out.push((hi << 4) | lo);
441                    i += 3;
442                    continue;
443                }
444            }
445            out.push(bytes[i]);
446            i += 1;
447        }
448        String::from_utf8_lossy(&out).into_owned()
449    }
450}
451
452#[cfg(test)]
453mod tests {
454    use super::*;
455    // Concrete-typed `public_key()` calls below need the trait in scope; the
456    // library code reaches it through the `NostrKeypair: NostrSigner` bound.
457    use nostro2_traits::NostrSigner as _;
458
459    type K = crate::tests::NipTester;
460
461    fn ident(seed: u8) -> K {
462        K::from_secret_bytes(&[seed; 32]).unwrap()
463    }
464
465    #[test]
466    fn full_invite_handshake_bootstraps_a_session() {
467        let inviter_id = ident(0xA1);
468        let invitee_id = ident(0xB2);
469
470        // Inviter mints an invite (holds the ephemeral secret).
471        let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("dev1")).unwrap();
472        assert!(invite.inviter_ephemeral_privkey.is_some());
473
474        // Invitee accepts -> initiator session + response event.
475        let (mut invitee_session, response) = invite
476            .accept::<K>(&invitee_id, None, 1_700_000_000)
477            .unwrap();
478        assert_eq!(response.kind, INVITE_RESPONSE_KIND);
479
480        // Inviter receives -> responder session + invitee identity.
481        let (mut inviter_session, recovered) = invite.receive::<K>(&response, &inviter_id).unwrap();
482        assert_eq!(recovered.invitee_identity, invitee_id.public_key());
483
484        // The two sessions must now actually talk. Invitee (initiator) sends first.
485        let (s1, env) = invitee_session.plan_send(b"hello inviter").unwrap();
486        invitee_session.apply(s1);
487        let (s2, pt) = inviter_session.plan_receive(&env).unwrap();
488        inviter_session.apply(s2);
489        assert_eq!(pt, b"hello inviter");
490
491        // And the reply direction works too.
492        let (s3, env2) = inviter_session.plan_send(b"hello invitee").unwrap();
493        inviter_session.apply(s3);
494        let (s4, pt2) = invitee_session.plan_receive(&env2).unwrap();
495        invitee_session.apply(s4);
496        assert_eq!(pt2, b"hello invitee");
497    }
498
499    #[test]
500    fn owner_pubkey_round_trips() {
501        let inviter_id = ident(0x11);
502        let invitee_id = ident(0x22);
503        let owner = ident(0x33).public_key();
504
505        let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
506        let (_s, response) = invite
507            .accept::<K>(&invitee_id, Some(&owner), 1_700_000_000)
508            .unwrap();
509        let (_session, recovered) = invite.receive::<K>(&response, &inviter_id).unwrap();
510        assert_eq!(recovered.owner_public_key.as_deref(), Some(owner.as_str()));
511    }
512
513    #[test]
514    fn wrong_identity_key_cannot_receive() {
515        let inviter_id = ident(0x44);
516        let invitee_id = ident(0x55);
517        let impostor = ident(0x66);
518
519        let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
520        let (_s, response) = invite
521            .accept::<K>(&invitee_id, None, 1_700_000_000)
522            .unwrap();
523
524        // Same ephemeral secret (so the envelope opens), but the wrong identity
525        // key fails the inner DH layer.
526        assert!(invite.receive::<K>(&response, &impostor).is_err());
527    }
528
529    #[test]
530    fn invite_event_round_trips() {
531        let inviter_id = ident(0x77);
532        let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("dev9")).unwrap();
533        let mut event = invite.to_event(1_700_000_000).unwrap();
534        event.sign_with(&inviter_id).unwrap();
535
536        let parsed = Invite::from_event(&event).unwrap();
537        assert_eq!(
538            parsed.inviter_ephemeral_pubkey,
539            invite.inviter_ephemeral_pubkey
540        );
541        assert_eq!(parsed.shared_secret, invite.shared_secret);
542        assert_eq!(parsed.inviter, inviter_id.public_key());
543        assert_eq!(parsed.device_id.as_deref(), Some("dev9"));
544    }
545
546    #[test]
547    fn url_round_trips() {
548        let inviter_id = ident(0x88);
549        let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
550        let url = invite.to_url("https://chat.iris.to");
551        let parsed = Invite::from_url(&url).unwrap();
552        assert_eq!(parsed.inviter, invite.inviter);
553        assert_eq!(
554            parsed.inviter_ephemeral_pubkey,
555            invite.inviter_ephemeral_pubkey
556        );
557        assert_eq!(parsed.shared_secret, invite.shared_secret);
558    }
559
560    #[test]
561    fn tampered_envelope_rejected() {
562        let inviter_id = ident(0x99);
563        let invitee_id = ident(0xAA);
564        let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
565        let (_s, mut response) = invite
566            .accept::<K>(&invitee_id, None, 1_700_000_000)
567            .unwrap();
568        response.content.push('A'); // breaks the signature
569        assert!(invite.receive::<K>(&response, &inviter_id).is_err());
570    }
571
572    // ── Adversarial ───────────────────────────────────────────
573
574    /// An attacker who knows the inviter's ephemeral pubkey but **not** the
575    /// shared link secret cannot forge an acceptance: the inviter's copy holds
576    /// the true secret, so layer-2 decryption of a wrong-secret response fails.
577    #[test]
578    fn wrong_shared_secret_cannot_accept() {
579        let inviter_id = ident(0x12);
580        let invitee_id = ident(0x34);
581
582        let real = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
583        // Forge an invite with the same ephemeral pubkey but a different link.
584        let mut forged = real.clone();
585        forged.shared_secret = [0xEE_u8; 32].to_hex();
586
587        // Invitee accepts the *forged* link…
588        let (_s, response) = forged
589            .accept::<K>(&invitee_id, None, 1_700_000_000)
590            .unwrap();
591        // …but the inviter peels with the *real* secret → layer-2 mismatch.
592        assert!(real.receive::<K>(&response, &inviter_id).is_err());
593    }
594
595    /// `receive` on a non-response event kind is rejected before any crypto.
596    #[test]
597    fn receive_wrong_kind_rejected() {
598        let inviter_id = ident(0x56);
599        let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
600        let mut note = nostro2::NostrNote {
601            kind: INVITE_RESPONSE_KIND + 1,
602            content: "x".into(),
603            ..Default::default()
604        };
605        note.sign_with(&inviter_id).unwrap();
606        assert!(matches!(
607            invite.receive::<K>(&note, &inviter_id),
608            Err(Nip104Error::InvalidInvite(_))
609        ));
610    }
611
612    /// A parsed-from-event invite (the public, ephemeral-secret-less copy)
613    /// cannot `receive` — only the inviter's minting copy holds the secret.
614    #[test]
615    fn receive_without_ephemeral_secret_rejected() {
616        let inviter_id = ident(0x78);
617        let invitee_id = ident(0x9A);
618        let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("dev")).unwrap();
619        let (_s, response) = invite
620            .accept::<K>(&invitee_id, None, 1_700_000_000)
621            .unwrap();
622
623        // Round-trip through a published event strips the ephemeral secret.
624        let mut ev = invite.to_event(1_700_000_000).unwrap();
625        ev.sign_with(&inviter_id).unwrap();
626        let public_copy = Invite::from_event(&ev).unwrap();
627        assert!(public_copy.inviter_ephemeral_privkey.is_none());
628
629        assert!(matches!(
630            public_copy.receive::<K>(&response, &inviter_id),
631            Err(Nip104Error::InvalidInvite(_))
632        ));
633    }
634
635    /// Malformed invite URLs (no hash, empty hash, junk) are clean errors.
636    #[test]
637    fn malformed_urls_rejected() {
638        assert!(Invite::from_url("https://chat.iris.to").is_err());
639        assert!(Invite::from_url("https://chat.iris.to#").is_err());
640        assert!(Invite::from_url("not-a-url").is_err());
641        // Hash present but missing required fields.
642        assert!(Invite::from_url("https://x#%7B%22inviter%22%3A%22ab%22%7D").is_err());
643    }
644
645    // A '%' escape whose two "hex" bytes straddle a multi-byte UTF-8
646    // character must not panic (urldecode used to slice `str` byte ranges
647    // that could land mid-codepoint). Malformed input is simply a clean
648    // parse error, never a crash.
649    #[test]
650    fn urldecode_does_not_panic_on_non_ascii_after_percent() {
651        assert!(Invite::from_url("https://x#%a\u{e9}").is_err());
652        assert!(Invite::from_url("https://x#%\u{20ac}").is_err());
653        assert!(Invite::from_url("https://x#abc%").is_err());
654        assert!(Invite::from_url("https://x#abc%a").is_err());
655    }
656
657    /// `from_event` rejects an unsigned / wrong-kind / tagless invite event.
658    #[test]
659    fn from_event_validates() {
660        let inviter_id = ident(0xBC);
661        // Wrong kind.
662        let mut wrong = nostro2::NostrNote {
663            kind: INVITE_EVENT_KIND + 1,
664            pubkey: inviter_id.public_key(),
665            ..Default::default()
666        };
667        wrong.sign_with(&inviter_id).unwrap();
668        assert!(Invite::from_event(&wrong).is_err());
669
670        // Right kind, but unsigned (id/sig absent) → verify() fails.
671        let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("d")).unwrap();
672        let unsigned = invite.to_event(1_700_000_000).unwrap();
673        assert!(Invite::from_event(&unsigned).is_err());
674    }
675
676    /// Two invitees accepting the *same* public invite get independent
677    /// sessions; the inviter recovers each distinctly and neither response is
678    /// interchangeable.
679    #[test]
680    fn two_invitees_one_invite_are_independent() {
681        let inviter_id = ident(0xC1);
682        let alice = ident(0xC2);
683        let bob = ident(0xC3);
684        let invite = Invite::create_new::<K>(&inviter_id.public_key(), None).unwrap();
685
686        let (_sa, ra) = invite.accept::<K>(&alice, None, 1_700_000_000).unwrap();
687        let (_sb, rb) = invite.accept::<K>(&bob, None, 1_700_000_000).unwrap();
688
689        let (_s1, rec_a) = invite.receive::<K>(&ra, &inviter_id).unwrap();
690        let (_s2, rec_b) = invite.receive::<K>(&rb, &inviter_id).unwrap();
691        assert_eq!(rec_a.invitee_identity, alice.public_key());
692        assert_eq!(rec_b.invitee_identity, bob.public_key());
693        assert_ne!(rec_a.invitee_session_pubkey, rec_b.invitee_session_pubkey);
694    }
695}