Skip to main content

nula_core/nips/
nip17.rs

1//! [NIP-17] Private Direct Messages.
2//!
3//! NIP-17 layers a *chat-message rumor* (kind 14) on top of the NIP-59
4//! gift-wrap envelope. Each message is wrapped once per recipient *and*
5//! once for the sender so both sides keep a copy. Per spec the inner
6//! rumor is **never signed**: omitting the signature gives the sender
7//! plausible deniability if the rumor leaks.
8//!
9//! # Pipeline
10//!
11//! ```text
12//! sender ─────────────────┐
13//!                         ▼
14//!     build kind-14 rumor (unsigned, p-tags carry recipients)
15//!                         │
16//!         ┌───────────────┼───────────────────────┐
17//!         ▼               ▼                       ▼
18//!    seal+wrap to    seal+wrap to           seal+wrap to
19//!    recipient #1    recipient #2     …    sender's own pk
20//! ```
21//!
22//! The sender keeps a self-wrap so they can reconstruct their outgoing
23//! history without storing plaintext locally; clients SHOULD publish
24//! that copy to the sender's own [`Kind::DM_RELAYS`] preferred relays.
25//!
26//! # DM relays
27//!
28//! Kind `10050` advertises the relays a user wants gift-wrapped DMs
29//! delivered to. Use [`build_dm_relays_event`] to produce the
30//! replaceable list and [`parse_dm_relays_event`] to consume one.
31//!
32//! [NIP-17]: https://github.com/nostr-protocol/nips/blob/master/17.md
33
34use thiserror::Error;
35
36use super::nip59;
37use crate::event::{
38    Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind, Tags, UnsignedEvent,
39};
40use crate::key::{Keys, PublicKey};
41use crate::types::{RelayUrl, Timestamp};
42
43/// Wire name of the conversation-title tag (NIP-17 §Chat Message).
44const SUBJECT_TAG: &str = "subject";
45/// Wire name of the reply-marker tag value (NIP-17 §Chat Message).
46const REPLY_MARKER: &str = "reply";
47
48/// Errors raised by the NIP-17 helpers.
49#[derive(Debug, Error)]
50#[non_exhaustive]
51pub enum Nip17Error {
52    /// Caller supplied an empty recipient list.
53    ///
54    /// NIP-17 only makes sense with at least one peer; even a "self
55    /// note" pattern goes through this entry point with the sender's
56    /// own pubkey in the recipient list.
57    #[error("recipients list must not be empty")]
58    NoRecipients,
59    /// The DM-relays event was not kind 10050.
60    #[error("expected kind 10050, got {0}")]
61    UnexpectedKind(u16),
62    /// A `relay` tag had no URL value.
63    #[error("`relay` tag is missing the URL value")]
64    MissingRelayUrl,
65    /// A `relay` tag's URL did not parse.
66    #[error(transparent)]
67    InvalidRelayUrl(#[from] crate::types::RelayUrlError),
68    /// Forwarded gift-wrap error.
69    #[error(transparent)]
70    Wrap(#[from] nip59::Nip59Error),
71}
72
73/// Recipient of a private message: a public key plus an optional relay
74/// hint that gets baked into the inner rumor's `p` tag and reused on
75/// the outer gift wrap's `p` tag.
76///
77/// Owned (`Option<RelayUrl>` rather than `Option<&RelayUrl>`) so
78/// callers can keep recipient lists in `Vec` / `HashMap` /
79/// configuration files without juggling lifetimes. [`RelayUrl`]
80/// internally wraps a single [`url::Url`], so cloning costs ~one
81/// heap allocation; perfectly cheap on the message-send path.
82#[derive(Debug, Clone, PartialEq, Eq, Hash)]
83pub struct Recipient {
84    /// Recipient's BIP-340 x-only public key.
85    pub public_key: PublicKey,
86    /// Optional relay hint surfaced as the third element of the `p`
87    /// tag (both on the inner rumor and on the outer gift wrap).
88    pub relay_hint: Option<RelayUrl>,
89}
90
91impl Recipient {
92    /// Build a recipient with no relay hint.
93    #[must_use]
94    pub const fn new(public_key: PublicKey) -> Self {
95        Self {
96            public_key,
97            relay_hint: None,
98        }
99    }
100
101    /// Attach a relay hint.
102    #[must_use]
103    pub fn with_relay_hint(mut self, relay: RelayUrl) -> Self {
104        self.relay_hint = Some(relay);
105        self
106    }
107}
108
109impl From<PublicKey> for Recipient {
110    fn from(public_key: PublicKey) -> Self {
111        Self::new(public_key)
112    }
113}
114
115/// Optional reply pointer: a NIP-10 `e` tag value built into the rumor.
116///
117/// Owned for the same reasons as [`Recipient`].
118#[derive(Debug, Clone, PartialEq, Eq, Hash)]
119pub struct ReplyTo {
120    /// Event id this message is a reply to.
121    pub event_id: crate::event::EventId,
122    /// Optional relay hint surfaced as the third element of the `e` tag.
123    pub relay_hint: Option<RelayUrl>,
124}
125
126/// Build the kind-14 chat-message *rumor* (per [NIP-17 §Chat Message]).
127///
128/// `recipients` populates one `["p", <pubkey>, <relay_hint>?]` tag per
129/// entry, in the order supplied. Optional `subject` adds a single
130/// `["subject", <title>]` tag. Optional `reply_to` adds a single
131/// `["e", <id>, <relay_hint>, "reply"]` tag.
132///
133/// The returned [`UnsignedEvent`] is **never signed** — that is the
134/// whole point of the deniable design. Pass it directly to
135/// [`wrap_for`] / [`wrap_for_many`].
136///
137/// [NIP-17 §Chat Message]: https://github.com/nostr-protocol/nips/blob/master/17.md#chat-message
138#[must_use]
139pub fn build_chat_message_rumor(
140    sender: &Keys,
141    recipients: &[Recipient],
142    message: impl Into<String>,
143    created_at: Timestamp,
144    subject: Option<&str>,
145    reply_to: Option<&ReplyTo>,
146) -> UnsignedEvent {
147    let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
148    let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
149    let subject_kind = TagKind::from_wire(SUBJECT_TAG);
150
151    let mut tags: Vec<Tag> = Vec::with_capacity(
152        recipients.len() + usize::from(subject.is_some()) + usize::from(reply_to.is_some()),
153    );
154
155    for recipient in recipients {
156        let values = recipient.relay_hint.as_ref().map_or_else(
157            || vec![recipient.public_key.to_hex()],
158            |url| vec![recipient.public_key.to_hex(), url.as_str().to_owned()],
159        );
160        tags.push(Tag::with(&p_kind, values));
161    }
162
163    if let Some(reply) = reply_to {
164        // Spec: `["e", <id>, <relay-url>, "reply"]`. Use the empty
165        // string when no relay hint is available so the marker stays at
166        // index 3.
167        let relay = reply
168            .relay_hint
169            .as_ref()
170            .map_or_else(String::new, |url| url.as_str().to_owned());
171        tags.push(Tag::with(
172            &e_kind,
173            [reply.event_id.to_hex(), relay, REPLY_MARKER.to_owned()],
174        ));
175    }
176
177    if let Some(title) = subject {
178        tags.push(Tag::with(&subject_kind, [title.to_owned()]));
179    }
180
181    UnsignedEvent::new(
182        *sender.public_key(),
183        created_at,
184        Kind::PRIVATE_DIRECT_MESSAGE,
185        Tags::from_vec(tags),
186        message,
187    )
188}
189
190/// Build one gift-wrapped event per recipient *and* one for the sender.
191///
192/// Returns `Vec<Event>` in the order `[wrap_for_self, wrap_for_recipient_0, …]`.
193/// Each wrap is fully signed by an ephemeral key; relays only see the
194/// outer envelope and the recipient's `p` tag.
195///
196/// `relay_hints` parameter on each [`Recipient`] is reused on the gift
197/// wrap's `p` tag (so relays can route it). If you do not have a hint,
198/// pass `None` on the Recipient.
199///
200/// # Errors
201///
202/// Returns [`Nip17Error::NoRecipients`] if `recipients` is empty, or
203/// [`Nip17Error::Wrap`] for any underlying NIP-59 / NIP-44 failure.
204pub fn wrap_for_many(
205    sender: &Keys,
206    recipients: &[Recipient],
207    rumor: &UnsignedEvent,
208    timestamps: nip59::Timestamps,
209) -> Result<Vec<Event>, Nip17Error> {
210    if recipients.is_empty() {
211        return Err(Nip17Error::NoRecipients);
212    }
213
214    let mut wraps = Vec::with_capacity(recipients.len() + 1);
215
216    // Self-wrap: lets the sender reconstruct outgoing history without
217    // keeping a separate plaintext archive.
218    let self_seal = nip59::create_seal(sender, sender.public_key(), rumor, timestamps.seal)?;
219    wraps.push(nip59::create_gift_wrap(
220        &self_seal,
221        sender.public_key(),
222        None,
223        timestamps.wrap,
224    )?);
225
226    // One wrap per peer.
227    for recipient in recipients {
228        let seal = nip59::create_seal(sender, &recipient.public_key, rumor, timestamps.seal)?;
229        wraps.push(nip59::create_gift_wrap(
230            &seal,
231            &recipient.public_key,
232            recipient.relay_hint.as_ref(),
233            timestamps.wrap,
234        )?);
235    }
236
237    Ok(wraps)
238}
239
240/// Build a single gift-wrapped event for one recipient.
241///
242/// Convenience wrapper around [`wrap_for_many`] for callers that want
243/// the simple two-party case without the self-wrap. **No** copy is
244/// produced for the sender — call [`wrap_for_many`] when you want one.
245///
246/// # Errors
247///
248/// See [`wrap_for_many`].
249pub fn wrap_for(
250    sender: &Keys,
251    recipient: &Recipient,
252    rumor: &UnsignedEvent,
253    timestamps: nip59::Timestamps,
254) -> Result<Event, Nip17Error> {
255    let seal = nip59::create_seal(sender, &recipient.public_key, rumor, timestamps.seal)?;
256    Ok(nip59::create_gift_wrap(
257        &seal,
258        &recipient.public_key,
259        recipient.relay_hint.as_ref(),
260        timestamps.wrap,
261    )?)
262}
263
264/// Peel a gift-wrapped event and recover the inner kind-14 rumor.
265///
266/// Convenience alias for [`nip59::unwrap`] that asserts the rumor's
267/// kind is `14` after unwrapping (kind `15` file messages and kind `7`
268/// reactions are valid NIP-17 payloads too, see [`unwrap_dm_payload`]).
269///
270/// # Errors
271///
272/// Returns [`Nip17Error::UnexpectedKind`] when the rumor is not kind 14;
273/// otherwise see [`nip59::unwrap`].
274pub fn unwrap_chat_message(
275    recipient: &Keys,
276    gift_wrap: &Event,
277) -> Result<UnsignedEvent, Nip17Error> {
278    let rumor = nip59::unwrap(recipient, gift_wrap).map_err(Nip17Error::Wrap)?;
279    if rumor.kind != Kind::PRIVATE_DIRECT_MESSAGE {
280        return Err(Nip17Error::UnexpectedKind(rumor.kind.as_u16()));
281    }
282    Ok(rumor)
283}
284
285/// Peel a gift-wrapped event and accept any NIP-17 payload kind.
286///
287/// NIP-17 §Chat Rooms allows kind 14 (chat), kind 15 (file message),
288/// and kind 7 (reaction) inside the wrap. Use this entry point when
289/// the caller wants to handle the full set without manual kind
290/// dispatch.
291///
292/// # Errors
293///
294/// See [`nip59::unwrap`]. Unlike [`unwrap_chat_message`] this function
295/// does not assert the rumor's kind; callers should match on
296/// `rumor.kind` themselves.
297pub fn unwrap_dm_payload(recipient: &Keys, gift_wrap: &Event) -> Result<UnsignedEvent, Nip17Error> {
298    nip59::unwrap(recipient, gift_wrap).map_err(Nip17Error::Wrap)
299}
300
301/// Build a [`Kind::DM_RELAYS`] (10050) replaceable event listing the
302/// relays the author wants NIP-17 gift wraps delivered to.
303///
304/// The event's content is empty per spec; relays are encoded as one
305/// `["relay", <url>]` tag each. The caller signs the resulting builder
306/// with their own [`Keys`].
307///
308/// `relays` is taken in the order supplied; clients SHOULD list the
309/// most-preferred relay first.
310#[must_use]
311pub fn build_dm_relays_event(relays: &[RelayUrl]) -> EventBuilder {
312    let kind = TagKind::from_wire("relay");
313    let tags: Vec<Tag> = relays
314        .iter()
315        .map(|url| Tag::with(&kind, [url.as_str().to_owned()]))
316        .collect();
317    EventBuilder::new(Kind::DM_RELAYS, "").tags(tags)
318}
319
320/// Parse a kind-10050 event into the list of relays the author
321/// advertises for NIP-17 delivery.
322///
323/// `relay` tags whose URL fails to parse are surfaced as
324/// [`Nip17Error::InvalidRelayUrl`] rather than silently dropped — relay
325/// lists are a privacy-sensitive signal and silent corruption could
326/// route DMs to the wrong server.
327///
328/// # Errors
329///
330/// Returns [`Nip17Error::UnexpectedKind`] if the event is not kind 10050
331/// and [`Nip17Error::InvalidRelayUrl`] / [`Nip17Error::MissingRelayUrl`] for
332/// malformed `relay` tags.
333pub fn parse_dm_relays_event(event: &Event) -> Result<Vec<RelayUrl>, Nip17Error> {
334    if event.kind != Kind::DM_RELAYS {
335        return Err(Nip17Error::UnexpectedKind(event.kind.as_u16()));
336    }
337    let relay_kind = TagKind::from_wire("relay");
338    let mut out = Vec::new();
339    for tag in &event.tags {
340        if tag.kind() != relay_kind {
341            // Forward-compat: ignore non-`relay` tags.
342            continue;
343        }
344        let value = tag.values().get(1).ok_or(Nip17Error::MissingRelayUrl)?;
345        out.push(RelayUrl::parse(value)?);
346    }
347    Ok(out)
348}
349
350#[cfg(test)]
351mod tests {
352    use super::*;
353    use crate::Keys;
354    use crate::event::EventId;
355
356    fn keys_alice() -> Keys {
357        Keys::parse("000000000000000000000000000000000000000000000000000000000000a1ce").unwrap()
358    }
359
360    fn keys_bob() -> Keys {
361        Keys::parse("00000000000000000000000000000000000000000000000000000000000000b0").unwrap()
362    }
363
364    fn keys_carol() -> Keys {
365        Keys::parse("00000000000000000000000000000000000000000000000000000000000ca800").unwrap()
366    }
367
368    #[test]
369    fn rumor_carries_p_tag_per_recipient() {
370        let alice = keys_alice();
371        let bob = keys_bob();
372        let carol = keys_carol();
373        let now = Timestamp::from_secs(1_700_000_000);
374
375        let rumor = build_chat_message_rumor(
376            &alice,
377            &[
378                Recipient::new(*bob.public_key()),
379                Recipient::new(*carol.public_key()),
380            ],
381            "hello",
382            now,
383            None,
384            None,
385        );
386
387        let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
388        let p_tags: Vec<&Tag> = rumor.tags.iter().filter(|t| t.kind() == p_kind).collect();
389        assert_eq!(p_tags.len(), 2);
390        assert_eq!(
391            p_tags[0].values().get(1).unwrap(),
392            &bob.public_key().to_hex()
393        );
394        assert_eq!(
395            p_tags[1].values().get(1).unwrap(),
396            &carol.public_key().to_hex()
397        );
398        assert_eq!(rumor.kind, Kind::PRIVATE_DIRECT_MESSAGE);
399        assert_eq!(rumor.content, "hello");
400    }
401
402    #[test]
403    fn rumor_carries_subject_and_reply_tags() {
404        let alice = keys_alice();
405        let bob = keys_bob();
406        let now = Timestamp::from_secs(1_700_000_000);
407        let parent = EventId::from_byte_array([0xab; 32]);
408        let relay = RelayUrl::parse("wss://relay.example/").unwrap();
409
410        let rumor = build_chat_message_rumor(
411            &alice,
412            &[Recipient::new(*bob.public_key()).with_relay_hint(relay.clone())],
413            "thread reply",
414            now,
415            Some("daily standup"),
416            Some(&ReplyTo {
417                event_id: parent,
418                relay_hint: Some(relay.clone()),
419            }),
420        );
421
422        let subject_tag = rumor
423            .tags
424            .find_first(&TagKind::from_wire(SUBJECT_TAG))
425            .unwrap();
426        assert_eq!(subject_tag.values().get(1).unwrap(), "daily standup");
427
428        let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
429        let e_tag = rumor.tags.find_first(&e_kind).unwrap();
430        let e_values = e_tag.values();
431        assert_eq!(e_values.get(1).unwrap(), &parent.to_hex());
432        assert_eq!(e_values.get(2).unwrap(), relay.as_str());
433        assert_eq!(e_values.get(3).unwrap(), REPLY_MARKER);
434    }
435
436    #[test]
437    fn wrap_for_many_produces_self_plus_recipient_copies() {
438        let alice = keys_alice();
439        let bob = keys_bob();
440        let carol = keys_carol();
441        let now = Timestamp::from_secs(1_700_000_000);
442        let rumor = build_chat_message_rumor(
443            &alice,
444            &[
445                Recipient::new(*bob.public_key()),
446                Recipient::new(*carol.public_key()),
447            ],
448            "hi all",
449            now,
450            None,
451            None,
452        );
453
454        let wraps = wrap_for_many(
455            &alice,
456            &[
457                Recipient::new(*bob.public_key()),
458                Recipient::new(*carol.public_key()),
459            ],
460            &rumor,
461            nip59::Timestamps::all_at(now),
462        )
463        .unwrap();
464
465        // 1 self + 2 recipients
466        assert_eq!(wraps.len(), 3);
467
468        // Alice can decrypt the self-wrap.
469        let recovered_self = unwrap_chat_message(&alice, &wraps[0]).unwrap();
470        assert_eq!(recovered_self.content, "hi all");
471
472        // Bob can decrypt his copy.
473        let recovered_bob = unwrap_chat_message(&bob, &wraps[1]).unwrap();
474        assert_eq!(recovered_bob.content, "hi all");
475
476        // Carol can decrypt hers.
477        let recovered_carol = unwrap_chat_message(&carol, &wraps[2]).unwrap();
478        assert_eq!(recovered_carol.content, "hi all");
479    }
480
481    #[test]
482    fn wrap_for_many_rejects_empty_recipients() {
483        let alice = keys_alice();
484        let now = Timestamp::from_secs(1_700_000_000);
485        let rumor = build_chat_message_rumor(&alice, &[], "ghost", now, None, None);
486        let err = wrap_for_many(&alice, &[], &rumor, nip59::Timestamps::all_at(now)).unwrap_err();
487        assert!(matches!(err, Nip17Error::NoRecipients));
488    }
489
490    #[test]
491    fn unwrap_chat_message_rejects_wrong_inner_kind() {
492        let alice = keys_alice();
493        let bob = keys_bob();
494        let now = Timestamp::from_secs(1_700_000_000);
495
496        // Sneak a kind-1 rumor through the gift wrap.
497        let rumor = UnsignedEvent::new(
498            *alice.public_key(),
499            now,
500            Kind::TEXT_NOTE,
501            Tags::new(),
502            "not a DM",
503        );
504        let seal = nip59::create_seal(&alice, bob.public_key(), &rumor, now).unwrap();
505        let wrap = nip59::create_gift_wrap(&seal, bob.public_key(), None, now).unwrap();
506
507        let err = unwrap_chat_message(&bob, &wrap).unwrap_err();
508        assert!(matches!(err, Nip17Error::UnexpectedKind(1)));
509
510        // The same payload survives the more permissive entry point.
511        let recovered = unwrap_dm_payload(&bob, &wrap).unwrap();
512        assert_eq!(recovered.kind, Kind::TEXT_NOTE);
513    }
514
515    #[test]
516    fn dm_relays_round_trip() {
517        let alice = keys_alice();
518        let relays = vec![
519            RelayUrl::parse("wss://inbox.nostr.example/").unwrap(),
520            RelayUrl::parse("wss://dm.nostr.example/").unwrap(),
521        ];
522        let event = build_dm_relays_event(&relays)
523            .created_at(Timestamp::from_secs(1))
524            .sign_with_keys(&alice)
525            .unwrap();
526        assert_eq!(event.kind, Kind::DM_RELAYS);
527        let parsed = parse_dm_relays_event(&event).unwrap();
528        assert_eq!(parsed, relays);
529    }
530
531    #[test]
532    fn dm_relays_rejects_wrong_kind() {
533        let alice = keys_alice();
534        let event = EventBuilder::text_note("not a dm relays event")
535            .created_at(Timestamp::from_secs(1))
536            .sign_with_keys(&alice)
537            .unwrap();
538        let err = parse_dm_relays_event(&event).unwrap_err();
539        assert!(matches!(err, Nip17Error::UnexpectedKind(1)));
540    }
541
542    #[test]
543    fn dm_relays_rejects_missing_url() {
544        let alice = keys_alice();
545        let event = EventBuilder::new(Kind::DM_RELAYS, "")
546            .tag(Tag::new(["relay"]).unwrap())
547            .created_at(Timestamp::from_secs(1))
548            .sign_with_keys(&alice)
549            .unwrap();
550        let err = parse_dm_relays_event(&event).unwrap_err();
551        assert!(matches!(err, Nip17Error::MissingRelayUrl));
552    }
553}