Skip to main content

softchat/
envelope.rs

1//! Validated NIP-59 private-envelope construction and reading.
2
3use nostr::Keys;
4use nostr::secp256k1::rand::Rng;
5use nostr::secp256k1::rand::rngs::OsRng;
6
7use crate::diagnostics::{self, Operation};
8use crate::nip44_profile;
9use crate::{
10    LocalIdentity, MAX_PORTABLE_TIMESTAMP_SECONDS, Nip44EncryptedMessage, Nip44Payload,
11    NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrRumor, NostrTag,
12    SignedNostrEvent, SoftchatError,
13};
14
15/// Maximum number of seconds subtracted independently from private timestamps.
16pub const MAX_NIP59_TIMESTAMP_TWEAK_SECONDS: u64 = 172_800;
17
18/// The relay-storage behavior requested for one NIP-59 wrapper.
19#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
20#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
21pub enum Nip59EnvelopeKind {
22    /// Kind `1059`, retained by relays.
23    Durable,
24    /// Kind `21059`, broadcast without relay persistence.
25    Ephemeral,
26}
27
28impl Nip59EnvelopeKind {
29    const fn event_kind(self) -> NostrEventKind {
30        match self {
31            Self::Durable => NostrEventKind::GIFT_WRAP,
32            Self::Ephemeral => NostrEventKind::EPHEMERAL_GIFT_WRAP,
33        }
34    }
35
36    fn from_event_kind(kind: NostrEventKind) -> Result<Self, SoftchatError> {
37        if kind == NostrEventKind::GIFT_WRAP {
38            Ok(Self::Durable)
39        } else if kind == NostrEventKind::EPHEMERAL_GIFT_WRAP {
40            Ok(Self::Ephemeral)
41        } else {
42            Err(SoftchatError::InvalidNip59Envelope)
43        }
44    }
45}
46
47/// A rumor exposed only after every NIP-59 layer has authenticated.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct UnwrappedNip59Envelope {
50    outer_event_id: NostrEventId,
51    seal_event_id: NostrEventId,
52    kind: Nip59EnvelopeKind,
53    rumor: NostrRumor,
54}
55
56impl UnwrappedNip59Envelope {
57    /// Return the verified one-time wrapper event ID.
58    #[must_use]
59    pub const fn outer_event_id(&self) -> NostrEventId {
60        self.outer_event_id
61    }
62
63    /// Return the verified kind-13 seal event ID.
64    #[must_use]
65    pub const fn seal_event_id(&self) -> NostrEventId {
66        self.seal_event_id
67    }
68
69    /// Return whether the outer event is durable or ephemeral.
70    #[must_use]
71    pub const fn kind(&self) -> Nip59EnvelopeKind {
72        self.kind
73    }
74
75    /// Borrow the authenticated unsigned rumor.
76    #[must_use]
77    pub const fn rumor(&self) -> &NostrRumor {
78        &self.rumor
79    }
80
81    /// Consume the envelope and return its authenticated rumor.
82    #[must_use]
83    pub fn into_rumor(self) -> NostrRumor {
84        self.rumor
85    }
86}
87
88/// Flat authenticated-envelope record for generated native bindings.
89#[derive(Clone, Debug, Eq, PartialEq)]
90#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
91pub struct UnwrappedEnvelope {
92    /// Verified one-time wrapper event ID.
93    pub outer_event_id: String,
94    /// Verified kind-13 seal event ID.
95    pub seal_event_id: String,
96    /// Relay-storage behavior declared by the wrapper kind.
97    pub kind: Nip59EnvelopeKind,
98    /// Authenticated unsigned rumor.
99    pub rumor: crate::RumorEvent,
100}
101
102impl From<UnwrappedNip59Envelope> for UnwrappedEnvelope {
103    fn from(envelope: UnwrappedNip59Envelope) -> Self {
104        Self {
105            outer_event_id: envelope.outer_event_id.to_hex(),
106            seal_event_id: envelope.seal_event_id.to_hex(),
107            kind: envelope.kind,
108            rumor: crate::RumorEvent::from(&envelope.rumor),
109        }
110    }
111}
112
113impl LocalIdentity {
114    /// Create one unsigned rumor whose author is this identity.
115    ///
116    /// The draft timestamp remains the canonical application timestamp.
117    ///
118    /// # Errors
119    ///
120    /// Returns a stable draft-validation or event-size failure.
121    pub fn create_rumor(&self, draft: NostrEventDraft) -> Result<NostrRumor, SoftchatError> {
122        NostrRumor::new(self.public_key(), draft)
123    }
124
125    /// Wrap one authenticated rumor for exactly one recipient.
126    ///
127    /// A fresh wrapper key is generated for every call. Seal and wrapper
128    /// timestamps are sampled independently from `[now - 172800, now]`.
129    ///
130    /// # Errors
131    ///
132    /// Returns [`SoftchatError::Nip59EnvelopeCreationFailed`] if the rumor
133    /// author, timestamp, nested size, encryption, or signing checks fail.
134    pub fn gift_wrap(
135        &self,
136        recipient: &NostrPublicKey,
137        rumor: &NostrRumor,
138        kind: Nip59EnvelopeKind,
139        now: u64,
140    ) -> Result<SignedNostrEvent, SoftchatError> {
141        if now > MAX_PORTABLE_TIMESTAMP_SECONDS || rumor.public_key() != self.public_key() {
142            diagnostics::record_rejection(rumor.content().len());
143            return Err(SoftchatError::Nip59EnvelopeCreationFailed);
144        }
145
146        let mut rng = OsRng;
147        let largest_offset = now.min(MAX_NIP59_TIMESTAMP_TWEAK_SECONDS);
148        let seal_offset = rng.gen_range(0..=largest_offset);
149        let wrapper_offset = rng.gen_range(0..=largest_offset);
150        let wrapper_keys = Keys::generate_with_rng(&mut rng);
151
152        let result = self
153            .gift_wrap_with_parameters(
154                recipient,
155                rumor,
156                kind,
157                now - seal_offset,
158                now - wrapper_offset,
159                &wrapper_keys,
160            )
161            .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed);
162        match &result {
163            Ok(event) => diagnostics::record_success(
164                Operation::WrapEnvelope,
165                rumor.content().len(),
166                event.content().len(),
167            ),
168            Err(_) => diagnostics::record_rejection(rumor.content().len()),
169        }
170        result
171    }
172
173    /// Authenticate and unwrap one already verified NIP-59 outer event.
174    ///
175    /// No rumor is returned unless route, kind, both encrypted layers, seal
176    /// signature, seal shape, rumor ID, and author binding all validate.
177    ///
178    /// # Errors
179    ///
180    /// Every invalid layer maps to [`SoftchatError::InvalidNip59Envelope`].
181    pub fn unwrap_gift_wrap(
182        &self,
183        outer: &SignedNostrEvent,
184    ) -> Result<UnwrappedNip59Envelope, SoftchatError> {
185        let result = self
186            .unwrap_gift_wrap_inner(outer)
187            .map_err(|_| SoftchatError::InvalidNip59Envelope);
188        match &result {
189            Ok(envelope) => diagnostics::record_success(
190                Operation::UnwrapEnvelope,
191                outer.content().len(),
192                envelope.rumor().content().len(),
193            ),
194            Err(_) => diagnostics::record_rejection(outer.content().len()),
195        }
196        result
197    }
198
199    fn gift_wrap_with_parameters(
200        &self,
201        recipient: &NostrPublicKey,
202        rumor: &NostrRumor,
203        kind: Nip59EnvelopeKind,
204        seal_created_at: u64,
205        wrapper_created_at: u64,
206        wrapper_keys: &Keys,
207    ) -> Result<SignedNostrEvent, SoftchatError> {
208        if rumor.public_key() != self.public_key() {
209            return Err(SoftchatError::Nip59EnvelopeCreationFailed);
210        }
211
212        let encrypted_rumor = nip44_profile::encrypt_utf8(
213            self.keys().secret_key(),
214            recipient.as_inner(),
215            &rumor.to_json()?,
216        )
217        .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
218        let seal = NostrEventDraft::new(
219            seal_created_at,
220            NostrEventKind::SEAL,
221            Vec::new(),
222            encrypted_rumor,
223        )?
224        .sign_with_keys(self.keys())?;
225
226        let encrypted_seal = nip44_profile::encrypt_utf8(
227            wrapper_keys.secret_key(),
228            recipient.as_inner(),
229            &seal.to_json()?,
230        )
231        .map_err(|_| SoftchatError::Nip59EnvelopeCreationFailed)?;
232        let recipient_tag = NostrTag::new(vec!["p".to_owned(), recipient.to_hex()])?;
233        NostrEventDraft::new(
234            wrapper_created_at,
235            kind.event_kind(),
236            vec![recipient_tag],
237            encrypted_seal,
238        )?
239        .sign_with_keys(wrapper_keys)
240    }
241
242    fn unwrap_gift_wrap_inner(
243        &self,
244        outer: &SignedNostrEvent,
245    ) -> Result<UnwrappedNip59Envelope, SoftchatError> {
246        let kind = Nip59EnvelopeKind::from_event_kind(outer.kind())?;
247        validate_recipient_route(outer.tags(), &self.public_key())?;
248
249        let encrypted_seal = Nip44EncryptedMessage::new(
250            outer.public_key(),
251            Nip44Payload::from_encoded(outer.content())?,
252        );
253        let seal_json = self.decrypt_utf8(&encrypted_seal)?;
254        let seal = SignedNostrEvent::from_json(&seal_json)?;
255        if seal.kind() != NostrEventKind::SEAL || seal.tags().len() != 0 {
256            return Err(SoftchatError::InvalidNip59Envelope);
257        }
258
259        let encrypted_rumor = Nip44EncryptedMessage::new(
260            seal.public_key(),
261            Nip44Payload::from_encoded(seal.content())?,
262        );
263        let rumor_json = self.decrypt_utf8(&encrypted_rumor)?;
264        let rumor = NostrRumor::from_json(&rumor_json)?;
265        if rumor.public_key() != seal.public_key() {
266            return Err(SoftchatError::InvalidNip59Envelope);
267        }
268
269        Ok(UnwrappedNip59Envelope {
270            outer_event_id: outer.id(),
271            seal_event_id: seal.id(),
272            kind,
273            rumor,
274        })
275    }
276}
277
278fn validate_recipient_route<'a>(
279    tags: impl Iterator<Item = &'a [String]>,
280    recipient: &NostrPublicKey,
281) -> Result<(), SoftchatError> {
282    let mut matching_recipient = false;
283    let mut recipient_tags = 0_u8;
284
285    for tag in tags {
286        if tag.first().map(String::as_str) != Some("p") {
287            continue;
288        }
289        recipient_tags = recipient_tags
290            .checked_add(1)
291            .ok_or(SoftchatError::InvalidNip59Envelope)?;
292        let raw_public_key = tag.get(1).ok_or(SoftchatError::InvalidNip59Envelope)?;
293        let public_key = NostrPublicKey::from_hex(raw_public_key)?;
294        matching_recipient = public_key.to_hex() == *raw_public_key && &public_key == recipient;
295    }
296
297    if recipient_tags == 1 && matching_recipient {
298        Ok(())
299    } else {
300        Err(SoftchatError::InvalidNip59Envelope)
301    }
302}
303
304#[cfg(test)]
305mod tests {
306    use nostr::SecretKey;
307    use serde::Deserialize;
308
309    use super::*;
310
311    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
312    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
313    const WRAPPER_SECRET: &str = "8f40e50a84a7462e2b8d24c28898ef1f23359fff50d8c509e6fb7ce06e142f9c";
314    const OFFICIAL_EXAMPLE: &str =
315        include_str!("../../../fixtures/nostr/nip59/nip59-official-example.json");
316
317    #[derive(Deserialize)]
318    #[serde(rename_all = "camelCase")]
319    struct OfficialExample {
320        recipient_secret: String,
321        rumor: serde_json::Value,
322        seal: serde_json::Value,
323        gift_wrap: serde_json::Value,
324    }
325
326    struct OuterParameters {
327        kind: NostrEventKind,
328        tags: Vec<NostrTag>,
329        tamper_ciphertext: bool,
330    }
331
332    fn direct_message_rumor(
333        alice: &LocalIdentity,
334        bob: &LocalIdentity,
335    ) -> Result<NostrRumor, SoftchatError> {
336        alice.create_rumor(NostrEventDraft::new(
337            1_700_000_000,
338            NostrEventKind::PRIVATE_DIRECT_MESSAGE,
339            vec![NostrTag::new(vec![
340                "p".to_owned(),
341                bob.public_key().to_hex(),
342            ])?],
343            "hello Bob",
344        )?)
345    }
346
347    fn custom_envelope(
348        seal_signer: &LocalIdentity,
349        recipient: &LocalIdentity,
350        wrapper_keys: &Keys,
351        rumor_json: &str,
352        seal_tags: Vec<NostrTag>,
353        outer: OuterParameters,
354    ) -> Result<SignedNostrEvent, SoftchatError> {
355        let encrypted_rumor = nip44_profile::encrypt_utf8(
356            seal_signer.keys().secret_key(),
357            recipient.public_key().as_inner(),
358            rumor_json,
359        )
360        .map_err(|_| SoftchatError::EncryptionFailed)?;
361        let seal = NostrEventDraft::new(
362            1_700_000_100,
363            NostrEventKind::SEAL,
364            seal_tags,
365            encrypted_rumor,
366        )?
367        .sign_with_keys(seal_signer.keys())?;
368        let mut encrypted_seal = nip44_profile::encrypt_utf8(
369            wrapper_keys.secret_key(),
370            recipient.public_key().as_inner(),
371            &seal.to_json()?,
372        )
373        .map_err(|_| SoftchatError::EncryptionFailed)?;
374        if outer.tamper_ciphertext {
375            let replacement = if encrypted_seal.ends_with('A') {
376                "B"
377            } else {
378                "A"
379            };
380            encrypted_seal.replace_range(encrypted_seal.len() - 1.., replacement);
381        }
382        NostrEventDraft::new(1_700_000_200, outer.kind, outer.tags, encrypted_seal)?
383            .sign_with_keys(wrapper_keys)
384    }
385
386    #[test]
387    fn round_trips_durable_and_ephemeral_envelopes() -> Result<(), SoftchatError> {
388        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
389        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
390        let rumor = direct_message_rumor(&alice, &bob)?;
391
392        for kind in [Nip59EnvelopeKind::Durable, Nip59EnvelopeKind::Ephemeral] {
393            let outer = alice.gift_wrap(&bob.public_key(), &rumor, kind, 1_700_172_800)?;
394            let received = bob.unwrap_gift_wrap(&outer)?;
395            assert_eq!(received.kind(), kind);
396            assert_eq!(received.outer_event_id(), outer.id());
397            assert_eq!(received.rumor(), &rumor);
398            assert_ne!(received.seal_event_id(), outer.id());
399        }
400        Ok(())
401    }
402
403    #[test]
404    fn unwraps_the_official_nip59_example() -> Result<(), Box<dyn std::error::Error>> {
405        let fixture: OfficialExample = serde_json::from_str(OFFICIAL_EXAMPLE)?;
406        let recipient = LocalIdentity::from_secret_hex(&fixture.recipient_secret)?;
407        let outer = SignedNostrEvent::from_json(&fixture.gift_wrap.to_string())?;
408        let expected_rumor = NostrRumor::from_json(&fixture.rumor.to_string())?;
409        let seal = SignedNostrEvent::from_json(&fixture.seal.to_string())?;
410
411        let received = recipient.unwrap_gift_wrap(&outer)?;
412
413        assert_eq!(received.kind(), Nip59EnvelopeKind::Durable);
414        assert_eq!(received.outer_event_id(), outer.id());
415        assert_eq!(received.seal_event_id(), seal.id());
416        assert_eq!(received.rumor(), &expected_rumor);
417        Ok(())
418    }
419
420    #[test]
421    fn uses_fresh_wrapper_keys_and_private_timestamp_bounds() -> Result<(), SoftchatError> {
422        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
423        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
424        let rumor = direct_message_rumor(&alice, &bob)?;
425        let now = 1_700_172_800;
426
427        let first = alice.gift_wrap(&bob.public_key(), &rumor, Nip59EnvelopeKind::Durable, now)?;
428        let second = alice.gift_wrap(&bob.public_key(), &rumor, Nip59EnvelopeKind::Durable, now)?;
429
430        assert_ne!(first.public_key(), second.public_key());
431        for outer in [&first, &second] {
432            assert!((now - MAX_NIP59_TIMESTAMP_TWEAK_SECONDS..=now).contains(&outer.created_at()));
433        }
434        Ok(())
435    }
436
437    #[test]
438    fn deterministic_hook_keeps_private_timestamps_independent() -> Result<(), SoftchatError> {
439        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
440        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
441        let rumor = direct_message_rumor(&alice, &bob)?;
442        let wrapper_secret =
443            SecretKey::from_hex(WRAPPER_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?;
444        let wrapper_keys = Keys::new(wrapper_secret);
445
446        let outer = alice.gift_wrap_with_parameters(
447            &bob.public_key(),
448            &rumor,
449            Nip59EnvelopeKind::Durable,
450            1_700_000_100,
451            1_700_000_200,
452            &wrapper_keys,
453        )?;
454        let encrypted_seal = Nip44EncryptedMessage::new(
455            outer.public_key(),
456            Nip44Payload::from_encoded(outer.content())?,
457        );
458        let seal = SignedNostrEvent::from_json(&bob.decrypt_utf8(&encrypted_seal)?)?;
459
460        assert_eq!(seal.created_at(), 1_700_000_100);
461        assert_eq!(outer.created_at(), 1_700_000_200);
462        assert_eq!(
463            outer.public_key(),
464            NostrPublicKey::from_inner(wrapper_keys.public_key())
465        );
466        Ok(())
467    }
468
469    #[test]
470    fn rejects_wrong_recipient_and_wrong_rumor_author() -> Result<(), SoftchatError> {
471        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
472        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
473        let wrapper = LocalIdentity::from_secret_hex(WRAPPER_SECRET)?;
474        let rumor = direct_message_rumor(&alice, &bob)?;
475        let outer = alice.gift_wrap(
476            &bob.public_key(),
477            &rumor,
478            Nip59EnvelopeKind::Durable,
479            1_700_172_800,
480        )?;
481
482        assert!(matches!(
483            wrapper.unwrap_gift_wrap(&outer),
484            Err(SoftchatError::InvalidNip59Envelope)
485        ));
486        assert!(matches!(
487            wrapper.gift_wrap(
488                &bob.public_key(),
489                &rumor,
490                Nip59EnvelopeKind::Durable,
491                1_700_172_800,
492            ),
493            Err(SoftchatError::Nip59EnvelopeCreationFailed)
494        ));
495        Ok(())
496    }
497
498    #[test]
499    fn rejects_invalid_routes_kinds_seals_ciphertext_ids_and_author_binding()
500    -> Result<(), SoftchatError> {
501        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
502        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
503        let carol = LocalIdentity::from_secret_hex(WRAPPER_SECRET)?;
504        let rumor = direct_message_rumor(&alice, &bob)?;
505        let wrapper_keys = Keys::new(
506            SecretKey::from_hex(WRAPPER_SECRET).map_err(|_| SoftchatError::InvalidSecretKey)?,
507        );
508        let recipient_tag = NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?;
509        let alert_tag = NostrTag::new(vec!["alert".to_owned(), "true".to_owned()])?;
510
511        let with_non_recipient_tag = custom_envelope(
512            &alice,
513            &bob,
514            &wrapper_keys,
515            &rumor.to_json()?,
516            Vec::new(),
517            OuterParameters {
518                kind: NostrEventKind::GIFT_WRAP,
519                tags: vec![recipient_tag.clone(), alert_tag.clone()],
520                tamper_ciphertext: false,
521            },
522        )?;
523        assert_eq!(
524            bob.unwrap_gift_wrap(&with_non_recipient_tag)?.rumor(),
525            &rumor
526        );
527
528        let duplicate_route = custom_envelope(
529            &alice,
530            &bob,
531            &wrapper_keys,
532            &rumor.to_json()?,
533            Vec::new(),
534            OuterParameters {
535                kind: NostrEventKind::GIFT_WRAP,
536                tags: vec![recipient_tag.clone(), recipient_tag.clone()],
537                tamper_ciphertext: false,
538            },
539        )?;
540        let uppercase_route = custom_envelope(
541            &alice,
542            &bob,
543            &wrapper_keys,
544            &rumor.to_json()?,
545            Vec::new(),
546            OuterParameters {
547                kind: NostrEventKind::GIFT_WRAP,
548                tags: vec![NostrTag::new(vec![
549                    "p".to_owned(),
550                    bob.public_key().to_hex().to_uppercase(),
551                ])?],
552                tamper_ciphertext: false,
553            },
554        )?;
555        let wrong_kind = custom_envelope(
556            &alice,
557            &bob,
558            &wrapper_keys,
559            &rumor.to_json()?,
560            Vec::new(),
561            OuterParameters {
562                kind: NostrEventKind::FILE_METADATA,
563                tags: vec![recipient_tag.clone()],
564                tamper_ciphertext: false,
565            },
566        )?;
567        let tagged_seal = custom_envelope(
568            &alice,
569            &bob,
570            &wrapper_keys,
571            &rumor.to_json()?,
572            vec![alert_tag],
573            OuterParameters {
574                kind: NostrEventKind::GIFT_WRAP,
575                tags: vec![recipient_tag.clone()],
576                tamper_ciphertext: false,
577            },
578        )?;
579        let tampered_ciphertext = custom_envelope(
580            &alice,
581            &bob,
582            &wrapper_keys,
583            &rumor.to_json()?,
584            Vec::new(),
585            OuterParameters {
586                kind: NostrEventKind::GIFT_WRAP,
587                tags: vec![recipient_tag.clone()],
588                tamper_ciphertext: true,
589            },
590        )?;
591
592        let mut invalid_id: serde_json::Value =
593            serde_json::from_str(&rumor.to_json()?).map_err(|_| SoftchatError::InvalidEventJson)?;
594        invalid_id["id"] = serde_json::Value::String("00".repeat(32));
595        let invalid_rumor_id = custom_envelope(
596            &alice,
597            &bob,
598            &wrapper_keys,
599            &invalid_id.to_string(),
600            Vec::new(),
601            OuterParameters {
602                kind: NostrEventKind::GIFT_WRAP,
603                tags: vec![recipient_tag.clone()],
604                tamper_ciphertext: false,
605            },
606        )?;
607
608        let spoofed_rumor = carol.create_rumor(NostrEventDraft::new(
609            1_700_000_000,
610            NostrEventKind::PRIVATE_DIRECT_MESSAGE,
611            vec![recipient_tag.clone()],
612            "spoofed",
613        )?)?;
614        let author_mismatch = custom_envelope(
615            &alice,
616            &bob,
617            &wrapper_keys,
618            &spoofed_rumor.to_json()?,
619            Vec::new(),
620            OuterParameters {
621                kind: NostrEventKind::GIFT_WRAP,
622                tags: vec![recipient_tag],
623                tamper_ciphertext: false,
624            },
625        )?;
626
627        for invalid in [
628            duplicate_route,
629            uppercase_route,
630            wrong_kind,
631            tagged_seal,
632            tampered_ciphertext,
633            invalid_rumor_id,
634            author_mismatch,
635        ] {
636            assert!(matches!(
637                bob.unwrap_gift_wrap(&invalid),
638                Err(SoftchatError::InvalidNip59Envelope)
639            ));
640        }
641        Ok(())
642    }
643}