Skip to main content

nula_core/nips/
nip05.rs

1//! [NIP-05] DNS-based internet identifiers for Nostr keys.
2//!
3//! NIP-05 maps an email-like identifier `<local>@<domain>` to a Nostr
4//! public key by resolving
5//! `https://<domain>/.well-known/nostr.json?name=<local>` and looking
6//! the pubkey up under the document's `names` mapping. The optional
7//! `relays` field then yields per-pubkey relay hints.
8//!
9//! # Architecture
10//!
11//! Network IO is intentionally split from the spec logic:
12//!
13//! 1. [`Nip05Address::parse`] enforces the local-part charset
14//!    (`a-z0-9-_.`), lowercases the domain, and recognises the
15//!    `_@<domain>` "root" form that clients render as just `<domain>`.
16//! 2. [`Nip05Document::parse`] deserialises the well-known JSON.
17//! 3. [`verify_document`] composes (1) and (2) into a single
18//!    side-effect-free verifier that operates on a JSON string the
19//!    caller already obtained somehow.
20//! 4. [`Nip05Fetcher`] is the **only** trait that touches network IO.
21//!    It returns a boxed future so the trait stays
22//!    [dyn-compatible](https://doc.rust-lang.org/reference/items/traits.html#dyn-compatible-traits)
23//!    and so future NAPI / FFI bindings can pin the boxed future
24//!    across the FFI boundary without conditional compilation.
25//! 5. [`lookup_pubkey`] / [`lookup_with_relays`] / [`verify_identifier`]
26//!    are the user-facing async helpers that wire (4) into (1)–(3).
27//!
28//! The default reqwest-backed fetcher ([`ReqwestNip05Fetcher`]) is
29//! gated behind the `nip05` Cargo feature. Implementers who want to
30//! plug in a different HTTP client (`hyper`, `surf`, an in-process
31//! cache, …) only need to implement [`Nip05Fetcher`].
32//!
33//! # Security
34//!
35//! NIP-05 §"Security Constraints" states the well-known endpoint
36//! MUST NOT return HTTP redirects and fetchers MUST ignore any.
37//! [`ReqwestNip05Fetcher`] hard-disables redirects via
38//! [`reqwest::redirect::Policy::none`], so a server that points
39//! to a third-party host cannot launder a different pubkey under the
40//! original identifier.
41//!
42//! [NIP-05]: https://github.com/nostr-protocol/nips/blob/master/05.md
43
44use std::collections::HashMap;
45use std::future::Future;
46use std::pin::Pin;
47
48use serde::{Deserialize, Serialize};
49use thiserror::Error;
50
51use crate::key::PublicKey;
52use crate::types::RelayUrl;
53
54/// Conventional `local-part` for the "root" identifier (`_@<domain>`),
55/// rendered as just `<domain>` by clients per NIP-05 §"Showing just
56/// the domain as an identifier".
57pub const ROOT_LOCAL_PART: &str = "_";
58
59/// Path component appended to the domain to produce the well-known URL.
60pub const WELL_KNOWN_PATH: &str = "/.well-known/nostr.json";
61
62/// Errors common to NIP-05 parsing and verification.
63#[derive(Debug, Error)]
64#[non_exhaustive]
65pub enum Nip05Error {
66    /// The address did not contain exactly one `@` separator.
67    #[error("NIP-05 address must contain exactly one `@`")]
68    MalformedAddress,
69    /// The `local-part` contained a character outside `a-z0-9-_.`.
70    #[error("NIP-05 local-part must only use `a-z0-9-_.`; got `{0}`")]
71    InvalidLocalPart(String),
72    /// The domain part was empty.
73    #[error("NIP-05 domain must not be empty")]
74    EmptyDomain,
75    /// The well-known JSON document failed to parse.
76    #[error("NIP-05 well-known JSON failed to parse: {0}")]
77    DocumentParse(#[from] serde_json::Error),
78    /// The document did not contain a mapping for the local-part.
79    #[error("NIP-05 well-known document does not list `{0}` under `names`")]
80    NameNotListed(String),
81}
82
83/// Errors that can surface from a [`Nip05Fetcher`] implementation.
84#[derive(Debug, Error)]
85#[non_exhaustive]
86pub enum Nip05FetchError {
87    /// The HTTP request failed (network, TLS, DNS, …).
88    #[error("NIP-05 well-known fetch failed: {0}")]
89    Transport(String),
90    /// The server returned a non-2xx status code.
91    #[error("NIP-05 well-known fetch returned status {0}")]
92    Status(u16),
93    /// The server attempted an HTTP redirect, which NIP-05 forbids.
94    #[error("NIP-05 well-known fetch was redirected, which the spec forbids")]
95    Redirected,
96}
97
98/// Errors raised by the high-level helpers ([`lookup_pubkey`] etc.).
99#[derive(Debug, Error)]
100#[non_exhaustive]
101pub enum Nip05LookupError {
102    /// The address itself was invalid.
103    #[error(transparent)]
104    Address(Nip05Error),
105    /// The fetcher could not retrieve the well-known document.
106    #[error(transparent)]
107    Fetch(#[from] Nip05FetchError),
108    /// The fetched document failed to parse or did not list the name.
109    #[error(transparent)]
110    Document(Nip05Error),
111}
112
113/// A NIP-05 internet identifier.
114///
115/// Both halves are stored in their canonical wire-form
116/// (lowercase). Use [`Self::parse`] to construct, [`Self::display`]
117/// to render the user-facing form (which suppresses the leading
118/// `_@` for the root identifier).
119#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120pub struct Nip05Address {
121    /// The `local-part` after lowercasing.
122    pub local: String,
123    /// The `<domain>` after lowercasing.
124    pub domain: String,
125}
126
127impl Nip05Address {
128    /// Parse `<local>@<domain>` per NIP-05.
129    ///
130    /// # Errors
131    ///
132    /// - [`Nip05Error::MalformedAddress`] if the input lacks exactly
133    ///   one `@`.
134    /// - [`Nip05Error::InvalidLocalPart`] if the local part contains
135    ///   any character outside `a-z0-9-_.` (after case-folding).
136    /// - [`Nip05Error::EmptyDomain`] if the domain is empty.
137    pub fn parse(input: &str) -> Result<Self, Nip05Error> {
138        let (local, domain) = input.split_once('@').ok_or(Nip05Error::MalformedAddress)?;
139        if local.contains('@') || domain.contains('@') {
140            return Err(Nip05Error::MalformedAddress);
141        }
142        if domain.is_empty() {
143            return Err(Nip05Error::EmptyDomain);
144        }
145        let local_lower = local.to_ascii_lowercase();
146        if !is_valid_local_part(&local_lower) {
147            return Err(Nip05Error::InvalidLocalPart(local.to_owned()));
148        }
149        Ok(Self {
150            local: local_lower,
151            domain: domain.to_ascii_lowercase(),
152        })
153    }
154
155    /// Return the `https://<domain>/.well-known/nostr.json?name=<local>`
156    /// URL the client must `GET`.
157    #[must_use]
158    pub fn well_known_url(&self) -> String {
159        format!(
160            "https://{domain}{path}?name={local}",
161            domain = self.domain,
162            path = WELL_KNOWN_PATH,
163            local = self.local,
164        )
165    }
166
167    /// `true` when `local == "_"`. Such addresses are rendered as
168    /// just the domain in user-facing UIs.
169    #[must_use]
170    pub fn is_root(&self) -> bool {
171        self.local == ROOT_LOCAL_PART
172    }
173
174    /// Render the address for display: `_@d.com` becomes `d.com`,
175    /// every other form is `<local>@<domain>`.
176    #[must_use]
177    pub fn display(&self) -> String {
178        if self.is_root() {
179            self.domain.clone()
180        } else {
181            format!("{}@{}", self.local, self.domain)
182        }
183    }
184}
185
186fn is_valid_local_part(s: &str) -> bool {
187    !s.is_empty()
188        && s.bytes()
189            .all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
190}
191
192/// The JSON document served at the well-known endpoint.
193///
194/// Both maps are deserialised verbatim; further validation against
195/// the queried local-part lives in [`verify_document`] /
196/// [`Self::pubkey_for`].
197#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
198pub struct Nip05Document {
199    /// `local-part -> pubkey` mapping. The pubkey is BIP-340 hex.
200    #[serde(default)]
201    pub names: HashMap<String, PublicKey>,
202    /// `pubkey -> [relay urls]` mapping for relay hints.
203    #[serde(default)]
204    pub relays: HashMap<PublicKey, Vec<RelayUrl>>,
205}
206
207impl Nip05Document {
208    /// Parse a JSON document.
209    ///
210    /// # Errors
211    ///
212    /// Returns [`Nip05Error::DocumentParse`] when the bytes are not
213    /// valid JSON or the schema does not match.
214    pub fn parse(json: &str) -> Result<Self, Nip05Error> {
215        Ok(serde_json::from_str(json)?)
216    }
217
218    /// Look up the pubkey for `local`. NIP-05 does not specify
219    /// case-sensitivity on the lookup but in practice servers serve
220    /// the same lowercase form the client sent in `?name=`; we
221    /// therefore look up by the exact local-part the caller already
222    /// canonicalised through [`Nip05Address::parse`].
223    #[must_use]
224    pub fn pubkey_for(&self, local: &str) -> Option<&PublicKey> {
225        self.names.get(local)
226    }
227
228    /// Borrow the relay-hint list for `pubkey`. Returns `&[]` when
229    /// no hints are present.
230    #[must_use]
231    pub fn relays_for(&self, pubkey: &PublicKey) -> &[RelayUrl] {
232        self.relays
233            .get(pubkey)
234            .map(Vec::as_slice)
235            .unwrap_or_default()
236    }
237}
238
239/// Verify a NIP-05 document against an `(address, expected_pubkey)`
240/// pair without doing any IO.
241///
242/// This is the side-effect-free entry point most callers should
243/// reach for first: it lets unit tests pin behaviour against
244/// fixture JSON, and lets advanced integrations swap in their own
245/// fetch layer.
246///
247/// # Errors
248///
249/// - [`Nip05Error::DocumentParse`] for malformed JSON.
250/// - [`Nip05Error::NameNotListed`] when the document does not map
251///   the queried local-part.
252pub fn verify_document(
253    address: &Nip05Address,
254    document_json: &str,
255    expected_pubkey: &PublicKey,
256) -> Result<bool, Nip05Error> {
257    let doc = Nip05Document::parse(document_json)?;
258    let listed = doc
259        .pubkey_for(&address.local)
260        .ok_or_else(|| Nip05Error::NameNotListed(address.local.clone()))?;
261    Ok(listed == expected_pubkey)
262}
263
264/// Boxed `Future` returned by [`Nip05Fetcher::fetch`].
265pub type FetchFuture<'a, T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
266
267/// IO trait for retrieving a NIP-05 well-known document.
268///
269/// Implementations MUST:
270///
271/// - issue an HTTPS `GET` for the supplied `url`,
272/// - **refuse to follow** any HTTP 3xx redirects (NIP-05 §"Security
273///   Constraints"),
274/// - return [`Nip05FetchError::Status`] for non-2xx responses,
275/// - return the response body verbatim as a UTF-8 [`String`].
276///
277/// The trait is [`Send`] + [`Sync`] so callers can share a single
278/// fetcher across futures and threads.
279pub trait Nip05Fetcher: Send + Sync {
280    /// Fetch the JSON document at `url`.
281    fn fetch<'a>(&'a self, url: &'a str) -> FetchFuture<'a, String, Nip05FetchError>;
282}
283
284/// Look up the public key associated with `address`.
285///
286/// # Errors
287///
288/// Wraps the underlying error in the appropriate [`Nip05LookupError`]
289/// variant.
290pub async fn lookup_pubkey<F>(
291    fetcher: &F,
292    address: &Nip05Address,
293) -> Result<PublicKey, Nip05LookupError>
294where
295    F: Nip05Fetcher + ?Sized,
296{
297    let body = fetcher.fetch(&address.well_known_url()).await?;
298    let doc = Nip05Document::parse(&body).map_err(Nip05LookupError::Document)?;
299    let pk = doc.pubkey_for(&address.local).copied().ok_or_else(|| {
300        Nip05LookupError::Document(Nip05Error::NameNotListed(address.local.clone()))
301    })?;
302    Ok(pk)
303}
304
305/// Look up `(pubkey, relay_hints)` for `address` in one fetch.
306///
307/// # Errors
308///
309/// See [`lookup_pubkey`].
310pub async fn lookup_with_relays<F>(
311    fetcher: &F,
312    address: &Nip05Address,
313) -> Result<(PublicKey, Vec<RelayUrl>), Nip05LookupError>
314where
315    F: Nip05Fetcher + ?Sized,
316{
317    let body = fetcher.fetch(&address.well_known_url()).await?;
318    let doc = Nip05Document::parse(&body).map_err(Nip05LookupError::Document)?;
319    let pk = doc.pubkey_for(&address.local).copied().ok_or_else(|| {
320        Nip05LookupError::Document(Nip05Error::NameNotListed(address.local.clone()))
321    })?;
322    let relays = doc.relays_for(&pk).to_vec();
323    Ok((pk, relays))
324}
325
326/// Verify that `address` resolves to `expected_pubkey`.
327///
328/// Returns `Ok(true)` when the document lists `expected_pubkey`,
329/// `Ok(false)` when the document lists a *different* pubkey for the
330/// same name, and an error when the lookup itself failed.
331///
332/// # Errors
333///
334/// See [`lookup_pubkey`].
335pub async fn verify_identifier<F>(
336    fetcher: &F,
337    address: &Nip05Address,
338    expected_pubkey: &PublicKey,
339) -> Result<bool, Nip05LookupError>
340where
341    F: Nip05Fetcher + ?Sized,
342{
343    let body = fetcher.fetch(&address.well_known_url()).await?;
344    verify_document(address, &body, expected_pubkey).map_err(Nip05LookupError::Document)
345}
346
347#[cfg(feature = "nip05")]
348#[cfg_attr(docsrs, doc(cfg(feature = "nip05")))]
349pub use reqwest_impl::ReqwestNip05Fetcher;
350
351#[cfg(feature = "nip05")]
352mod reqwest_impl {
353    use super::{FetchFuture, Nip05FetchError, Nip05Fetcher};
354
355    /// `reqwest`-backed [`Nip05Fetcher`] with redirects disabled per
356    /// NIP-05 §"Security Constraints".
357    ///
358    /// The internal client uses `reqwest::redirect::Policy::none()`
359    /// so a server that emits an HTTP 3xx is treated as a fetch
360    /// failure ([`Nip05FetchError::Redirected`]) rather than
361    /// transparently re-routing under a different identity.
362    #[derive(Debug, Clone)]
363    pub struct ReqwestNip05Fetcher {
364        client: reqwest::Client,
365    }
366
367    impl ReqwestNip05Fetcher {
368        /// Build a new fetcher with a fresh internal client.
369        ///
370        /// # Errors
371        ///
372        /// Propagates the underlying [`reqwest::Error`] when the
373        /// client cannot be initialised (typically a TLS backend
374        /// initialisation failure).
375        pub fn new() -> Result<Self, reqwest::Error> {
376            let client = reqwest::Client::builder()
377                .redirect(reqwest::redirect::Policy::none())
378                .build()?;
379            Ok(Self { client })
380        }
381
382        /// Wrap an existing `reqwest::Client`.
383        ///
384        /// **Caller responsibility**: the supplied client MUST be
385        /// configured with `redirect::Policy::none()`. NIP-05's
386        /// security constraints rely on every fetch refusing
387        /// redirects; passing in a client with a default redirect
388        /// policy silently weakens that.
389        #[must_use]
390        pub const fn from_client(client: reqwest::Client) -> Self {
391            Self { client }
392        }
393    }
394
395    async fn do_fetch(client: &reqwest::Client, url: &str) -> Result<String, Nip05FetchError> {
396        let response = client
397            .get(url)
398            .send()
399            .await
400            .map_err(|e| Nip05FetchError::Transport(e.to_string()))?;
401        let status = response.status();
402        if status.is_redirection() {
403            return Err(Nip05FetchError::Redirected);
404        }
405        if !status.is_success() {
406            return Err(Nip05FetchError::Status(status.as_u16()));
407        }
408        response
409            .text()
410            .await
411            .map_err(|e| Nip05FetchError::Transport(e.to_string()))
412    }
413
414    impl Nip05Fetcher for ReqwestNip05Fetcher {
415        fn fetch<'a>(&'a self, url: &'a str) -> FetchFuture<'a, String, Nip05FetchError> {
416            Box::pin(do_fetch(&self.client, url))
417        }
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    const FIXTURE_PUBKEY_HEX: &str =
426        "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9";
427    const FIXTURE_DOC: &str = r#"{
428        "names": {
429            "bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
430        },
431        "relays": {
432            "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9": [
433                "wss://relay.example.com",
434                "wss://relay2.example.com"
435            ]
436        }
437    }"#;
438
439    fn fixture_pubkey() -> PublicKey {
440        PublicKey::parse(FIXTURE_PUBKEY_HEX).unwrap()
441    }
442
443    #[test]
444    fn parse_address_lowercases_and_validates_local_part() {
445        let a = Nip05Address::parse("Bob@Example.COM").unwrap();
446        assert_eq!(a.local, "bob");
447        assert_eq!(a.domain, "example.com");
448        assert!(!a.is_root());
449        assert_eq!(a.display(), "bob@example.com");
450    }
451
452    #[test]
453    fn parse_address_recognises_root_identifier() {
454        let a = Nip05Address::parse("_@bob.com").unwrap();
455        assert!(a.is_root());
456        // Display strips the leading `_@` for root identifiers.
457        assert_eq!(a.display(), "bob.com");
458    }
459
460    #[test]
461    fn parse_address_rejects_invalid_local_part() {
462        let cases = [
463            ("bob+spam@x.com", "bob+spam"),
464            ("bob spam@x.com", "bob spam"),
465            ("bob/spam@x.com", "bob/spam"),
466            ("bob:spam@x.com", "bob:spam"),
467        ];
468        for (input, raw_local) in cases {
469            let err = Nip05Address::parse(input).unwrap_err();
470            assert!(
471                matches!(err, Nip05Error::InvalidLocalPart(s) if s == raw_local),
472                "expected InvalidLocalPart for {input:?}, got something else"
473            );
474        }
475    }
476
477    #[test]
478    fn parse_address_rejects_missing_or_doubled_separator() {
479        assert!(matches!(
480            Nip05Address::parse("noseparator").unwrap_err(),
481            Nip05Error::MalformedAddress,
482        ));
483        assert!(matches!(
484            Nip05Address::parse("a@b@c").unwrap_err(),
485            Nip05Error::MalformedAddress,
486        ));
487        assert!(matches!(
488            Nip05Address::parse("nodomain@").unwrap_err(),
489            Nip05Error::EmptyDomain,
490        ));
491    }
492
493    #[test]
494    fn well_known_url_uses_https_and_lowercase_query() {
495        let a = Nip05Address::parse("BOB@Example.COM").unwrap();
496        assert_eq!(
497            a.well_known_url(),
498            "https://example.com/.well-known/nostr.json?name=bob"
499        );
500    }
501
502    #[test]
503    fn document_parse_round_trips_names_and_relays() {
504        let doc = Nip05Document::parse(FIXTURE_DOC).unwrap();
505        assert_eq!(doc.pubkey_for("bob"), Some(&fixture_pubkey()));
506        let relays = doc.relays_for(&fixture_pubkey());
507        assert_eq!(relays.len(), 2);
508        assert_eq!(relays[0].as_str(), "wss://relay.example.com/");
509    }
510
511    #[test]
512    fn document_parse_handles_minimal_fixture_without_relays() {
513        let json = r#"{"names":{"bob":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"}}"#;
514        let doc = Nip05Document::parse(json).unwrap();
515        assert!(doc.relays.is_empty());
516        assert_eq!(doc.pubkey_for("bob"), Some(&fixture_pubkey()));
517    }
518
519    #[test]
520    fn verify_document_returns_true_for_match_and_false_for_mismatch() {
521        let address = Nip05Address::parse("bob@example.com").unwrap();
522        assert!(verify_document(&address, FIXTURE_DOC, &fixture_pubkey()).unwrap());
523
524        // Different pubkey -> false (not an error: the document is
525        // well-formed, the identifier just doesn't match the user we
526        // have).
527        let other =
528            PublicKey::parse("0000000000000000000000000000000000000000000000000000000000000003")
529                .unwrap();
530        // Construct a synthetic Keys to derive a public key.
531        let some_other_pubkey =
532            *crate::Keys::parse("0000000000000000000000000000000000000000000000000000000000000005")
533                .unwrap()
534                .public_key();
535        assert!(!verify_document(&address, FIXTURE_DOC, &other).unwrap());
536        assert!(!verify_document(&address, FIXTURE_DOC, &some_other_pubkey).unwrap());
537    }
538
539    #[test]
540    fn verify_document_errors_when_name_is_absent() {
541        let address = Nip05Address::parse("alice@example.com").unwrap();
542        let err = verify_document(&address, FIXTURE_DOC, &fixture_pubkey()).unwrap_err();
543        assert!(matches!(err, Nip05Error::NameNotListed(s) if s == "alice"));
544    }
545
546    /// In-memory mock fetcher used to drive the async helpers without
547    /// pulling in tokio as a dev-dep.
548    struct MockFetcher {
549        body: String,
550    }
551
552    impl Nip05Fetcher for MockFetcher {
553        fn fetch<'a>(&'a self, _url: &'a str) -> FetchFuture<'a, String, Nip05FetchError> {
554            let body = self.body.clone();
555            Box::pin(async move { Ok(body) })
556        }
557    }
558
559    fn block_on<F: Future>(fut: F) -> F::Output {
560        // A tiny in-test executor: poll the future until it's done,
561        // using the standard library's no-op waker. Sufficient for a
562        // fetcher that finishes synchronously.
563        use std::task::{Context, Poll, Waker};
564        let waker = Waker::noop();
565        let mut cx = Context::from_waker(waker);
566        let mut fut = Box::pin(fut);
567        loop {
568            if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
569                return v;
570            }
571        }
572    }
573
574    #[test]
575    fn high_level_helpers_work_against_a_mock_fetcher() {
576        let fetcher = MockFetcher {
577            body: FIXTURE_DOC.to_owned(),
578        };
579        let address = Nip05Address::parse("bob@example.com").unwrap();
580
581        let pk = block_on(lookup_pubkey(&fetcher, &address)).unwrap();
582        assert_eq!(pk, fixture_pubkey());
583
584        let (pk2, relays) = block_on(lookup_with_relays(&fetcher, &address)).unwrap();
585        assert_eq!(pk2, fixture_pubkey());
586        assert_eq!(relays.len(), 2);
587
588        let ok = block_on(verify_identifier(&fetcher, &address, &fixture_pubkey())).unwrap();
589        assert!(ok);
590    }
591}