Skip to main content

softchat/
nip17.rs

1//! Minimum typed NIP-17 one-to-one text-message profile.
2
3#![allow(
4    unreachable_pub,
5    reason = "UniFFI adapter validation stays inside this private core module"
6)]
7
8use crate::event::RumorEvent;
9use crate::{
10    LocalIdentity, NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrRumor,
11    NostrTag, SoftchatError,
12};
13
14/// A validated one-to-one kind-14 text-message rumor.
15///
16/// This first profile intentionally does not assign typed semantics to reply,
17/// subject, forwarding, media, or group tags. Exact unknown tags remain on the
18/// underlying rumor and therefore remain part of its event ID.
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Nip17TextMessage {
21    rumor: NostrRumor,
22    recipient: NostrPublicKey,
23}
24
25impl Nip17TextMessage {
26    /// Validate a kind-14 rumor as one non-empty text message to one peer.
27    ///
28    /// One canonical `p` tag is required. Its optional relay-hint and extension
29    /// fields, plus all non-`p` tags, remain exact on the underlying rumor.
30    ///
31    /// # Errors
32    ///
33    /// Returns [`SoftchatError::InvalidNip17Message`] for an unsupported kind,
34    /// empty text, malformed or duplicate recipient, or self-recipient.
35    pub fn from_rumor(rumor: NostrRumor) -> Result<Self, SoftchatError> {
36        validate_rumor(&rumor)
37            .map(|recipient| Self { rumor, recipient })
38            .map_err(|_| SoftchatError::InvalidNip17Message)
39    }
40
41    /// Return the canonical rumor ID used for conversation references.
42    #[must_use]
43    pub const fn id(&self) -> NostrEventId {
44        self.rumor.id()
45    }
46
47    /// Return the rumor's declared author.
48    ///
49    /// The author is authenticated only when the rumor came from a validated
50    /// NIP-59 envelope.
51    #[must_use]
52    pub fn sender(&self) -> NostrPublicKey {
53        self.rumor.public_key()
54    }
55
56    /// Return the one peer named by the canonical recipient tag.
57    #[must_use]
58    pub fn recipient(&self) -> &NostrPublicKey {
59        &self.recipient
60    }
61
62    /// Return the canonical application timestamp.
63    #[must_use]
64    pub const fn created_at(&self) -> u64 {
65        self.rumor.created_at()
66    }
67
68    /// Borrow the non-empty plain-text content.
69    #[must_use]
70    pub fn content(&self) -> &str {
71        self.rumor.content()
72    }
73
74    /// Borrow the complete exact rumor, including unknown tags.
75    #[must_use]
76    pub const fn rumor(&self) -> &NostrRumor {
77        &self.rumor
78    }
79
80    /// Consume the typed view without changing the underlying rumor.
81    #[must_use]
82    pub fn into_rumor(self) -> NostrRumor {
83        self.rumor
84    }
85}
86
87impl LocalIdentity {
88    /// Create one minimal one-to-one NIP-17 text-message rumor.
89    ///
90    /// The writer emits exactly one canonical `p` tag and no extension tags.
91    /// Callers explicitly create separate NIP-59 copies for the recipient and
92    /// sender when recoverable local history is required.
93    ///
94    /// [Guide and performance](https://softchat-sdk.jackl.dev/private-messaging/#create-a-one-to-one-message)
95    ///
96    /// # Errors
97    ///
98    /// Returns [`SoftchatError::Nip17MessageCreationFailed`] for self-addressed
99    /// messages, empty text, invalid timestamps, or oversized rumors.
100    pub fn create_nip17_text_message(
101        &self,
102        recipient: &NostrPublicKey,
103        created_at: u64,
104        content: impl Into<String>,
105    ) -> Result<Nip17TextMessage, SoftchatError> {
106        let content = content.into();
107        if recipient == &self.public_key() || content.is_empty() {
108            return Err(SoftchatError::Nip17MessageCreationFailed);
109        }
110
111        let recipient_tag = NostrTag::new(vec!["p".to_owned(), recipient.to_hex()])
112            .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
113        let draft = NostrEventDraft::new(
114            created_at,
115            NostrEventKind::PRIVATE_DIRECT_MESSAGE,
116            vec![recipient_tag],
117            content,
118        )
119        .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
120        let rumor = self
121            .create_rumor(draft)
122            .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
123        Nip17TextMessage::from_rumor(rumor).map_err(|_| SoftchatError::Nip17MessageCreationFailed)
124    }
125}
126
127/// Flat one-to-one text-message view for generated native bindings.
128#[derive(Clone, Debug, Eq, PartialEq)]
129#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
130pub struct Nip17Message {
131    /// Complete exact unsigned rumor.
132    pub rumor: RumorEvent,
133    /// Canonical recipient public key derived from its sole `p` tag.
134    pub recipient_public_key: String,
135}
136
137impl From<&Nip17TextMessage> for Nip17Message {
138    fn from(message: &Nip17TextMessage) -> Self {
139        Self {
140            rumor: RumorEvent::from(message.rumor()),
141            recipient_public_key: message.recipient().to_hex(),
142        }
143    }
144}
145
146impl TryFrom<Nip17Message> for Nip17TextMessage {
147    type Error = SoftchatError;
148
149    fn try_from(message: Nip17Message) -> Result<Self, Self::Error> {
150        let declared_recipient = NostrPublicKey::from_hex(&message.recipient_public_key)
151            .map_err(|_| SoftchatError::InvalidNip17Message)?;
152        let message = Self::from_rumor(
153            NostrRumor::try_from(message.rumor).map_err(|_| SoftchatError::InvalidNip17Message)?,
154        )?;
155        if message.recipient != declared_recipient {
156            return Err(SoftchatError::InvalidNip17Message);
157        }
158        Ok(message)
159    }
160}
161
162/// Validate a binding-owned rumor as a one-to-one NIP-17 text message.
163#[cfg(any(
164    feature = "native-bindings",
165    all(feature = "javascript-bindings", target_arch = "wasm32"),
166    test
167))]
168#[cfg_attr(feature = "native-bindings", uniffi::export)]
169pub fn validate_nip17_text_message(rumor: RumorEvent) -> Result<Nip17Message, SoftchatError> {
170    Nip17TextMessage::from_rumor(
171        NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidNip17Message)?,
172    )
173    .map(|message| Nip17Message::from(&message))
174}
175
176fn validate_rumor(rumor: &NostrRumor) -> Result<NostrPublicKey, SoftchatError> {
177    if rumor.kind() != NostrEventKind::PRIVATE_DIRECT_MESSAGE || rumor.content().is_empty() {
178        return Err(SoftchatError::InvalidNip17Message);
179    }
180
181    let mut recipient = None;
182    for tag in rumor.tags() {
183        if tag.first().map(String::as_str) != Some("p") {
184            continue;
185        }
186        if recipient.is_some() {
187            return Err(SoftchatError::InvalidNip17Message);
188        }
189        let raw_public_key = tag.get(1).ok_or(SoftchatError::InvalidNip17Message)?;
190        let public_key = NostrPublicKey::from_hex(raw_public_key)
191            .map_err(|_| SoftchatError::InvalidNip17Message)?;
192        if public_key.to_hex() != *raw_public_key || public_key == rumor.public_key() {
193            return Err(SoftchatError::InvalidNip17Message);
194        }
195        recipient = Some(public_key);
196    }
197    recipient.ok_or(SoftchatError::InvalidNip17Message)
198}
199
200#[cfg(test)]
201mod tests {
202    use nostr::SecretKey;
203    use serde::Deserialize;
204
205    use super::*;
206    use crate::{Nip59EnvelopeKind, SignedNostrEvent, UnwrappedNip59Envelope};
207
208    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
209    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
210    const OFFICIAL_EXAMPLE: &str =
211        include_str!("../../../fixtures/nostr/nip17/nip17-official-example.json");
212
213    #[derive(Deserialize)]
214    #[serde(rename_all = "camelCase")]
215    struct OfficialExample {
216        sender_nsec: String,
217        recipient_nsec: String,
218        recipient_gift_wrap: serde_json::Value,
219        sender_gift_wrap: serde_json::Value,
220    }
221
222    fn classify(envelope: UnwrappedNip59Envelope) -> Result<Nip17TextMessage, SoftchatError> {
223        Nip17TextMessage::from_rumor(envelope.into_rumor())
224    }
225
226    #[test]
227    fn creates_wraps_receives_and_classifies_one_to_one_text() -> Result<(), SoftchatError> {
228        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
229        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
230        let message =
231            alice.create_nip17_text_message(&bob.public_key(), 1_700_000_000, "hello Bob")?;
232
233        let recipient_copy = alice.gift_wrap(
234            &bob.public_key(),
235            message.rumor(),
236            Nip59EnvelopeKind::Durable,
237            1_700_172_800,
238        )?;
239        let sender_copy = alice.gift_wrap(
240            &alice.public_key(),
241            message.rumor(),
242            Nip59EnvelopeKind::Durable,
243            1_700_172_800,
244        )?;
245
246        let received = classify(bob.unwrap_gift_wrap(&recipient_copy)?)?;
247        let retained = classify(alice.unwrap_gift_wrap(&sender_copy)?)?;
248        assert_eq!(received, message);
249        assert_eq!(retained, message);
250        assert_eq!(received.sender(), alice.public_key());
251        assert_eq!(received.recipient(), &bob.public_key());
252        assert_eq!(received.content(), "hello Bob");
253        Ok(())
254    }
255
256    #[test]
257    fn receives_both_official_nip17_copies() -> Result<(), Box<dyn std::error::Error>> {
258        let fixture: OfficialExample = serde_json::from_str(OFFICIAL_EXAMPLE)?;
259        let sender_secret = SecretKey::parse(&fixture.sender_nsec)?;
260        let recipient_secret = SecretKey::parse(&fixture.recipient_nsec)?;
261        let sender = LocalIdentity::from_secret_bytes(&sender_secret.to_secret_bytes())?;
262        let recipient = LocalIdentity::from_secret_bytes(&recipient_secret.to_secret_bytes())?;
263        let recipient_outer =
264            SignedNostrEvent::from_json(&fixture.recipient_gift_wrap.to_string())?;
265        let sender_outer = SignedNostrEvent::from_json(&fixture.sender_gift_wrap.to_string())?;
266
267        let received = classify(recipient.unwrap_gift_wrap(&recipient_outer)?)?;
268        let retained = classify(sender.unwrap_gift_wrap(&sender_outer)?)?;
269
270        assert_eq!(received, retained);
271        assert_eq!(received.sender(), sender.public_key());
272        assert_eq!(received.recipient(), &recipient.public_key());
273        assert_eq!(received.content(), "Hola, que tal?");
274        Ok(())
275    }
276
277    #[test]
278    fn preserves_relay_hints_and_unknown_tags_in_typed_view() -> Result<(), SoftchatError> {
279        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
280        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
281        let rumor = alice.create_rumor(NostrEventDraft::new(
282            1_700_000_000,
283            NostrEventKind::PRIVATE_DIRECT_MESSAGE,
284            vec![
285                NostrTag::new(vec![
286                    "p".to_owned(),
287                    bob.public_key().to_hex(),
288                    "wss://relay.example".to_owned(),
289                ])?,
290                NostrTag::new(vec!["future".to_owned(), "exact".to_owned()])?,
291            ],
292            "hello",
293        )?)?;
294        let message = Nip17TextMessage::from_rumor(rumor.clone())?;
295
296        assert_eq!(message.rumor(), &rumor);
297        assert_eq!(
298            message
299                .rumor()
300                .tags()
301                .map(<[String]>::to_vec)
302                .collect::<Vec<_>>(),
303            vec![
304                vec![
305                    "p".to_owned(),
306                    bob.public_key().to_hex(),
307                    "wss://relay.example".to_owned(),
308                ],
309                vec!["future".to_owned(), "exact".to_owned()],
310            ]
311        );
312        Ok(())
313    }
314
315    #[test]
316    fn rejects_empty_self_group_duplicate_and_wrong_kind_messages() -> Result<(), SoftchatError> {
317        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
318        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
319        assert!(matches!(
320            alice.create_nip17_text_message(&alice.public_key(), 1, "self"),
321            Err(SoftchatError::Nip17MessageCreationFailed)
322        ));
323        assert!(matches!(
324            alice.create_nip17_text_message(&bob.public_key(), 1, ""),
325            Err(SoftchatError::Nip17MessageCreationFailed)
326        ));
327
328        for (kind, tags, content) in [
329            (
330                NostrEventKind::SHORT_TEXT_NOTE,
331                vec![NostrTag::new(vec![
332                    "p".to_owned(),
333                    bob.public_key().to_hex(),
334                ])?],
335                "wrong kind",
336            ),
337            (
338                NostrEventKind::PRIVATE_DIRECT_MESSAGE,
339                Vec::new(),
340                "missing",
341            ),
342            (
343                NostrEventKind::PRIVATE_DIRECT_MESSAGE,
344                vec![
345                    NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?,
346                    NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?,
347                ],
348                "duplicate",
349            ),
350            (
351                NostrEventKind::PRIVATE_DIRECT_MESSAGE,
352                vec![NostrTag::new(vec![
353                    "p".to_owned(),
354                    bob.public_key().to_hex(),
355                ])?],
356                "",
357            ),
358        ] {
359            let rumor = alice.create_rumor(NostrEventDraft::new(1, kind, tags, content)?)?;
360            assert!(matches!(
361                Nip17TextMessage::from_rumor(rumor),
362                Err(SoftchatError::InvalidNip17Message)
363            ));
364        }
365        Ok(())
366    }
367}