1#![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
49pub const INVITE_EVENT_KIND: u32 = 30078;
52
53pub const INVITE_RESPONSE_KIND: u32 = 1059;
56
57#[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct InviteResponse {
82 pub invitee_identity: String,
84 pub invitee_session_pubkey: String,
86 pub owner_public_key: Option<String>,
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
94pub struct Invite {
95 pub inviter_ephemeral_pubkey: String,
97 pub shared_secret: String,
99 pub inviter: String,
101 pub inviter_ephemeral_privkey: Option<String>,
103 pub device_id: Option<String>,
105}
106
107impl 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 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 #[must_use]
144 pub fn to_url(&self, root: &str) -> String {
145 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
375impl Invite {
378 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 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 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 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 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 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 let invite = Invite::create_new::<K>(&inviter_id.public_key(), Some("dev1")).unwrap();
472 assert!(invite.inviter_ephemeral_privkey.is_some());
473
474 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 let (mut inviter_session, recovered) = invite.receive::<K>(&response, &inviter_id).unwrap();
482 assert_eq!(recovered.invitee_identity, invitee_id.public_key());
483
484 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 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 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'); assert!(invite.receive::<K>(&response, &inviter_id).is_err());
570 }
571
572 #[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 let mut forged = real.clone();
585 forged.shared_secret = [0xEE_u8; 32].to_hex();
586
587 let (_s, response) = forged
589 .accept::<K>(&invitee_id, None, 1_700_000_000)
590 .unwrap();
591 assert!(real.receive::<K>(&response, &inviter_id).is_err());
593 }
594
595 #[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>(¬e, &inviter_id),
608 Err(Nip104Error::InvalidInvite(_))
609 ));
610 }
611
612 #[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 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 #[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 assert!(Invite::from_url("https://x#%7B%22inviter%22%3A%22ab%22%7D").is_err());
643 }
644
645 #[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 #[test]
659 fn from_event_validates() {
660 let inviter_id = ident(0xBC);
661 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 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 #[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}