use std::collections::HashMap;
use std::future::Future;
use std::pin::Pin;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::key::PublicKey;
use crate::types::RelayUrl;
pub const ROOT_LOCAL_PART: &str = "_";
pub const WELL_KNOWN_PATH: &str = "/.well-known/nostr.json";
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip05Error {
#[error("NIP-05 address must contain exactly one `@`")]
MalformedAddress,
#[error("NIP-05 local-part must only use `a-z0-9-_.`; got `{0}`")]
InvalidLocalPart(String),
#[error("NIP-05 domain must not be empty")]
EmptyDomain,
#[error("NIP-05 well-known JSON failed to parse: {0}")]
DocumentParse(#[from] serde_json::Error),
#[error("NIP-05 well-known document does not list `{0}` under `names`")]
NameNotListed(String),
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip05FetchError {
#[error("NIP-05 well-known fetch failed: {0}")]
Transport(String),
#[error("NIP-05 well-known fetch returned status {0}")]
Status(u16),
#[error("NIP-05 well-known fetch was redirected, which the spec forbids")]
Redirected,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip05LookupError {
#[error(transparent)]
Address(Nip05Error),
#[error(transparent)]
Fetch(#[from] Nip05FetchError),
#[error(transparent)]
Document(Nip05Error),
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Nip05Address {
pub local: String,
pub domain: String,
}
impl Nip05Address {
pub fn parse(input: &str) -> Result<Self, Nip05Error> {
let (local, domain) = input.split_once('@').ok_or(Nip05Error::MalformedAddress)?;
if local.contains('@') || domain.contains('@') {
return Err(Nip05Error::MalformedAddress);
}
if domain.is_empty() {
return Err(Nip05Error::EmptyDomain);
}
let local_lower = local.to_ascii_lowercase();
if !is_valid_local_part(&local_lower) {
return Err(Nip05Error::InvalidLocalPart(local.to_owned()));
}
Ok(Self {
local: local_lower,
domain: domain.to_ascii_lowercase(),
})
}
#[must_use]
pub fn well_known_url(&self) -> String {
format!(
"https://{domain}{path}?name={local}",
domain = self.domain,
path = WELL_KNOWN_PATH,
local = self.local,
)
}
#[must_use]
pub fn is_root(&self) -> bool {
self.local == ROOT_LOCAL_PART
}
#[must_use]
pub fn display(&self) -> String {
if self.is_root() {
self.domain.clone()
} else {
format!("{}@{}", self.local, self.domain)
}
}
}
fn is_valid_local_part(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| matches!(b, b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.'))
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)]
pub struct Nip05Document {
#[serde(default)]
pub names: HashMap<String, PublicKey>,
#[serde(default)]
pub relays: HashMap<PublicKey, Vec<RelayUrl>>,
}
impl Nip05Document {
pub fn parse(json: &str) -> Result<Self, Nip05Error> {
Ok(serde_json::from_str(json)?)
}
#[must_use]
pub fn pubkey_for(&self, local: &str) -> Option<&PublicKey> {
self.names.get(local)
}
#[must_use]
pub fn relays_for(&self, pubkey: &PublicKey) -> &[RelayUrl] {
self.relays
.get(pubkey)
.map(Vec::as_slice)
.unwrap_or_default()
}
}
pub fn verify_document(
address: &Nip05Address,
document_json: &str,
expected_pubkey: &PublicKey,
) -> Result<bool, Nip05Error> {
let doc = Nip05Document::parse(document_json)?;
let listed = doc
.pubkey_for(&address.local)
.ok_or_else(|| Nip05Error::NameNotListed(address.local.clone()))?;
Ok(listed == expected_pubkey)
}
pub type FetchFuture<'a, T, E> = Pin<Box<dyn Future<Output = Result<T, E>> + Send + 'a>>;
pub trait Nip05Fetcher: Send + Sync {
fn fetch<'a>(&'a self, url: &'a str) -> FetchFuture<'a, String, Nip05FetchError>;
}
pub async fn lookup_pubkey<F>(
fetcher: &F,
address: &Nip05Address,
) -> Result<PublicKey, Nip05LookupError>
where
F: Nip05Fetcher + ?Sized,
{
let body = fetcher.fetch(&address.well_known_url()).await?;
let doc = Nip05Document::parse(&body).map_err(Nip05LookupError::Document)?;
let pk = doc.pubkey_for(&address.local).copied().ok_or_else(|| {
Nip05LookupError::Document(Nip05Error::NameNotListed(address.local.clone()))
})?;
Ok(pk)
}
pub async fn lookup_with_relays<F>(
fetcher: &F,
address: &Nip05Address,
) -> Result<(PublicKey, Vec<RelayUrl>), Nip05LookupError>
where
F: Nip05Fetcher + ?Sized,
{
let body = fetcher.fetch(&address.well_known_url()).await?;
let doc = Nip05Document::parse(&body).map_err(Nip05LookupError::Document)?;
let pk = doc.pubkey_for(&address.local).copied().ok_or_else(|| {
Nip05LookupError::Document(Nip05Error::NameNotListed(address.local.clone()))
})?;
let relays = doc.relays_for(&pk).to_vec();
Ok((pk, relays))
}
pub async fn verify_identifier<F>(
fetcher: &F,
address: &Nip05Address,
expected_pubkey: &PublicKey,
) -> Result<bool, Nip05LookupError>
where
F: Nip05Fetcher + ?Sized,
{
let body = fetcher.fetch(&address.well_known_url()).await?;
verify_document(address, &body, expected_pubkey).map_err(Nip05LookupError::Document)
}
#[cfg(feature = "nip05")]
#[cfg_attr(docsrs, doc(cfg(feature = "nip05")))]
pub use reqwest_impl::ReqwestNip05Fetcher;
#[cfg(feature = "nip05")]
mod reqwest_impl {
use super::{FetchFuture, Nip05FetchError, Nip05Fetcher};
#[derive(Debug, Clone)]
pub struct ReqwestNip05Fetcher {
client: reqwest::Client,
}
impl ReqwestNip05Fetcher {
pub fn new() -> Result<Self, reqwest::Error> {
let client = reqwest::Client::builder()
.redirect(reqwest::redirect::Policy::none())
.build()?;
Ok(Self { client })
}
#[must_use]
pub const fn from_client(client: reqwest::Client) -> Self {
Self { client }
}
}
async fn do_fetch(client: &reqwest::Client, url: &str) -> Result<String, Nip05FetchError> {
let response = client
.get(url)
.send()
.await
.map_err(|e| Nip05FetchError::Transport(e.to_string()))?;
let status = response.status();
if status.is_redirection() {
return Err(Nip05FetchError::Redirected);
}
if !status.is_success() {
return Err(Nip05FetchError::Status(status.as_u16()));
}
response
.text()
.await
.map_err(|e| Nip05FetchError::Transport(e.to_string()))
}
impl Nip05Fetcher for ReqwestNip05Fetcher {
fn fetch<'a>(&'a self, url: &'a str) -> FetchFuture<'a, String, Nip05FetchError> {
Box::pin(do_fetch(&self.client, url))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
const FIXTURE_PUBKEY_HEX: &str =
"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9";
const FIXTURE_DOC: &str = r#"{
"names": {
"bob": "b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"
},
"relays": {
"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9": [
"wss://relay.example.com",
"wss://relay2.example.com"
]
}
}"#;
fn fixture_pubkey() -> PublicKey {
PublicKey::parse(FIXTURE_PUBKEY_HEX).unwrap()
}
#[test]
fn parse_address_lowercases_and_validates_local_part() {
let a = Nip05Address::parse("Bob@Example.COM").unwrap();
assert_eq!(a.local, "bob");
assert_eq!(a.domain, "example.com");
assert!(!a.is_root());
assert_eq!(a.display(), "bob@example.com");
}
#[test]
fn parse_address_recognises_root_identifier() {
let a = Nip05Address::parse("_@bob.com").unwrap();
assert!(a.is_root());
assert_eq!(a.display(), "bob.com");
}
#[test]
fn parse_address_rejects_invalid_local_part() {
let cases = [
("bob+spam@x.com", "bob+spam"),
("bob spam@x.com", "bob spam"),
("bob/spam@x.com", "bob/spam"),
("bob:spam@x.com", "bob:spam"),
];
for (input, raw_local) in cases {
let err = Nip05Address::parse(input).unwrap_err();
assert!(
matches!(err, Nip05Error::InvalidLocalPart(s) if s == raw_local),
"expected InvalidLocalPart for {input:?}, got something else"
);
}
}
#[test]
fn parse_address_rejects_missing_or_doubled_separator() {
assert!(matches!(
Nip05Address::parse("noseparator").unwrap_err(),
Nip05Error::MalformedAddress,
));
assert!(matches!(
Nip05Address::parse("a@b@c").unwrap_err(),
Nip05Error::MalformedAddress,
));
assert!(matches!(
Nip05Address::parse("nodomain@").unwrap_err(),
Nip05Error::EmptyDomain,
));
}
#[test]
fn well_known_url_uses_https_and_lowercase_query() {
let a = Nip05Address::parse("BOB@Example.COM").unwrap();
assert_eq!(
a.well_known_url(),
"https://example.com/.well-known/nostr.json?name=bob"
);
}
#[test]
fn document_parse_round_trips_names_and_relays() {
let doc = Nip05Document::parse(FIXTURE_DOC).unwrap();
assert_eq!(doc.pubkey_for("bob"), Some(&fixture_pubkey()));
let relays = doc.relays_for(&fixture_pubkey());
assert_eq!(relays.len(), 2);
assert_eq!(relays[0].as_str(), "wss://relay.example.com/");
}
#[test]
fn document_parse_handles_minimal_fixture_without_relays() {
let json = r#"{"names":{"bob":"b0635d6a9851d3aed0cd6c495b282167acf761729078d975fc341b22650b07b9"}}"#;
let doc = Nip05Document::parse(json).unwrap();
assert!(doc.relays.is_empty());
assert_eq!(doc.pubkey_for("bob"), Some(&fixture_pubkey()));
}
#[test]
fn verify_document_returns_true_for_match_and_false_for_mismatch() {
let address = Nip05Address::parse("bob@example.com").unwrap();
assert!(verify_document(&address, FIXTURE_DOC, &fixture_pubkey()).unwrap());
let other =
PublicKey::parse("0000000000000000000000000000000000000000000000000000000000000003")
.unwrap();
let some_other_pubkey =
*crate::Keys::parse("0000000000000000000000000000000000000000000000000000000000000005")
.unwrap()
.public_key();
assert!(!verify_document(&address, FIXTURE_DOC, &other).unwrap());
assert!(!verify_document(&address, FIXTURE_DOC, &some_other_pubkey).unwrap());
}
#[test]
fn verify_document_errors_when_name_is_absent() {
let address = Nip05Address::parse("alice@example.com").unwrap();
let err = verify_document(&address, FIXTURE_DOC, &fixture_pubkey()).unwrap_err();
assert!(matches!(err, Nip05Error::NameNotListed(s) if s == "alice"));
}
struct MockFetcher {
body: String,
}
impl Nip05Fetcher for MockFetcher {
fn fetch<'a>(&'a self, _url: &'a str) -> FetchFuture<'a, String, Nip05FetchError> {
let body = self.body.clone();
Box::pin(async move { Ok(body) })
}
}
fn block_on<F: Future>(fut: F) -> F::Output {
use std::task::{Context, Poll, Waker};
let waker = Waker::noop();
let mut cx = Context::from_waker(waker);
let mut fut = Box::pin(fut);
loop {
if let Poll::Ready(v) = fut.as_mut().poll(&mut cx) {
return v;
}
}
}
#[test]
fn high_level_helpers_work_against_a_mock_fetcher() {
let fetcher = MockFetcher {
body: FIXTURE_DOC.to_owned(),
};
let address = Nip05Address::parse("bob@example.com").unwrap();
let pk = block_on(lookup_pubkey(&fetcher, &address)).unwrap();
assert_eq!(pk, fixture_pubkey());
let (pk2, relays) = block_on(lookup_with_relays(&fetcher, &address)).unwrap();
assert_eq!(pk2, fixture_pubkey());
assert_eq!(relays.len(), 2);
let ok = block_on(verify_identifier(&fetcher, &address, &fixture_pubkey())).unwrap();
assert!(ok);
}
}