Skip to main content

nula_core/nips/
nip37.rs

1//! [NIP-37] Draft Wraps.
2//!
3//! Two kinds:
4//!
5//! - `kind: 31234` — encrypted draft. The unsigned wrapped event is
6//!   JSON-stringified, NIP-44-encrypted to the signer's own pubkey,
7//!   and stored in `.content`. The plaintext draft kind is recorded in
8//!   a `k` tag.
9//! - `kind: 10013` — private storage relay list. Relay URLs are
10//!   carried inside NIP-44-encrypted private tags within `.content`,
11//!   following the same pattern as NIP-51 lists.
12//!
13//! All encryption helpers require the `nip44` feature.
14//!
15//! [NIP-37]: https://github.com/nostr-protocol/nips/blob/master/37.md
16
17use thiserror::Error;
18
19use crate::event::{Event, EventBuilder, Kind, Tag, TagKind};
20use crate::key::{PublicKey, SecretKey};
21use crate::nips::nip44::{self, Nip44Error};
22use crate::types::{RelayUrl, RelayUrlError, Timestamp, TimestampError};
23
24/// `kind: 31234` — draft wrap.
25pub const KIND_DRAFT_WRAP: Kind = Kind::DRAFT_WRAP;
26
27/// `kind: 10013` — private storage relay list.
28pub const KIND_PRIVATE_STORAGE_RELAYS: Kind = Kind::PRIVATE_STORAGE_RELAYS;
29
30const KIND_TAG: &str = "k";
31const EXPIRATION_TAG: &str = "expiration";
32const RELAY_TAG: &str = "relay";
33
34/// Typed bundle for a `kind: 31234` draft wrap.
35#[derive(Debug, Clone, PartialEq, Eq)]
36pub struct DraftWrap {
37    /// `d` identifier.
38    pub identifier: String,
39    /// Wrapped draft kind (per `k` tag).
40    pub draft_kind: Kind,
41    /// Optional NIP-40 `expiration` deadline.
42    pub expiration: Option<Timestamp>,
43    /// NIP-44 ciphertext of the wrapped draft event JSON. An empty
44    /// string signals the draft has been deleted (per spec).
45    pub ciphertext: String,
46    /// Forward-compatible passthrough for unknown tags.
47    pub extra_tags: Vec<Tag>,
48}
49
50/// Typed bundle for a `kind: 10013` private storage relay list.
51#[derive(Debug, Clone, PartialEq, Eq, Default)]
52pub struct PrivateStorageRelays {
53    /// NIP-44 ciphertext of the encrypted relay tag list.
54    pub ciphertext: String,
55    /// Forward-compatible passthrough for unknown public tags.
56    pub extra_tags: Vec<Tag>,
57}
58
59/// Errors raised by NIP-37 helpers.
60#[derive(Debug, Error)]
61#[non_exhaustive]
62pub enum DraftError {
63    /// Event kind is not `31234` / `10013`.
64    #[error("unexpected kind for NIP-37 event: {}", .0.as_u16())]
65    WrongKind(Kind),
66    /// `d` tag missing on a draft wrap.
67    #[error("draft wrap missing `d` identifier")]
68    MissingIdentifier,
69    /// `k` tag missing on a draft wrap.
70    #[error("draft wrap missing `k` tag (wrapped draft kind)")]
71    MissingDraftKind,
72    /// `k` tag value is not a valid kind integer.
73    #[error("draft wrap `k` tag value `{0}` is not a valid kind")]
74    InvalidDraftKind(String),
75    /// Wrapped timestamp parser error.
76    #[error(transparent)]
77    InvalidTimestamp(#[from] TimestampError),
78    /// Wrapped NIP-44 error.
79    #[error(transparent)]
80    Encryption(#[from] Nip44Error),
81    /// JSON serialisation error.
82    #[error(transparent)]
83    Json(#[from] serde_json::Error),
84    /// Wrapped relay-URL parser error (private list).
85    #[error(transparent)]
86    InvalidRelayUrl(#[from] RelayUrlError),
87}
88
89impl DraftWrap {
90    /// Construct a draft wrap with the ciphertext seeded.
91    #[must_use]
92    pub fn new(
93        identifier: impl Into<String>,
94        draft_kind: Kind,
95        ciphertext: impl Into<String>,
96    ) -> Self {
97        Self {
98            identifier: identifier.into(),
99            draft_kind,
100            expiration: None,
101            ciphertext: ciphertext.into(),
102            extra_tags: Vec::new(),
103        }
104    }
105
106    /// Author a fresh draft, encrypting `plaintext_event_json` to the
107    /// signer's own pubkey via NIP-44.
108    ///
109    /// # Errors
110    ///
111    /// Propagates [`Nip44Error`] when encryption fails.
112    pub fn encrypt(
113        identifier: impl Into<String>,
114        draft_kind: Kind,
115        plaintext_event_json: &str,
116        secret: &SecretKey,
117        public_key: &PublicKey,
118    ) -> Result<Self, DraftError> {
119        let ciphertext = nip44::encrypt(secret, public_key, plaintext_event_json)?;
120        Ok(Self::new(identifier, draft_kind, ciphertext))
121    }
122
123    /// Decrypt the wrapped draft JSON with the signer's own keys.
124    ///
125    /// Returns `None` when [`Self::ciphertext`] is empty (the spec's
126    /// "draft deleted" sentinel).
127    ///
128    /// # Errors
129    ///
130    /// Propagates [`Nip44Error`] when decryption fails.
131    pub fn decrypt(
132        &self,
133        secret: &SecretKey,
134        public_key: &PublicKey,
135    ) -> Result<Option<String>, DraftError> {
136        if self.ciphertext.is_empty() {
137            return Ok(None);
138        }
139        Ok(Some(nip44::decrypt(secret, public_key, &self.ciphertext)?))
140    }
141
142    /// True when [`Self::ciphertext`] is empty (draft tombstone).
143    #[must_use]
144    pub const fn is_tombstone(&self) -> bool {
145        self.ciphertext.is_empty()
146    }
147
148    /// Parse a `kind: 31234` draft wrap event.
149    ///
150    /// # Errors
151    ///
152    /// See [`DraftError`] for the failure modes.
153    pub fn from_event(event: &Event) -> Result<Self, DraftError> {
154        if event.kind != KIND_DRAFT_WRAP {
155            return Err(DraftError::WrongKind(event.kind));
156        }
157        let mut identifier: Option<String> = None;
158        let mut draft_kind: Option<Kind> = None;
159        let mut expiration: Option<Timestamp> = None;
160        let mut extra_tags: Vec<Tag> = Vec::new();
161        for tag in &event.tags {
162            absorb_draft_tag(
163                tag,
164                &mut identifier,
165                &mut draft_kind,
166                &mut expiration,
167                &mut extra_tags,
168            )?;
169        }
170        Ok(Self {
171            identifier: identifier.ok_or(DraftError::MissingIdentifier)?,
172            draft_kind: draft_kind.ok_or(DraftError::MissingDraftKind)?,
173            expiration,
174            ciphertext: event.content.clone(),
175            extra_tags,
176        })
177    }
178}
179
180fn absorb_draft_tag(
181    tag: &Tag,
182    identifier: &mut Option<String>,
183    draft_kind: &mut Option<Kind>,
184    expiration: &mut Option<Timestamp>,
185    extra_tags: &mut Vec<Tag>,
186) -> Result<(), DraftError> {
187    match tag.kind() {
188        TagKind::SingleLetter(s)
189            if !s.uppercase && s.character == crate::event::Alphabet::D && identifier.is_none() =>
190        {
191            *identifier = tag.get(1).map(str::to_owned);
192        }
193        _ if tag.name() == KIND_TAG && draft_kind.is_none() => {
194            let raw = tag.get(1).ok_or(DraftError::MissingDraftKind)?;
195            let value = raw
196                .parse::<u16>()
197                .map_err(|_| DraftError::InvalidDraftKind(raw.to_owned()))?;
198            *draft_kind = Some(Kind::new(value));
199        }
200        _ if tag.name() == EXPIRATION_TAG => {
201            if let Some(raw) = tag.get(1) {
202                *expiration = Some(raw.parse::<Timestamp>()?);
203            }
204        }
205        _ => extra_tags.push(tag.clone()),
206    }
207    Ok(())
208}
209
210impl PrivateStorageRelays {
211    /// Construct a relay list with the ciphertext seeded.
212    #[must_use]
213    pub fn new(ciphertext: impl Into<String>) -> Self {
214        Self {
215            ciphertext: ciphertext.into(),
216            extra_tags: Vec::new(),
217        }
218    }
219
220    /// Encrypt the supplied relay list to the signer's own keys.
221    ///
222    /// # Errors
223    ///
224    /// Propagates [`Nip44Error`] / [`serde_json::Error`].
225    pub fn encrypt(
226        relays: &[RelayUrl],
227        secret: &SecretKey,
228        public_key: &PublicKey,
229    ) -> Result<Self, DraftError> {
230        let payload: Vec<Vec<String>> = relays
231            .iter()
232            .map(|relay| vec![RELAY_TAG.to_owned(), relay.as_str().to_owned()])
233            .collect();
234        let plaintext = serde_json::to_string(&payload)?;
235        let ciphertext = nip44::encrypt(secret, public_key, &plaintext)?;
236        Ok(Self::new(ciphertext))
237    }
238
239    /// Decrypt the wrapped relay list with the signer's own keys.
240    ///
241    /// # Errors
242    ///
243    /// Propagates [`Nip44Error`] / [`serde_json::Error`] /
244    /// [`RelayUrlError`].
245    pub fn decrypt(
246        &self,
247        secret: &SecretKey,
248        public_key: &PublicKey,
249    ) -> Result<Vec<RelayUrl>, DraftError> {
250        if self.ciphertext.is_empty() {
251            return Ok(Vec::new());
252        }
253        let plaintext = nip44::decrypt(secret, public_key, &self.ciphertext)?;
254        let rows: Vec<Vec<String>> = serde_json::from_str(&plaintext)?;
255        let mut relays: Vec<RelayUrl> = Vec::new();
256        for row in rows {
257            let mut iter = row.into_iter();
258            let head = iter.next();
259            let value = iter.next();
260            if head.as_deref() != Some(RELAY_TAG) {
261                continue;
262            }
263            if let Some(raw) = value {
264                relays.push(RelayUrl::parse(&raw)?);
265            }
266        }
267        Ok(relays)
268    }
269
270    /// Parse a `kind: 10013` private-storage relay list event.
271    ///
272    /// # Errors
273    ///
274    /// See [`DraftError`] for the failure modes.
275    pub fn from_event(event: &Event) -> Result<Self, DraftError> {
276        if event.kind != KIND_PRIVATE_STORAGE_RELAYS {
277            return Err(DraftError::WrongKind(event.kind));
278        }
279        Ok(Self {
280            ciphertext: event.content.clone(),
281            extra_tags: event.tags.iter().cloned().collect(),
282        })
283    }
284}
285
286impl EventBuilder {
287    /// Author a NIP-37 `kind: 31234` draft wrap.
288    #[must_use]
289    pub fn draft_wrap(draft: &DraftWrap) -> Self {
290        let mut builder = Self::new(KIND_DRAFT_WRAP, draft.ciphertext.clone());
291        builder = builder.tag(Tag::d(&draft.identifier)).tag(Tag::with(
292            &TagKind::from_wire(KIND_TAG),
293            [draft.draft_kind.as_u16().to_string()],
294        ));
295        if let Some(ts) = draft.expiration {
296            builder = builder.tag(Tag::with(
297                &TagKind::from_wire(EXPIRATION_TAG),
298                [ts.as_secs().to_string()],
299            ));
300        }
301        for tag in &draft.extra_tags {
302            builder = builder.tag(tag.clone());
303        }
304        builder
305    }
306
307    /// Author a NIP-37 `kind: 10013` private storage relay list.
308    #[must_use]
309    pub fn private_storage_relays(list: &PrivateStorageRelays) -> Self {
310        let mut builder = Self::new(KIND_PRIVATE_STORAGE_RELAYS, list.ciphertext.clone());
311        for tag in &list.extra_tags {
312            builder = builder.tag(tag.clone());
313        }
314        builder
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::Keys;
322
323    fn keys() -> Keys {
324        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
325    }
326
327    #[test]
328    fn draft_round_trip() {
329        let plaintext = r#"{"kind":1,"content":"hi"}"#;
330        let draft = DraftWrap::encrypt(
331            "draft-1",
332            Kind::TEXT_NOTE,
333            plaintext,
334            keys().secret_key(),
335            keys().public_key(),
336        )
337        .unwrap();
338        let event = EventBuilder::draft_wrap(&draft)
339            .sign_with_keys(&keys())
340            .unwrap();
341        let parsed = DraftWrap::from_event(&event).unwrap();
342        assert_eq!(parsed.identifier, draft.identifier);
343        assert_eq!(parsed.draft_kind, Kind::TEXT_NOTE);
344        let decrypted = parsed
345            .decrypt(keys().secret_key(), keys().public_key())
346            .unwrap();
347        assert_eq!(decrypted.as_deref(), Some(plaintext));
348    }
349
350    #[test]
351    fn tombstone_decrypts_as_none() {
352        let draft = DraftWrap::new("d", Kind::TEXT_NOTE, "");
353        assert!(draft.is_tombstone());
354        let decrypted = draft
355            .decrypt(keys().secret_key(), keys().public_key())
356            .unwrap();
357        assert!(decrypted.is_none());
358    }
359
360    #[test]
361    fn private_storage_relays_round_trip() {
362        let relays = vec![
363            RelayUrl::parse("wss://private.example/").unwrap(),
364            RelayUrl::parse("wss://other.example/").unwrap(),
365        ];
366        let list = PrivateStorageRelays::encrypt(&relays, keys().secret_key(), keys().public_key())
367            .unwrap();
368        let event = EventBuilder::private_storage_relays(&list)
369            .sign_with_keys(&keys())
370            .unwrap();
371        let parsed = PrivateStorageRelays::from_event(&event).unwrap();
372        let decrypted = parsed
373            .decrypt(keys().secret_key(), keys().public_key())
374            .unwrap();
375        assert_eq!(decrypted, relays);
376    }
377
378    #[test]
379    fn missing_kind_is_rejected() {
380        let event = EventBuilder::new(KIND_DRAFT_WRAP, "ct")
381            .tag(Tag::d("foo"))
382            .sign_with_keys(&keys())
383            .unwrap();
384        assert!(matches!(
385            DraftWrap::from_event(&event),
386            Err(DraftError::MissingDraftKind)
387        ));
388    }
389
390    #[test]
391    fn wrong_kind_is_rejected() {
392        // A text note is the canonical \"not a draft wrap\" sample.
393        let event = EventBuilder::text_note("not a draft")
394            .sign_with_keys(&keys())
395            .unwrap();
396        assert!(matches!(
397            DraftWrap::from_event(&event),
398            Err(DraftError::WrongKind(_))
399        ));
400        // PrivateStorageRelays parser also rejects mismatched kinds.
401        assert!(matches!(
402            PrivateStorageRelays::from_event(&event),
403            Err(DraftError::WrongKind(_))
404        ));
405    }
406
407    #[test]
408    fn invalid_k_tag_is_rejected() {
409        // `k` tag value must be a valid `u16` kind \u2014 anything else is a
410        // wire-level error per the typed `Kind` invariants.
411        let event = EventBuilder::new(KIND_DRAFT_WRAP, "")
412            .tag(Tag::d("foo"))
413            .tag(Tag::with(
414                &TagKind::from_wire(KIND_TAG),
415                ["not-a-number".to_owned()],
416            ))
417            .sign_with_keys(&keys())
418            .unwrap();
419        assert!(matches!(
420            DraftWrap::from_event(&event),
421            Err(DraftError::InvalidDraftKind(raw)) if raw == "not-a-number"
422        ));
423    }
424}