Skip to main content

softchat/
envelope.rs

1//! Validated NIP-59 private-envelope construction and reading.
2
3#![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
21/// Maximum number of seconds subtracted independently from private timestamps.
22pub 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/// The relay-storage behavior requested for one NIP-59 wrapper.
33#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
34#[cfg_attr(feature = "native-bindings", derive(uniffi::Enum))]
35pub enum Nip59EnvelopeKind {
36    /// Kind `1059`, retained by relays.
37    Durable,
38    /// Kind `21059`, broadcast without relay persistence.
39    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/// Verified outer-envelope routing metadata that does not expose plaintext.
62#[derive(Clone, Debug, Eq, PartialEq)]
63pub struct Nip59EnvelopeRoute {
64    outer_event_id: NostrEventId,
65    recipient: NostrPublicKey,
66    kind: Nip59EnvelopeKind,
67}
68
69impl Nip59EnvelopeRoute {
70    /// Return the verified outer event ID.
71    #[must_use]
72    pub const fn outer_event_id(&self) -> NostrEventId {
73        self.outer_event_id
74    }
75
76    /// Return the one canonical recipient advertised by the wrapper.
77    #[must_use]
78    pub fn recipient(&self) -> NostrPublicKey {
79        self.recipient.clone()
80    }
81
82    /// Return the relay-storage behavior of the wrapper.
83    #[must_use]
84    pub const fn kind(&self) -> Nip59EnvelopeKind {
85        self.kind
86    }
87}
88
89/// Verify a bounded gift-wrap event and extract its account-routing hint.
90///
91/// This authenticates only the signed outer wrapper and its single canonical
92/// recipient tag. The selected account must still unwrap and authenticate
93/// every inner layer before exposing message content.
94///
95/// # Errors
96///
97/// Returns a stable event or envelope failure for malformed, forged,
98/// unsupported, or ambiguously routed input.
99pub 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/// A rumor exposed only after every NIP-59 layer has authenticated.
111#[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    /// Return the verified one-time wrapper event ID.
121    #[must_use]
122    pub const fn outer_event_id(&self) -> NostrEventId {
123        self.outer_event_id
124    }
125
126    /// Return the verified kind-13 seal event ID.
127    #[must_use]
128    pub const fn seal_event_id(&self) -> NostrEventId {
129        self.seal_event_id
130    }
131
132    /// Return whether the outer event is durable or ephemeral.
133    #[must_use]
134    pub const fn kind(&self) -> Nip59EnvelopeKind {
135        self.kind
136    }
137
138    /// Borrow the authenticated unsigned rumor.
139    #[must_use]
140    pub const fn rumor(&self) -> &NostrRumor {
141        &self.rumor
142    }
143
144    /// Consume the envelope and return its authenticated rumor.
145    #[must_use]
146    pub fn into_rumor(self) -> NostrRumor {
147        self.rumor
148    }
149}
150
151/// Flat authenticated-envelope record for generated native bindings.
152#[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    /// Verified one-time wrapper event ID.
170    pub outer_event_id: String,
171    /// Verified kind-13 seal event ID.
172    pub seal_event_id: String,
173    /// Relay-storage behavior declared by the wrapper kind.
174    pub kind: Nip59EnvelopeKind,
175    /// Authenticated unsigned rumor.
176    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    /// Create one unsigned rumor whose author is this identity.
198    ///
199    /// The draft timestamp remains the canonical application timestamp.
200    ///
201    /// [Guide and performance](https://softchat-sdk.jackl.dev/private-messaging/#create-a-one-to-one-message)
202    ///
203    /// # Errors
204    ///
205    /// Returns a stable draft-validation or event-size failure.
206    pub fn create_rumor(&self, draft: NostrEventDraft) -> Result<NostrRumor, SoftchatError> {
207        NostrRumor::new(self.public_key(), draft)
208    }
209
210    /// Wrap one authenticated rumor for exactly one recipient.
211    ///
212    /// A fresh wrapper key is generated for every call. Seal and wrapper
213    /// timestamps are sampled independently from `[now - 172800, now]`.
214    ///
215    /// [Guide and performance](https://softchat-sdk.jackl.dev/private-messaging/#wrap-recipient-and-sender-copies)
216    ///
217    /// # Errors
218    ///
219    /// Returns [`SoftchatError::Nip59EnvelopeCreationFailed`] if the rumor
220    /// author, timestamp, nested size, encryption, or signing checks fail.
221    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    /// Authenticate and unwrap one already verified NIP-59 outer event.
293    ///
294    /// No rumor is returned unless route, kind, both encrypted layers, seal
295    /// signature, seal shape, rumor ID, and author binding all validate.
296    ///
297    /// [Guide and performance](https://softchat-sdk.jackl.dev/private-messaging/#receive-and-classify)
298    ///
299    /// # Errors
300    ///
301    /// Every invalid layer maps to [`SoftchatError::InvalidNip59Envelope`].
302    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
402/// Preflight the complete double-wrapped wire shape without performing
403/// cryptography or allocating ciphertext.
404///
405/// NIP-44 ciphertext length is deterministic for a plaintext byte length. The
406/// seal and wrapper have fixed-size IDs, keys, and signatures, so compact JSON
407/// probes can establish whether both encryption layers and the final NIP-01
408/// event fit before any platform or database effect begins.
409pub(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    // version (1), nonce (32), legacy length prefix (2), padded plaintext,
487    // and HMAC (32), followed by standard padded base64.
488    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}