1#![allow(
4 unreachable_pub,
5 reason = "UniFFI adapter records stay inside this private core module"
6)]
7
8use nostr::Keys;
9use nostr::secp256k1::rand::Rng;
10use nostr::secp256k1::rand::rngs::OsRng;
11use serde::Serialize;
12
13use crate::diagnostics::{self, Operation};
14use crate::nip44_profile;
15use crate::{
16 LocalIdentity, MAX_PORTABLE_TIMESTAMP_SECONDS, Nip44EncryptedMessage, Nip44Payload,
17 NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrRumor, NostrTag,
18 SignedNostrEvent, SoftchatError,
19};
20
21pub const MAX_NIP59_TIMESTAMP_TWEAK_SECONDS: u64 = 172_800;
23
24struct GiftWrapParameters<'a> {
25 kind: Nip59EnvelopeKind,
26 seal_created_at: u64,
27 wrapper_created_at: u64,
28 wrapper_keys: &'a Keys,
29 notify_recipient: bool,
30}
31
32#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
34#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
35pub enum Nip59EnvelopeKind {
36 Durable,
38 Ephemeral,
40}
41
42impl Nip59EnvelopeKind {
43 const fn event_kind(self) -> NostrEventKind {
44 match self {
45 Self::Durable => NostrEventKind::GIFT_WRAP,
46 Self::Ephemeral => NostrEventKind::EPHEMERAL_GIFT_WRAP,
47 }
48 }
49
50 fn from_event_kind(kind: NostrEventKind) -> Result<Self, SoftchatError> {
51 if kind == NostrEventKind::GIFT_WRAP {
52 Ok(Self::Durable)
53 } else if kind == NostrEventKind::EPHEMERAL_GIFT_WRAP {
54 Ok(Self::Ephemeral)
55 } else {
56 Err(SoftchatError::InvalidNip59Envelope)
57 }
58 }
59}
60
61#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct Nip59EnvelopeRoute {
64 outer_event_id: NostrEventId,
65 recipient: NostrPublicKey,
66 kind: Nip59EnvelopeKind,
67}
68
69impl Nip59EnvelopeRoute {
70 #[must_use]
72 pub const fn outer_event_id(&self) -> NostrEventId {
73 self.outer_event_id
74 }
75
76 #[must_use]
78 pub fn recipient(&self) -> NostrPublicKey {
79 self.recipient.clone()
80 }
81
82 #[must_use]
84 pub const fn kind(&self) -> Nip59EnvelopeKind {
85 self.kind
86 }
87}
88
89pub fn route_nip59_envelope(json: &str) -> Result<Nip59EnvelopeRoute, SoftchatError> {
100 let outer = SignedNostrEvent::from_json(json)?;
101 let kind = Nip59EnvelopeKind::from_event_kind(outer.kind())?;
102 let recipient = recipient_route(outer.tags())?;
103 Ok(Nip59EnvelopeRoute {
104 outer_event_id: outer.id(),
105 recipient,
106 kind,
107 })
108}
109
110#[derive(Clone, Debug, Eq, PartialEq)]
112pub struct UnwrappedNip59Envelope {
113 outer_event_id: NostrEventId,
114 seal_event_id: NostrEventId,
115 kind: Nip59EnvelopeKind,
116 rumor: NostrRumor,
117}
118
119impl UnwrappedNip59Envelope {
120 #[must_use]
122 pub const fn outer_event_id(&self) -> NostrEventId {
123 self.outer_event_id
124 }
125
126 #[must_use]
128 pub const fn seal_event_id(&self) -> NostrEventId {
129 self.seal_event_id
130 }
131
132 #[must_use]
134 pub const fn kind(&self) -> Nip59EnvelopeKind {
135 self.kind
136 }
137
138 #[must_use]
140 pub const fn rumor(&self) -> &NostrRumor {
141 &self.rumor
142 }
143
144 #[must_use]
146 pub fn into_rumor(self) -> NostrRumor {
147 self.rumor
148 }
149}
150
151#[cfg(any(
153 feature = "sqlite-storage",
154 feature = "native-bindings",
155 all(feature = "javascript-bindings", target_arch = "wasm32"),
156 test
157))]
158#[derive(Clone, Debug, Eq, PartialEq)]
159#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
160#[cfg_attr(
161 not(any(
162 feature = "native-bindings",
163 all(feature = "javascript-bindings", target_arch = "wasm32"),
164 test
165 )),
166 allow(dead_code)
167)]
168pub struct UnwrappedEnvelope {
169 pub outer_event_id: String,
171 pub seal_event_id: String,
173 pub kind: Nip59EnvelopeKind,
175 pub rumor: crate::event::RumorEvent,
177}
178
179#[cfg(any(
180 feature = "sqlite-storage",
181 feature = "native-bindings",
182 all(feature = "javascript-bindings", target_arch = "wasm32"),
183 test
184))]
185impl From<UnwrappedNip59Envelope> for UnwrappedEnvelope {
186 fn from(envelope: UnwrappedNip59Envelope) -> Self {
187 Self {
188 outer_event_id: envelope.outer_event_id.to_hex(),
189 seal_event_id: envelope.seal_event_id.to_hex(),
190 kind: envelope.kind,
191 rumor: crate::event::RumorEvent::from(&envelope.rumor),
192 }
193 }
194}
195
196impl LocalIdentity {
197 pub fn create_rumor(&self, draft: NostrEventDraft) -> Result<NostrRumor, SoftchatError> {
207 NostrRumor::new(self.public_key(), draft)
208 }
209
210 pub fn gift_wrap(
222 &self,
223 recipient: &NostrPublicKey,
224 rumor: &NostrRumor,
225 kind: Nip59EnvelopeKind,
226 now: u64,
227 ) -> Result<SignedNostrEvent, SoftchatError> {
228 self.gift_wrap_for_operation(recipient, rumor, kind, now, false)
229 }
230
231 pub(crate) fn gift_wrap_for_operation(
232 &self,
233 recipient: &NostrPublicKey,
234 rumor: &NostrRumor,
235 kind: Nip59EnvelopeKind,
236 now: u64,
237 notify_recipient: bool,
238 ) -> Result<SignedNostrEvent, SoftchatError> {
239 if rumor.public_key() != self.public_key()
240 || validate_nip59_wire_size(rumor, kind, now, notify_recipient).is_err()
241 {
242 diagnostics::record_rejection(rumor.content().len());
243 return Err(SoftchatError::Nip59EnvelopeCreationFailed);
244 }
245
246 self.gift_wrap_for_operation_prevalidated(recipient, rumor, kind, now, notify_recipient)
247 }
248
249 pub(crate) fn gift_wrap_for_operation_prevalidated(
250 &self,
251 recipient: &NostrPublicKey,
252 rumor: &NostrRumor,
253 kind: Nip59EnvelopeKind,
254 now: u64,
255 notify_recipient: bool,
256 ) -> Result<SignedNostrEvent, SoftchatError> {
257 if now > MAX_PORTABLE_TIMESTAMP_SECONDS || rumor.public_key() != self.public_key() {
258 diagnostics::record_rejection(rumor.content().len());
259 return Err(SoftchatError::Nip59EnvelopeCreationFailed);
260 }
261
262 let mut rng = OsRng;
263 let largest_offset = now.min(MAX_NIP59_TIMESTAMP_TWEAK_SECONDS);
264 let seal_offset = rng.gen_range(0..=largest_offset);
265 let wrapper_offset = rng.gen_range(0..=largest_offset);
266 let wrapper_keys = Keys::generate_with_rng(&mut rng);
267
268 let result = self
269 .gift_wrap_with_parameters(
270 recipient,
271 rumor,
272 GiftWrapParameters {
273 kind,
274 seal_created_at: now - seal_offset,
275 wrapper_created_at: now - wrapper_offset,
276 wrapper_keys: &wrapper_keys,
277 notify_recipient,
278 },
279 )
280 .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed);
281 match &result {
282 Ok(event) => diagnostics::record_success(
283 Operation::WrapEnvelope,
284 rumor.content().len(),
285 event.content().len(),
286 ),
287 Err(_) => diagnostics::record_rejection(rumor.content().len()),
288 }
289 result
290 }
291
292 pub fn unwrap_gift_wrap(
303 &self,
304 outer: &SignedNostrEvent,
305 ) -> Result<UnwrappedNip59Envelope, SoftchatError> {
306 let result = self
307 .unwrap_gift_wrap_inner(outer)
308 .map_err(|_| SoftchatError::InvalidNip59Envelope);
309 match &result {
310 Ok(envelope) => diagnostics::record_success(
311 Operation::UnwrapEnvelope,
312 outer.content().len(),
313 envelope.rumor().content().len(),
314 ),
315 Err(_) => diagnostics::record_rejection(outer.content().len()),
316 }
317 result
318 }
319
320 fn gift_wrap_with_parameters(
321 &self,
322 recipient: &NostrPublicKey,
323 rumor: &NostrRumor,
324 parameters: GiftWrapParameters<'_>,
325 ) -> Result<SignedNostrEvent, SoftchatError> {
326 if rumor.public_key() != self.public_key() {
327 return Err(SoftchatError::Nip59EnvelopeCreationFailed);
328 }
329
330 let encrypted_rumor = nip44_profile::encrypt_utf8(
331 self.keys().secret_key(),
332 recipient.as_inner(),
333 &rumor.to_json()?,
334 )
335 .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
336 let seal = NostrEventDraft::new(
337 parameters.seal_created_at,
338 NostrEventKind::SEAL,
339 Vec::new(),
340 encrypted_rumor,
341 )?
342 .sign_with_keys(self.keys())?;
343
344 let encrypted_seal = nip44_profile::encrypt_utf8(
345 parameters.wrapper_keys.secret_key(),
346 recipient.as_inner(),
347 &seal.to_json()?,
348 )
349 .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
350 let recipient_tag = NostrTag::new(vec!["p".to_owned(), recipient.to_hex()])?;
351 let mut wrapper_tags = vec![recipient_tag];
352 if parameters.notify_recipient {
353 wrapper_tags.push(NostrTag::new(vec!["alert".to_owned()])?);
354 }
355 NostrEventDraft::new(
356 parameters.wrapper_created_at,
357 parameters.kind.event_kind(),
358 wrapper_tags,
359 encrypted_seal,
360 )?
361 .sign_with_keys(parameters.wrapper_keys)
362 }
363
364 fn unwrap_gift_wrap_inner(
365 &self,
366 outer: &SignedNostrEvent,
367 ) -> Result<UnwrappedNip59Envelope, SoftchatError> {
368 let kind = Nip59EnvelopeKind::from_event_kind(outer.kind())?;
369 if recipient_route(outer.tags())? != self.public_key() {
370 return Err(SoftchatError::InvalidNip59Envelope);
371 }
372
373 let encrypted_seal = Nip44EncryptedMessage::new(
374 outer.public_key(),
375 Nip44Payload::from_encoded(outer.content())?,
376 );
377 let seal_json = self.decrypt_utf8(&encrypted_seal)?;
378 let seal = SignedNostrEvent::from_json(&seal_json)?;
379 if seal.kind() != NostrEventKind::SEAL || !has_supported_seal_tags(&seal) {
380 return Err(SoftchatError::InvalidNip59Envelope);
381 }
382
383 let encrypted_rumor = Nip44EncryptedMessage::new(
384 seal.public_key(),
385 Nip44Payload::from_encoded(seal.content())?,
386 );
387 let rumor_json = self.decrypt_utf8(&encrypted_rumor)?;
388 let rumor = NostrRumor::from_json(&rumor_json)?;
389 if rumor.public_key() != seal.public_key() {
390 return Err(SoftchatError::InvalidNip59Envelope);
391 }
392
393 Ok(UnwrappedNip59Envelope {
394 outer_event_id: outer.id(),
395 seal_event_id: seal.id(),
396 kind,
397 rumor,
398 })
399 }
400}
401
402pub(crate) fn validate_nip59_wire_size(
410 rumor: &NostrRumor,
411 kind: Nip59EnvelopeKind,
412 now: u64,
413 notify_recipient: bool,
414) -> Result<(), SoftchatError> {
415 let rumor_json_bytes = rumor.to_json()?.len();
416 validate_nip59_wire_size_bytes(rumor_json_bytes, kind, now, notify_recipient)
417}
418
419#[cfg(any(feature = "sqlite-storage", test))]
420pub(crate) fn validate_nip59_draft_wire_size(
421 draft: &NostrEventDraft,
422 kind: Nip59EnvelopeKind,
423 now: u64,
424 notify_recipient: bool,
425) -> Result<(), SoftchatError> {
426 let rumor_json_bytes = draft
427 .rumor_json_bytes()
428 .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
429 validate_nip59_wire_size_bytes(rumor_json_bytes, kind, now, notify_recipient)
430}
431
432fn validate_nip59_wire_size_bytes(
433 rumor_json_bytes: usize,
434 kind: Nip59EnvelopeKind,
435 now: u64,
436 notify_recipient: bool,
437) -> Result<(), SoftchatError> {
438 if now > MAX_PORTABLE_TIMESTAMP_SECONDS {
439 return Err(SoftchatError::Nip59EnvelopeCreationFailed);
440 }
441 let encrypted_rumor_bytes = nip44_encoded_payload_len(rumor_json_bytes)
442 .ok_or(SoftchatError::Nip59EnvelopeCreationFailed)?;
443 let seal_json_bytes =
444 signed_event_json_bytes(now, NostrEventKind::SEAL, &[], encrypted_rumor_bytes)?;
445 let encrypted_seal_bytes = nip44_encoded_payload_len(seal_json_bytes)
446 .ok_or(SoftchatError::Nip59EnvelopeCreationFailed)?;
447 let mut wrapper_tags = vec![vec!["p", EVENT_KEY_PLACEHOLDER]];
448 if notify_recipient {
449 wrapper_tags.push(vec!["alert"]);
450 }
451 let wrapper_json_bytes =
452 signed_event_json_bytes(now, kind.event_kind(), &wrapper_tags, encrypted_seal_bytes)?;
453 if wrapper_json_bytes > crate::MAX_NOSTR_EVENT_JSON_BYTES {
454 return Err(SoftchatError::Nip59EnvelopeCreationFailed);
455 }
456 Ok(())
457}
458
459const EVENT_KEY_PLACEHOLDER: &str =
460 "0000000000000000000000000000000000000000000000000000000000000000";
461const EVENT_SIGNATURE_PLACEHOLDER: &str = concat!(
462 "0000000000000000000000000000000000000000000000000000000000000000",
463 "0000000000000000000000000000000000000000000000000000000000000000"
464);
465
466fn nip44_encoded_payload_len(plaintext_bytes: usize) -> Option<usize> {
467 if !(1..=crate::MAX_NIP44_WRITER_PLAINTEXT_BYTES).contains(&plaintext_bytes) {
468 return None;
469 }
470 let padded_bytes = if plaintext_bytes <= 32 {
471 32
472 } else {
473 let next_power = plaintext_bytes.checked_next_power_of_two()?;
474 let chunk = if next_power <= 256 {
475 32
476 } else {
477 next_power.checked_div(8)?
478 };
479 chunk.checked_mul(
480 plaintext_bytes
481 .checked_sub(1)?
482 .checked_div(chunk)?
483 .checked_add(1)?,
484 )?
485 };
486 let binary_bytes = padded_bytes.checked_add(67)?;
489 binary_bytes.checked_add(2)?.checked_div(3)?.checked_mul(4)
490}
491
492fn signed_event_json_bytes(
493 created_at: u64,
494 kind: NostrEventKind,
495 tags: &[Vec<&str>],
496 content_bytes: usize,
497) -> Result<usize, SoftchatError> {
498 #[derive(Serialize)]
499 struct SignedEventProbe<'a> {
500 id: &'static str,
501 pubkey: &'static str,
502 created_at: u64,
503 kind: u16,
504 tags: &'a [Vec<&'a str>],
505 content: &'static str,
506 sig: &'static str,
507 }
508
509 let probe = SignedEventProbe {
510 id: EVENT_KEY_PLACEHOLDER,
511 pubkey: EVENT_KEY_PLACEHOLDER,
512 created_at,
513 kind: kind.as_u16(),
514 tags,
515 content: "",
516 sig: EVENT_SIGNATURE_PLACEHOLDER,
517 };
518 serde_json::to_string(&probe)
519 .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?
520 .len()
521 .checked_add(content_bytes)
522 .ok_or(SoftchatError::Nip59EnvelopeCreationFailed)
523}
524
525fn has_supported_seal_tags(seal: &SignedNostrEvent) -> bool {
526 let tags = seal.tags().collect::<Vec<_>>();
527 match tags.as_slice() {
528 [] => true,
529 [tag]
530 if tag.len() == 2
531 && tag.first().map(String::as_str) == Some("expiration")
532 && tag
533 .get(1)
534 .and_then(|value| value.parse::<u64>().ok())
535 .is_some() =>
536 {
537 true
538 }
539 _ => false,
540 }
541}
542
543fn recipient_route<'a>(
544 tags: impl Iterator<Item = &'a [String]>,
545) -> Result<NostrPublicKey, SoftchatError> {
546 let mut recipient = None;
547 let mut recipient_tags = 0_u8;
548
549 for tag in tags {
550 if tag.first().map(String::as_str) != Some("p") {
551 continue;
552 }
553 recipient_tags = recipient_tags
554 .checked_add(1)
555 .ok_or(SoftchatError::InvalidNip59Envelope)?;
556 let raw_public_key = tag.get(1).ok_or(SoftchatError::InvalidNip59Envelope)?;
557 let public_key = NostrPublicKey::from_hex(raw_public_key)?;
558 if public_key.to_hex() != *raw_public_key {
559 return Err(SoftchatError::InvalidNip59Envelope);
560 }
561 recipient = Some(public_key);
562 }
563
564 match (recipient_tags, recipient) {
565 (1, Some(recipient)) => Ok(recipient),
566 _ => Err(SoftchatError::InvalidNip59Envelope),
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use nostr::SecretKey;
573 use serde::Deserialize;
574
575 use super::*;
576 use crate::{AttachmentMetadata, ChatMessageDraft, ChatRelation};
577
578 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
579 const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
580 const WRAPPER_SECRET: &str = "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c";
581 const OFFICIAL_EXAMPLE: &str =
582 include_str!("../../../fixtures/nostr/nip59/nip59-official-example.json");
583
584 #[derive(Deserialize)]
585 #[serde(rename_all = "camelCase")]
586 struct OfficialExample {
587 recipient_secret: String,
588 rumor: serde_json::Value,
589 seal: serde_json::Value,
590 gift_wrap: serde_json::Value,
591 }
592
593 struct OuterParameters {
594 kind: NostrEventKind,
595 tags: Vec<NostrTag>,
596 tamper_ciphertext: bool,
597 }
598
599 #[derive(Clone, Copy)]
600 enum BoundaryShape {
601 Plain,
602 Reply,
603 Group,
604 Tags,
605 Attachment,
606 }
607
608 fn direct_message_rumor(
609 alice: &LocalIdentity,
610 bob: &LocalIdentity,
611 ) -> Result<NostrRumor, SoftchatError> {
612 alice.create_rumor(NostrEventDraft::new(
613 1_700_000_000,
614 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
615 vec![NostrTag::new(vec![
616 "p".to_owned(),
617 bob.public_key().to_hex(),
618 ])?],
619 "hello Bob",
620 )?)
621 }
622
623 fn custom_envelope(
624 seal_signer: &LocalIdentity,
625 recipient: &LocalIdentity,
626 wrapper_keys: &Keys,
627 rumor_json: &str,
628 seal_tags: Vec<NostrTag>,
629 outer: OuterParameters,
630 ) -> Result<SignedNostrEvent, SoftchatError> {
631 let encrypted_rumor = nip44_profile::encrypt_utf8(
632 seal_signer.keys().secret_key(),
633 recipient.public_key().as_inner(),
634 rumor_json,
635 )
636 .map_err(|_| SoftchatError::EncryptionFailed)?;
637 let seal = NostrEventDraft::new(
638 1_700_000_100,
639 NostrEventKind::SEAL,
640 seal_tags,
641 encrypted_rumor,
642 )?
643 .sign_with_keys(seal_signer.keys())?;
644 let mut encrypted_seal = nip44_profile::encrypt_utf8(
645 wrapper_keys.secret_key(),
646 recipient.public_key().as_inner(),
647 &seal.to_json()?,
648 )
649 .map_err(|_| SoftchatError::EncryptionFailed)?;
650 if outer.tamper_ciphertext {
651 let replacement = if encrypted_seal.ends_with('A') {
652 "B"
653 } else {
654 "A"
655 };
656 encrypted_seal.replace_range(encrypted_seal.len() - 1.., replacement);
657 }
658 NostrEventDraft::new(1_700_000_200, outer.kind, outer.tags, encrypted_seal)?
659 .sign_with_keys(wrapper_keys)
660 }
661
662 fn boundary_draft(
663 alice: &LocalIdentity,
664 bob: &LocalIdentity,
665 shape: BoundaryShape,
666 content: String,
667 ) -> Result<NostrEventDraft, SoftchatError> {
668 let carol = LocalIdentity::from_secret_hex(
669 "0000000000000000000000000000000000000000000000000000000000000002",
670 )?;
671 let participants = match shape {
672 BoundaryShape::Group => vec![bob.public_key(), carol.public_key()],
673 _ => vec![bob.public_key()],
674 };
675 let relation = match shape {
676 BoundaryShape::Reply => ChatRelation::Reply {
677 event_id: NostrEventId::from_hex(&"11".repeat(32))?,
678 relay_hint: Some("wss://relay.example/path".to_owned()),
679 legacy_unmarked: false,
680 },
681 _ => ChatRelation::None,
682 };
683 let attachments = match shape {
684 BoundaryShape::Attachment => vec![AttachmentMetadata {
685 url: "https://media.example/attachment".to_owned(),
686 mime_type: Some("image/jpeg".to_owned()),
687 sha256: Some("22".repeat(32)),
688 name: Some("boundary image.jpg".to_owned()),
689 ..AttachmentMetadata::default()
690 }],
691 _ => Vec::new(),
692 };
693 let (emoji_tags, extension_tags) = match shape {
694 BoundaryShape::Tags => (
695 vec![NostrTag::new(vec![
696 "emoji",
697 "softchat",
698 "https://emoji.example/softchat.png",
699 ])?],
700 vec![NostrTag::new(vec!["client", "boundary-fixture"])?],
701 ),
702 _ => (Vec::new(), Vec::new()),
703 };
704 crate::chat::prepare_chat_message_draft(
705 alice.public_key(),
706 ChatMessageDraft {
707 created_at: 1_700_172_800,
708 participants,
709 content,
710 relation,
711 attachments,
712 emoji_tags,
713 extension_tags,
714 },
715 )
716 }
717
718 fn boundary_rumor(
719 alice: &LocalIdentity,
720 bob: &LocalIdentity,
721 shape: BoundaryShape,
722 content: String,
723 ) -> Result<NostrRumor, SoftchatError> {
724 alice.create_rumor(boundary_draft(alice, bob, shape, content)?)
725 }
726
727 fn largest_valid_units(
728 mut rumor: impl FnMut(usize) -> Result<NostrRumor, SoftchatError>,
729 maximum_units: usize,
730 ) -> Result<usize, SoftchatError> {
731 let mut lower = 1;
732 let mut upper = maximum_units
733 .checked_add(1)
734 .ok_or(SoftchatError::Nip59EnvelopeCreationFailed)?;
735 while lower + 1 < upper {
736 let middle = lower + (upper - lower) / 2;
737 if validate_nip59_wire_size(
738 &rumor(middle)?,
739 Nip59EnvelopeKind::Durable,
740 1_700_172_800,
741 true,
742 )
743 .is_ok()
744 {
745 lower = middle;
746 } else {
747 upper = middle;
748 }
749 }
750 Ok(lower)
751 }
752
753 #[test]
754 fn round_trips_durable_and_ephemeral_envelopes() -> Result<(), SoftchatError> {
755 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
756 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
757 let rumor = direct_message_rumor(&alice, &bob)?;
758
759 for kind in [Nip59EnvelopeKind::Durable, Nip59EnvelopeKind::Ephemeral] {
760 let outer = alice.gift_wrap(&bob.public_key(), &rumor, kind, 1_700_172_800)?;
761 let received = bob.unwrap_gift_wrap(&outer)?;
762 assert_eq!(received.kind(), kind);
763 assert_eq!(received.outer_event_id(), outer.id());
764 assert_eq!(received.rumor(), &rumor);
765 assert_ne!(received.seal_event_id(), outer.id());
766 }
767 Ok(())
768 }
769
770 #[test]
771 fn wire_preflight_matches_double_wrap_at_every_product_message_boundary()
772 -> Result<(), SoftchatError> {
773 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
774 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
775 let wrapper_secret =
776 SecretKey::from_hex(WRAPPER_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?;
777 let wrapper_keys = Keys::new(wrapper_secret);
778
779 let verify = |shape, unit: &str| -> Result<(), SoftchatError> {
780 let text = |units| unit.repeat(units);
781 let maximum_units = crate::MAX_NIP44_WRITER_PLAINTEXT_BYTES / unit.len();
782 let boundary = largest_valid_units(
783 |units| boundary_rumor(&alice, &bob, shape, text(units)),
784 maximum_units,
785 )?;
786 let accepted = boundary_rumor(&alice, &bob, shape, text(boundary))?;
787 let rejected = boundary_rumor(&alice, &bob, shape, text(boundary + 1))?;
788 let accepted_draft = boundary_draft(&alice, &bob, shape, text(boundary))?;
789 let rejected_draft = boundary_draft(&alice, &bob, shape, text(boundary + 1))?;
790 assert_eq!(
791 accepted_draft.rumor_json_bytes()?,
792 accepted.to_json()?.len()
793 );
794 assert_eq!(
795 rejected_draft.rumor_json_bytes()?,
796 rejected.to_json()?.len()
797 );
798 assert!(
799 validate_nip59_wire_size(
800 &accepted,
801 Nip59EnvelopeKind::Durable,
802 1_700_172_800,
803 true,
804 )
805 .is_ok()
806 );
807 assert!(
808 validate_nip59_wire_size(
809 &rejected,
810 Nip59EnvelopeKind::Durable,
811 1_700_172_800,
812 true,
813 )
814 .is_err()
815 );
816 assert!(
817 validate_nip59_draft_wire_size(
818 &accepted_draft,
819 Nip59EnvelopeKind::Durable,
820 1_700_172_800,
821 true,
822 )
823 .is_ok()
824 );
825 assert!(
826 validate_nip59_draft_wire_size(
827 &rejected_draft,
828 Nip59EnvelopeKind::Durable,
829 1_700_172_800,
830 true,
831 )
832 .is_err()
833 );
834 alice.gift_wrap_with_parameters(
835 &bob.public_key(),
836 &accepted,
837 GiftWrapParameters {
838 kind: Nip59EnvelopeKind::Durable,
839 seal_created_at: 1_700_172_800,
840 wrapper_created_at: 1_700_172_800,
841 wrapper_keys: &wrapper_keys,
842 notify_recipient: true,
843 },
844 )?;
845 assert!(
846 alice
847 .gift_wrap_with_parameters(
848 &bob.public_key(),
849 &rejected,
850 GiftWrapParameters {
851 kind: Nip59EnvelopeKind::Durable,
852 seal_created_at: 1_700_172_800,
853 wrapper_created_at: 1_700_172_800,
854 wrapper_keys: &wrapper_keys,
855 notify_recipient: true,
856 },
857 )
858 .is_err()
859 );
860 Ok(())
861 };
862
863 for shape in [
864 BoundaryShape::Plain,
865 BoundaryShape::Reply,
866 BoundaryShape::Group,
867 BoundaryShape::Tags,
868 BoundaryShape::Attachment,
869 ] {
870 verify(shape, "a")?;
871 }
872 verify(BoundaryShape::Plain, "🦀")?;
873 verify(BoundaryShape::Plain, "\"")?;
874 Ok(())
875 }
876
877 #[test]
878 fn wire_preflight_uses_the_exact_nip44_encoded_length() -> Result<(), SoftchatError> {
879 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
880 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
881 for plaintext_bytes in [1, 32, 33, 256, 257, 65_408, 65_535] {
882 let payload = nip44_profile::encrypt_utf8(
883 alice.keys().secret_key(),
884 bob.public_key().as_inner(),
885 &"x".repeat(plaintext_bytes),
886 )
887 .map_err(|_| SoftchatError::EncryptionFailed)?;
888 assert_eq!(
889 nip44_encoded_payload_len(plaintext_bytes),
890 Some(payload.len())
891 );
892 }
893 Ok(())
894 }
895
896 #[test]
897 fn routes_only_verified_single_recipient_wrappers() -> Result<(), SoftchatError> {
898 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
899 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
900 let rumor = direct_message_rumor(&alice, &bob)?;
901 let outer = alice.gift_wrap(
902 &bob.public_key(),
903 &rumor,
904 Nip59EnvelopeKind::Durable,
905 1_700_172_800,
906 )?;
907 let route = route_nip59_envelope(&outer.to_json()?)?;
908
909 assert_eq!(route.outer_event_id(), outer.id());
910 assert_eq!(route.recipient(), bob.public_key());
911 assert_eq!(route.kind(), Nip59EnvelopeKind::Durable);
912
913 let duplicate_recipient = NostrEventDraft::new(
914 outer.created_at(),
915 NostrEventKind::GIFT_WRAP,
916 vec![
917 NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?,
918 NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?,
919 ],
920 outer.content().to_owned(),
921 )?
922 .sign_with_keys(alice.keys())?;
923 assert!(matches!(
924 route_nip59_envelope(&duplicate_recipient.to_json()?),
925 Err(SoftchatError::InvalidNip59Envelope)
926 ));
927
928 let wrong_kind = NostrEventDraft::new(
929 outer.created_at(),
930 NostrEventKind::SHORT_TEXT_NOTE,
931 vec![NostrTag::new(vec![
932 "p".to_owned(),
933 bob.public_key().to_hex(),
934 ])?],
935 outer.content().to_owned(),
936 )?
937 .sign_with_keys(alice.keys())?;
938 assert!(matches!(
939 route_nip59_envelope(&wrong_kind.to_json()?),
940 Err(SoftchatError::InvalidNip59Envelope)
941 ));
942 Ok(())
943 }
944
945 #[test]
946 fn unwraps_the_official_nip59_example() -> Result<(), Box<dyn std::error::Error>> {
947 let fixture: OfficialExample = serde_json::from_str(OFFICIAL_EXAMPLE)?;
948 let recipient = LocalIdentity::from_secret_hex(&fixture.recipient_secret)?;
949 let outer = SignedNostrEvent::from_json(&fixture.gift_wrap.to_string())?;
950 let expected_rumor = NostrRumor::from_json(&fixture.rumor.to_string())?;
951 let seal = SignedNostrEvent::from_json(&fixture.seal.to_string())?;
952
953 let received = recipient.unwrap_gift_wrap(&outer)?;
954
955 assert_eq!(received.kind(), Nip59EnvelopeKind::Durable);
956 assert_eq!(received.outer_event_id(), outer.id());
957 assert_eq!(received.seal_event_id(), seal.id());
958 assert_eq!(received.rumor(), &expected_rumor);
959 Ok(())
960 }
961
962 #[test]
963 fn uses_fresh_wrapper_keys_and_private_timestamp_bounds() -> Result<(), SoftchatError> {
964 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
965 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
966 let rumor = direct_message_rumor(&alice, &bob)?;
967 let now = 1_700_172_800;
968
969 let first = alice.gift_wrap(&bob.public_key(), &rumor, Nip59EnvelopeKind::Durable, now)?;
970 let second = alice.gift_wrap(&bob.public_key(), &rumor, Nip59EnvelopeKind::Durable, now)?;
971
972 assert_ne!(first.public_key(), second.public_key());
973 for outer in [&first, &second] {
974 assert!((now - MAX_NIP59_TIMESTAMP_TWEAK_SECONDS..=now).contains(&outer.created_at()));
975 }
976 Ok(())
977 }
978
979 #[test]
980 fn deterministic_hook_keeps_private_timestamps_independent() -> Result<(), SoftchatError> {
981 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
982 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
983 let rumor = direct_message_rumor(&alice, &bob)?;
984 let wrapper_secret =
985 SecretKey::from_hex(WRAPPER_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?;
986 let wrapper_keys = Keys::new(wrapper_secret);
987
988 let outer = alice.gift_wrap_with_parameters(
989 &bob.public_key(),
990 &rumor,
991 GiftWrapParameters {
992 kind: Nip59EnvelopeKind::Durable,
993 seal_created_at: 1_700_000_100,
994 wrapper_created_at: 1_700_000_200,
995 wrapper_keys: &wrapper_keys,
996 notify_recipient: false,
997 },
998 )?;
999 let encrypted_seal = Nip44EncryptedMessage::new(
1000 outer.public_key(),
1001 Nip44Payload::from_encoded(outer.content())?,
1002 );
1003 let seal = SignedNostrEvent::from_json(&bob.decrypt_utf8(&encrypted_seal)?)?;
1004
1005 assert_eq!(seal.created_at(), 1_700_000_100);
1006 assert_eq!(outer.created_at(), 1_700_000_200);
1007 assert_eq!(
1008 outer.public_key(),
1009 NostrPublicKey::from_inner(wrapper_keys.public_key())
1010 );
1011 Ok(())
1012 }
1013
1014 #[test]
1015 fn rejects_wrong_recipient_and_wrong_rumor_author() -> Result<(), SoftchatError> {
1016 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1017 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
1018 let wrapper = LocalIdentity::from_secret_hex(WRAPPER_SECRET)?;
1019 let rumor = direct_message_rumor(&alice, &bob)?;
1020 let outer = alice.gift_wrap(
1021 &bob.public_key(),
1022 &rumor,
1023 Nip59EnvelopeKind::Durable,
1024 1_700_172_800,
1025 )?;
1026
1027 assert!(matches!(
1028 wrapper.unwrap_gift_wrap(&outer),
1029 Err(SoftchatError::InvalidNip59Envelope)
1030 ));
1031 assert!(matches!(
1032 wrapper.gift_wrap(
1033 &bob.public_key(),
1034 &rumor,
1035 Nip59EnvelopeKind::Durable,
1036 1_700_172_800,
1037 ),
1038 Err(SoftchatError::Nip59EnvelopeCreationFailed)
1039 ));
1040 Ok(())
1041 }
1042
1043 #[test]
1044 fn rejects_invalid_routes_kinds_seals_ciphertext_ids_and_author_binding()
1045 -> Result<(), SoftchatError> {
1046 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1047 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
1048 let carol = LocalIdentity::from_secret_hex(WRAPPER_SECRET)?;
1049 let rumor = direct_message_rumor(&alice, &bob)?;
1050 let wrapper_keys = Keys::new(
1051 SecretKey::from_hex(WRAPPER_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?,
1052 );
1053 let recipient_tag = NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?;
1054 let alert_tag = NostrTag::new(vec!["alert".to_owned(), "true".to_owned()])?;
1055
1056 let with_non_recipient_tag = custom_envelope(
1057 &alice,
1058 &bob,
1059 &wrapper_keys,
1060 &rumor.to_json()?,
1061 Vec::new(),
1062 OuterParameters {
1063 kind: NostrEventKind::GIFT_WRAP,
1064 tags: vec![recipient_tag.clone(), alert_tag.clone()],
1065 tamper_ciphertext: false,
1066 },
1067 )?;
1068 assert_eq!(
1069 bob.unwrap_gift_wrap(&with_non_recipient_tag)?.rumor(),
1070 &rumor
1071 );
1072
1073 let duplicate_route = custom_envelope(
1074 &alice,
1075 &bob,
1076 &wrapper_keys,
1077 &rumor.to_json()?,
1078 Vec::new(),
1079 OuterParameters {
1080 kind: NostrEventKind::GIFT_WRAP,
1081 tags: vec![recipient_tag.clone(), recipient_tag.clone()],
1082 tamper_ciphertext: false,
1083 },
1084 )?;
1085 let uppercase_route = custom_envelope(
1086 &alice,
1087 &bob,
1088 &wrapper_keys,
1089 &rumor.to_json()?,
1090 Vec::new(),
1091 OuterParameters {
1092 kind: NostrEventKind::GIFT_WRAP,
1093 tags: vec![NostrTag::new(vec![
1094 "p".to_owned(),
1095 bob.public_key().to_hex().to_uppercase(),
1096 ])?],
1097 tamper_ciphertext: false,
1098 },
1099 )?;
1100 let wrong_kind = custom_envelope(
1101 &alice,
1102 &bob,
1103 &wrapper_keys,
1104 &rumor.to_json()?,
1105 Vec::new(),
1106 OuterParameters {
1107 kind: NostrEventKind::FILE_METADATA,
1108 tags: vec![recipient_tag.clone()],
1109 tamper_ciphertext: false,
1110 },
1111 )?;
1112 let tagged_seal = custom_envelope(
1113 &alice,
1114 &bob,
1115 &wrapper_keys,
1116 &rumor.to_json()?,
1117 vec![alert_tag],
1118 OuterParameters {
1119 kind: NostrEventKind::GIFT_WRAP,
1120 tags: vec![recipient_tag.clone()],
1121 tamper_ciphertext: false,
1122 },
1123 )?;
1124 let tampered_ciphertext = custom_envelope(
1125 &alice,
1126 &bob,
1127 &wrapper_keys,
1128 &rumor.to_json()?,
1129 Vec::new(),
1130 OuterParameters {
1131 kind: NostrEventKind::GIFT_WRAP,
1132 tags: vec![recipient_tag.clone()],
1133 tamper_ciphertext: true,
1134 },
1135 )?;
1136
1137 let mut invalid_id: serde_json::Value =
1138 serde_json::from_str(&rumor.to_json()?).map_err(|_| SoftchatError::InvalidEventJson)?;
1139 invalid_id["id"] = serde_json::Value::String("00".repeat(32));
1140 let invalid_rumor_id = custom_envelope(
1141 &alice,
1142 &bob,
1143 &wrapper_keys,
1144 &invalid_id.to_string(),
1145 Vec::new(),
1146 OuterParameters {
1147 kind: NostrEventKind::GIFT_WRAP,
1148 tags: vec![recipient_tag.clone()],
1149 tamper_ciphertext: false,
1150 },
1151 )?;
1152
1153 let spoofed_rumor = carol.create_rumor(NostrEventDraft::new(
1154 1_700_000_000,
1155 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
1156 vec![recipient_tag.clone()],
1157 "spoofed",
1158 )?)?;
1159 let author_mismatch = custom_envelope(
1160 &alice,
1161 &bob,
1162 &wrapper_keys,
1163 &spoofed_rumor.to_json()?,
1164 Vec::new(),
1165 OuterParameters {
1166 kind: NostrEventKind::GIFT_WRAP,
1167 tags: vec![recipient_tag],
1168 tamper_ciphertext: false,
1169 },
1170 )?;
1171
1172 for invalid in [
1173 duplicate_route,
1174 uppercase_route,
1175 wrong_kind,
1176 tagged_seal,
1177 tampered_ciphertext,
1178 invalid_rumor_id,
1179 author_mismatch,
1180 ] {
1181 assert!(matches!(
1182 bob.unwrap_gift_wrap(&invalid),
1183 Err(SoftchatError::InvalidNip59Envelope)
1184 ));
1185 }
1186 Ok(())
1187 }
1188
1189 #[test]
1190 fn accepts_only_the_released_expiration_seal_compatibility_shape() -> Result<(), SoftchatError>
1191 {
1192 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
1193 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
1194 let wrapper_keys = Keys::generate();
1195 let rumor = direct_message_rumor(&alice, &bob)?;
1196 let recipient_tag = NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?;
1197
1198 let compatible = custom_envelope(
1199 &alice,
1200 &bob,
1201 &wrapper_keys,
1202 &rumor.to_json()?,
1203 vec![NostrTag::new(vec![
1204 "expiration".to_owned(),
1205 "4102444800".to_owned(),
1206 ])?],
1207 OuterParameters {
1208 kind: NostrEventKind::GIFT_WRAP,
1209 tags: vec![recipient_tag.clone()],
1210 tamper_ciphertext: false,
1211 },
1212 )?;
1213 assert_eq!(bob.unwrap_gift_wrap(&compatible)?.rumor(), &rumor);
1214
1215 for invalid_tags in [
1216 vec![NostrTag::new(vec![
1217 "expiration".to_owned(),
1218 "-1".to_owned(),
1219 ])?],
1220 vec![NostrTag::new(vec![
1221 "expiration".to_owned(),
1222 "not-a-number".to_owned(),
1223 ])?],
1224 vec![NostrTag::new(vec![
1225 "expiration".to_owned(),
1226 "4102444800".to_owned(),
1227 "extra".to_owned(),
1228 ])?],
1229 vec![
1230 NostrTag::new(vec!["expiration".to_owned(), "4102444800".to_owned()])?,
1231 NostrTag::new(vec!["expiration".to_owned(), "4102444801".to_owned()])?,
1232 ],
1233 ] {
1234 let outer = custom_envelope(
1235 &alice,
1236 &bob,
1237 &wrapper_keys,
1238 &rumor.to_json()?,
1239 invalid_tags,
1240 OuterParameters {
1241 kind: NostrEventKind::GIFT_WRAP,
1242 tags: vec![recipient_tag.clone()],
1243 tamper_ciphertext: false,
1244 },
1245 )?;
1246 assert!(matches!(
1247 bob.unwrap_gift_wrap(&outer),
1248 Err(SoftchatError::InvalidNip59Envelope)
1249 ));
1250 }
1251 Ok(())
1252 }
1253}