1use 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
54pub const ROOT_LOCAL_PART: &str = "_";
58
59pub const WELL_KNOWN_PATH: &str = "/.well-known/nostr.json";
61
62#[derive(Debug, Error)]
64#[non_exhaustive]
65pub enum Nip05Error {
66 #[error("NIP-05 address must contain exactly one `@`")]
68 MalformedAddress,
69 #[error("NIP-05 local-part must only use `a-z0-9-_.`; got `{0}`")]
71 InvalidLocalPart(String),
72 #[error("NIP-05 domain must not be empty")]
74 EmptyDomain,
75 #[error("NIP-05 well-known JSON failed to parse: {0}")]
77 DocumentParse(#[from] serde_json::Error),
78 #[error("NIP-05 well-known document does not list `{0}` under `names`")]
80 NameNotListed(String),
81}
82
83#[derive(Debug, Error)]
85#[non_exhaustive]
86pub enum Nip05FetchError {
87 #[error("NIP-05 well-known fetch failed: {0}")]
89 Transport(String),
90 #[error("NIP-05 well-known fetch returned status {0}")]
92 Status(u16),
93 #[error("NIP-05 well-known fetch was redirected, which the spec forbids")]
95 Redirected,
96}
97
98#[derive(Debug, Error)]
100#[non_exhaustive]
101pub enum Nip05LookupError {
102 #[error(transparent)]
104 Address(Nip05Error),
105 #[error(transparent)]
107 Fetch(#[from] Nip05FetchError),
108 #[error(transparent)]
110 Document(Nip05Error),
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Hash)]
120pub struct Nip05Address {
121 pub local: String,
123 pub domain: String,
125}
126
127impl Nip05Address {
128 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 #[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 #[must_use]
170 pub fn is_root(&self) -> bool {
171 self.local == ROOT_LOCAL_PART
172 }
173
174 #[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#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
198pub struct Nip05Document {
199 #[serde(default)]
201 pub names: HashMap<String, PublicKey>,
202 #[serde(default)]
204 pub relays: HashMap<PublicKey, Vec<RelayUrl>>,
205}
206
207impl Nip05Document {
208 pub fn parse(json: &str) -> Result<Self, Nip05Error> {
215 Ok(serde_json::from_str(json)?)
216 }
217
218 #[must_use]
224 pub fn pubkey_for(&self, local: &str) -> Option<&PublicKey> {
225 self.names.get(local)
226 }
227
228 #[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
239pub 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
264pub type FetchFuture<'a, T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
266
267pub trait Nip05Fetcher: Send + Sync {
280 fn fetch<'a>(&'a self, url: &'a str) -> FetchFuture<'a, String, Nip05FetchError>;
282}
283
284pub 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
305pub 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
326pub 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 #[derive(Debug, Clone)]
363 pub struct ReqwestNip05Fetcher {
364 client: reqwest::Client,
365 }
366
367 impl ReqwestNip05Fetcher {
368 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 #[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 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 let other =
528 PublicKey::parse("0000000000000000000000000000000000000000000000000000000000000003")
529 .unwrap();
530 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 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 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}