Skip to main content

nula_core/nips/
nip21.rs

1//! [NIP-21] `nostr:` URI scheme.
2//!
3//! NIP-21 packages every NIP-19 bech32 entity — **except the secret
4//! key** — into a URI with the `nostr:` scheme. The secret-key variant
5//! (`nsec`) is deliberately refused: embedding a signing key inside a
6//! URL is an operational hazard that leaks through browser history,
7//! clipboard, referrer headers, search-engine indexers, server access
8//! logs, and QR-code readers. The encoder here therefore makes it
9//! impossible at compile time to construct a `nostr:nsec…` URI from
10//! a [`crate::SecretKey`]: only types that implement the sealed
11//! [`ToNostrUri`] trait are accepted, and [`crate::SecretKey`] is
12//! intentionally excluded from that trait's implementor list.
13//!
14//! The reverse direction ([`Nip21::parse`]) rejects any `nostr:nsec…`
15//! URI with [`Nip21Error::SecretKeyRefused`].
16//!
17//! # Spec ↔ source map
18//!
19//! | NIP-21 string                 | Rust type                     |
20//! |-------------------------------|-------------------------------|
21//! | `nostr:npub…`                 | [`crate::PublicKey`]          |
22//! | `nostr:note…`                 | [`crate::EventId`]            |
23//! | `nostr:nprofile…`             | [`Nip19Profile`]              |
24//! | `nostr:nevent…`               | [`Nip19Event`]                |
25//! | `nostr:naddr…`                | [`Nip19Coordinate`]           |
26//! | any of the above (discriminated) | [`Nip21`]                  |
27//!
28//! # Usage
29//!
30//! ```
31//! use nula_core::PublicKey;
32//! use nula_core::nips::nip21::{FromNostrUri, Nip21, ToNostrUri};
33//!
34//! let pk = PublicKey::parse(
35//!     "aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4",
36//! )
37//! .unwrap();
38//!
39//! let uri = pk.to_nostr_uri().unwrap();
40//! assert!(uri.starts_with("nostr:npub"));
41//!
42//! let round_trip = PublicKey::from_nostr_uri(&uri).unwrap();
43//! assert_eq!(round_trip, pk);
44//!
45//! // The discriminated form is handy when end-user input could be any
46//! // NIP-21 shape.
47//! let discriminated = Nip21::parse(&uri).unwrap();
48//! assert!(matches!(discriminated, Nip21::Pubkey(_)));
49//! ```
50//!
51//! [NIP-21]: https://github.com/nostr-protocol/nips/blob/master/21.md
52
53use thiserror::Error;
54
55use super::nip19::{
56    FromBech32, FromBech32Error, Nip19Coordinate, Nip19Entity, Nip19Event, Nip19Profile, ToBech32,
57    ToBech32Error,
58};
59use crate::event::EventId;
60use crate::key::PublicKey;
61
62/// The URI scheme defined by NIP-21.
63pub const SCHEME: &str = "nostr";
64
65/// The URI scheme including its delimiter, ready to prepend to a bech32
66/// body.
67pub const SCHEME_PREFIX: &str = "nostr:";
68
69/// Errors raised by the NIP-21 encoder / decoder.
70#[derive(Debug, Error)]
71#[non_exhaustive]
72pub enum Nip21Error {
73    /// The input did not start with `nostr:` or had an empty body.
74    #[error("invalid `nostr:` URI: expected `nostr:<bech32>` with a non-empty body")]
75    InvalidUri,
76    /// The URI contained a bech32 secret key (`nsec…` or `ncryptsec…`),
77    /// which NIP-21 forbids on safety grounds.
78    #[error(
79        "NIP-21 does not permit secret keys in URIs; pass a public key, profile, or event instead"
80    )]
81    SecretKeyRefused,
82    /// The bech32 body of the URI failed to decode.
83    #[error(transparent)]
84    Decode(#[from] FromBech32Error),
85    /// The NIP-19 encoder refused to re-encode the payload as bech32.
86    #[error(transparent)]
87    Encode(#[from] ToBech32Error),
88}
89
90/// Discriminated union over every NIP-21-expressible entity.
91///
92/// Use [`Nip21::parse`] when you need to accept any `nostr:` URI from
93/// end-user input. The enum mirrors [`Nip19Entity`] with the
94/// secret-key variant removed; the bidirectional conversions between
95/// the two are provided so callers can fluidly move between bech32 and
96/// URI representations without re-parsing.
97#[derive(Debug, Clone, PartialEq, Eq, Hash)]
98#[non_exhaustive]
99pub enum Nip21 {
100    /// `nostr:npub…`.
101    Pubkey(PublicKey),
102    /// `nostr:note…`.
103    EventId(EventId),
104    /// `nostr:nprofile…`.
105    Profile(Nip19Profile),
106    /// `nostr:nevent…`.
107    Event(Nip19Event),
108    /// `nostr:naddr…`.
109    Coordinate(Nip19Coordinate),
110}
111
112impl Nip21 {
113    /// Parse a `nostr:<bech32>` URI.
114    ///
115    /// # Errors
116    ///
117    /// - [`Nip21Error::InvalidUri`] if the input does not start with
118    ///   `nostr:` or the body is empty.
119    /// - [`Nip21Error::SecretKeyRefused`] if the body decodes as `nsec`
120    ///   (or `ncryptsec` under the `nip49` feature).
121    /// - [`Nip21Error::Decode`] for any underlying NIP-19 failure.
122    pub fn parse(uri: &str) -> Result<Self, Nip21Error> {
123        let body = strip_scheme(uri).ok_or(Nip21Error::InvalidUri)?;
124        let entity = Nip19Entity::from_bech32(body)?;
125        Self::try_from(entity)
126    }
127
128    /// Render this entity as its canonical NIP-19 bech32 body (without
129    /// the `nostr:` scheme).
130    ///
131    /// This is an inherent helper rather than a [`ToBech32`] impl
132    /// because [`ToBech32`] is sealed for round-trip safety with the
133    /// 6-variant [`Nip19Entity`]; adding [`Nip21`] to the sealed set
134    /// would break that guarantee.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`Nip21Error::Encode`] on any underlying bech32 failure.
139    pub fn to_bech32_body(&self) -> Result<String, Nip21Error> {
140        let body = match self {
141            Self::Pubkey(pk) => pk.to_bech32()?,
142            Self::EventId(id) => id.to_bech32()?,
143            Self::Profile(p) => p.to_bech32()?,
144            Self::Event(e) => e.to_bech32()?,
145            Self::Coordinate(c) => c.to_bech32()?,
146        };
147        Ok(body)
148    }
149
150    /// Render this entity as its canonical `nostr:` URI.
151    ///
152    /// # Errors
153    ///
154    /// Returns [`Nip21Error::Encode`] if the underlying bech32 encoder
155    /// rejects the payload (e.g. a profile with too many relay hints).
156    pub fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
157        let body = self.to_bech32_body()?;
158        Ok(format!("{SCHEME_PREFIX}{body}"))
159    }
160
161    /// Return the event id carried by this URI, if any.
162    ///
163    /// `note` and `nevent` carry event ids directly; every other
164    /// variant returns `None`.
165    #[must_use]
166    pub const fn event_id(&self) -> Option<EventId> {
167        match self {
168            Self::EventId(id) => Some(*id),
169            Self::Event(e) => Some(e.event_id),
170            Self::Pubkey(_) | Self::Profile(_) | Self::Coordinate(_) => None,
171        }
172    }
173
174    /// Return the public key carried by this URI, if any.
175    ///
176    /// `npub`, `nprofile`, and `naddr` all reference an author; the
177    /// other variants identify a particular event on the wire and do
178    /// not embed a public key.
179    #[must_use]
180    pub const fn pubkey(&self) -> Option<PublicKey> {
181        match self {
182            Self::Pubkey(pk) => Some(*pk),
183            Self::Profile(p) => Some(p.public_key),
184            Self::Coordinate(c) => Some(*c.author()),
185            Self::EventId(_) | Self::Event(_) => None,
186        }
187    }
188}
189
190impl From<Nip21> for Nip19Entity {
191    fn from(value: Nip21) -> Self {
192        match value {
193            Nip21::Pubkey(pk) => Self::PublicKey(pk),
194            Nip21::EventId(id) => Self::EventId(id),
195            Nip21::Profile(p) => Self::Profile(p),
196            Nip21::Event(e) => Self::Event(e),
197            Nip21::Coordinate(c) => Self::Coordinate(c),
198        }
199    }
200}
201
202impl TryFrom<Nip19Entity> for Nip21 {
203    type Error = Nip21Error;
204
205    fn try_from(value: Nip19Entity) -> Result<Self, Self::Error> {
206        match value {
207            Nip19Entity::SecretKey(_) => Err(Nip21Error::SecretKeyRefused),
208            Nip19Entity::PublicKey(pk) => Ok(Self::Pubkey(pk)),
209            Nip19Entity::EventId(id) => Ok(Self::EventId(id)),
210            Nip19Entity::Profile(p) => Ok(Self::Profile(p)),
211            Nip19Entity::Event(e) => Ok(Self::Event(e)),
212            Nip19Entity::Coordinate(c) => Ok(Self::Coordinate(c)),
213        }
214    }
215}
216
217/// Render a value as its `nostr:` URI.
218///
219/// This trait is **sealed**: only types defined in this crate whose
220/// bech32 encoding is a valid NIP-21 body can implement it. The
221/// sealing exists for the same reason NIP-19's traits are sealed
222/// (round-trip guarantees) **and** to prevent downstream code from
223/// accidentally implementing it for [`crate::SecretKey`], which would
224/// silently defeat the safety rationale of NIP-21.
225pub trait ToNostrUri: sealed::ToSealed {
226    /// Produce the `nostr:<bech32>` URI for this value.
227    ///
228    /// # Errors
229    ///
230    /// Returns [`Nip21Error::Encode`] if bech32 encoding fails.
231    fn to_nostr_uri(&self) -> Result<String, Nip21Error>;
232}
233
234/// Parse a value from its `nostr:` URI.
235///
236/// Sealed for the same reasons as [`ToNostrUri`].
237pub trait FromNostrUri: sealed::FromSealed + Sized {
238    /// Parse a `nostr:<bech32>` URI whose body matches `Self`.
239    ///
240    /// # Errors
241    ///
242    /// - [`Nip21Error::InvalidUri`] for a missing `nostr:` prefix.
243    /// - [`Nip21Error::Decode`] for an underlying NIP-19 failure
244    ///   (wrong HRP, bad checksum, malformed TLV, …).
245    fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error>;
246}
247
248mod sealed {
249    use super::{EventId, Nip19Coordinate, Nip19Event, Nip19Profile, Nip21, PublicKey};
250
251    /// Sealed marker for [`super::ToNostrUri`]. Notice that
252    /// [`crate::SecretKey`] is **not** a member — that is the point.
253    pub trait ToSealed {}
254    /// Sealed marker for [`super::FromNostrUri`].
255    pub trait FromSealed {}
256
257    impl ToSealed for PublicKey {}
258    impl ToSealed for EventId {}
259    impl ToSealed for Nip19Profile {}
260    impl ToSealed for Nip19Event {}
261    impl ToSealed for Nip19Coordinate {}
262    impl ToSealed for Nip21 {}
263    impl FromSealed for PublicKey {}
264    impl FromSealed for EventId {}
265    impl FromSealed for Nip19Profile {}
266    impl FromSealed for Nip19Event {}
267    impl FromSealed for Nip19Coordinate {}
268    impl FromSealed for Nip21 {}
269}
270
271fn strip_scheme(uri: &str) -> Option<&str> {
272    let body = uri.strip_prefix(SCHEME_PREFIX)?;
273    if body.is_empty() { None } else { Some(body) }
274}
275
276macro_rules! impl_to_nostr_uri_via_bech32 {
277    ($($ty:ty),+ $(,)?) => {
278        $(
279            impl ToNostrUri for $ty {
280                fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
281                    let body = ToBech32::to_bech32(self)?;
282                    Ok(format!("{SCHEME_PREFIX}{body}"))
283                }
284            }
285        )+
286    };
287}
288
289macro_rules! impl_from_nostr_uri_via_bech32 {
290    ($($ty:ty),+ $(,)?) => {
291        $(
292            impl FromNostrUri for $ty {
293                fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error> {
294                    let body = strip_scheme(uri).ok_or(Nip21Error::InvalidUri)?;
295                    Self::from_bech32(body).map_err(Nip21Error::Decode)
296                }
297            }
298        )+
299    };
300}
301
302impl_to_nostr_uri_via_bech32!(
303    PublicKey,
304    EventId,
305    Nip19Profile,
306    Nip19Event,
307    Nip19Coordinate
308);
309impl_from_nostr_uri_via_bech32!(
310    PublicKey,
311    EventId,
312    Nip19Profile,
313    Nip19Event,
314    Nip19Coordinate
315);
316
317impl ToNostrUri for Nip21 {
318    fn to_nostr_uri(&self) -> Result<String, Nip21Error> {
319        Self::to_nostr_uri(self)
320    }
321}
322
323impl FromNostrUri for Nip21 {
324    fn from_nostr_uri(uri: &str) -> Result<Self, Nip21Error> {
325        Self::parse(uri)
326    }
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use crate::event::{EventId, Kind};
333    use crate::types::RelayUrl;
334
335    const FIXTURE_PUBKEY_HEX: &str =
336        "aa4fc8665f5696e33db7e1a572e3b0f5b3d615837b0f362dcb1c8068b098c7b4";
337    const FIXTURE_NPUB_URI: &str =
338        "nostr:npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy";
339
340    fn fixture_pubkey() -> PublicKey {
341        PublicKey::parse(FIXTURE_PUBKEY_HEX).expect("fixture hex parses")
342    }
343
344    #[test]
345    fn pubkey_round_trip_matches_upstream_fixture() {
346        let pk = fixture_pubkey();
347        assert_eq!(pk.to_nostr_uri().unwrap(), FIXTURE_NPUB_URI);
348        assert_eq!(PublicKey::from_nostr_uri(FIXTURE_NPUB_URI).unwrap(), pk);
349
350        // Discriminated form agrees.
351        let parsed = Nip21::parse(FIXTURE_NPUB_URI).unwrap();
352        assert_eq!(parsed, Nip21::Pubkey(pk));
353        assert_eq!(parsed.pubkey(), Some(pk));
354        assert_eq!(parsed.event_id(), None);
355        assert_eq!(parsed.to_nostr_uri().unwrap(), FIXTURE_NPUB_URI);
356    }
357
358    #[test]
359    fn profile_round_trip() {
360        let pk = fixture_pubkey();
361        let profile = Nip19Profile::new(
362            pk,
363            [RelayUrl::parse("wss://relay.damus.io/").expect("fixture relay parses")],
364        );
365
366        let uri = profile.to_nostr_uri().unwrap();
367        assert!(uri.starts_with("nostr:nprofile"));
368        let round_trip = Nip19Profile::from_nostr_uri(&uri).unwrap();
369        assert_eq!(round_trip, profile);
370        assert_eq!(Nip21::parse(&uri).unwrap(), Nip21::Profile(profile));
371    }
372
373    #[test]
374    fn event_round_trip_preserves_discriminator_accessors() {
375        let id = EventId::parse("b2f61aa5ce66cef9f9e3dcbfa9a17b16b6b9d43f7e0a8e2b7c5f1e6f80a7f123")
376            .expect("fixture event id parses");
377        let nevent = Nip19Event::new(id)
378            .with_author(fixture_pubkey())
379            .with_kind(Kind::TEXT_NOTE)
380            .with_relays([RelayUrl::parse("wss://relay.damus.io/").unwrap()]);
381
382        let uri = nevent.to_nostr_uri().unwrap();
383        assert!(uri.starts_with("nostr:nevent"));
384
385        let parsed = Nip21::parse(&uri).unwrap();
386        assert_eq!(parsed.event_id(), Some(id));
387        assert_eq!(parsed.pubkey(), None);
388        assert!(matches!(parsed, Nip21::Event(_)));
389    }
390
391    #[test]
392    fn secret_key_is_refused_at_parse() {
393        // Fixture from upstream rust-nostr (nip21.rs tests).
394        let nsec_uri = "nostr:nsec1j4c6269y9w0q2er2xjw8sv2ehyrtfxq3jwgdlxj6qfn8z4gjsq5qfvfk99";
395        let err = Nip21::parse(nsec_uri).expect_err("nsec URIs are forbidden");
396        assert!(matches!(err, Nip21Error::SecretKeyRefused));
397    }
398
399    #[test]
400    fn missing_scheme_is_rejected() {
401        for bad in [
402            "npub14f8usejl26twx0dhuxjh9cas7keav9vr0v8nvtwtrjqx3vycc76qqh9nsy",
403            "nostr:",
404        ] {
405            let err = Nip21::parse(bad).expect_err("Nip21::parse accepts only `nostr:<bech32>`");
406            assert!(
407                matches!(err, Nip21Error::InvalidUri),
408                "unexpected error for {bad:?}: {err:?}"
409            );
410        }
411
412        let trait_err =
413            PublicKey::from_nostr_uri("bolt11:lnbc1…").expect_err("foreign scheme is not NIP-21");
414        assert!(matches!(trait_err, Nip21Error::InvalidUri));
415    }
416
417    #[test]
418    fn nip19_entity_bidirectional_conversion() {
419        let pk = fixture_pubkey();
420        let as_entity: Nip19Entity = Nip21::Pubkey(pk).into();
421        assert_eq!(as_entity, Nip19Entity::PublicKey(pk));
422
423        let back = Nip21::try_from(as_entity).unwrap();
424        assert_eq!(back, Nip21::Pubkey(pk));
425
426        // Secret keys cannot be laundered through the conversion.
427        let sk = crate::SecretKey::parse(
428            "0000000000000000000000000000000000000000000000000000000000000003",
429        )
430        .unwrap();
431        let err = Nip21::try_from(Nip19Entity::SecretKey(sk))
432            .expect_err("secret keys must not become NIP-21 values");
433        assert!(matches!(err, Nip21Error::SecretKeyRefused));
434    }
435}