Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 71 additions & 1 deletion crates/buzz-auth/src/error.rs
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
//! Error types for buzz-auth.

/// Prefix of the operator-facing detail inside [`AuthError::Nip98Invalid`] for
/// `u`-tag mismatches. Kept as a shared constant so [`AuthError::client_message`]
/// and the verifier cannot drift apart.
pub const NIP98_URL_MISMATCH_PREFIX: &str = "URL mismatch";

/// All errors that can occur during authentication and authorization.
///
/// Variants are designed to be safe to return to callers without leaking
/// internal implementation details. Do **not** include raw token values,
/// database contents, or stack traces in error messages.
///
/// [`AuthError::Nip98Invalid`] is the exception for **server logs only**: its
/// `Display` may include the signed `u` tag and the relay's expected URL so
/// operators can diagnose Host / proxy mismatches. HTTP handlers must return
/// [`AuthError::client_message`] instead of formatting `{e}` into responses.
#[derive(Debug, thiserror::Error)]
pub enum AuthError {
/// The NIP-42 event signature is invalid or the event is structurally malformed.
Expand All @@ -26,7 +36,8 @@ pub enum AuthError {
/// NIP-98 HTTP Auth event (kind:27235) failed verification.
///
/// The inner string describes the specific failure (signature, timestamp, URL, etc.)
/// and is safe to include in server logs. Do **not** forward raw event content to clients.
/// and is safe to include in **server logs**. Do **not** forward this Display
/// form to clients — use [`AuthError::client_message`].
#[error("NIP-98 HTTP Auth verification failed: {0}")]
Nip98Invalid(String),

Expand Down Expand Up @@ -57,3 +68,62 @@ pub enum AuthError {
#[error("internal auth error: {0}")]
Internal(String),
}

impl AuthError {
/// Client-facing message that must not disclose internal relay addresses,
/// signed URL tags, or other verification detail useful to an unauthenticated
/// caller probing a fronted origin.
///
/// Log the full [`Display`](std::fmt::Display) form server-side; return this
/// string (or an equivalent opaque phrase) in HTTP 401 bodies.
#[must_use]
pub fn client_message(&self) -> &'static str {
match self {
Self::Nip98Invalid(detail) if detail.starts_with(NIP98_URL_MISMATCH_PREFIX) => {
"NIP-98: URL mismatch"
}
Self::Nip98Invalid(_) => "NIP-98: authentication failed",
Self::Nip98Replay => "NIP-98: replay detected",
Self::InvalidSignature => "invalid signature or malformed auth event",
Self::ChallengeMismatch => "challenge mismatch",
Self::RelayUrlMismatch => "relay url mismatch",
Self::EventExpired => "auth event timestamp outside ±60s window",
Self::PubkeyMismatch => {
"pubkey mismatch: event pubkey does not match authenticated identity"
}
Self::InsufficientScope { .. } => "insufficient scope",
Self::ChannelAccessDenied => "channel access denied",
// Internal detail stays off the wire; callers already map this to 5xx.
Self::Internal(_) => "internal auth error",
}
}
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn nip98_url_mismatch_client_message_omits_urls() {
let err = AuthError::Nip98Invalid(format!(
"{NIP98_URL_MISMATCH_PREFIX}: event has `https://public.example.com/query`, expected `http://10.0.0.1:3001/query`"
));
let full = err.to_string();
assert!(
full.contains("10.0.0.1"),
"Display must retain detail for operators; got {full}"
);
let client = err.client_message();
assert_eq!(client, "NIP-98: URL mismatch");
assert!(
!client.contains("10.0.0.1") && !client.contains("public.example.com"),
"client message must not embed either URL; got {client}"
);
}

#[test]
fn other_nip98_failures_collapse_to_generic_client_message() {
let err = AuthError::Nip98Invalid("invalid Schnorr signature".into());
assert_eq!(err.client_message(), "NIP-98: authentication failed");
}
}
2 changes: 1 addition & 1 deletion crates/buzz-auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub mod rate_limit;
pub mod scope;

pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker};
pub use error::AuthError;
pub use error::{AuthError, NIP98_URL_MISMATCH_PREFIX};
pub use nip42::{generate_challenge, verify_nip42_event};
pub use nip98::verify_nip98_event;
pub use nip98_replay::{
Expand Down
24 changes: 23 additions & 1 deletion crates/buzz-auth/src/nip98.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,8 @@ pub fn verify_nip98_event(

if normalize_url(u_tag) != normalize_url(expected_url) {
return Err(AuthError::Nip98Invalid(format!(
"URL mismatch: event has `{u_tag}`, expected `{expected_url}`"
"{}: event has `{u_tag}`, expected `{expected_url}`",
crate::error::NIP98_URL_MISMATCH_PREFIX
)));
}

Expand Down Expand Up @@ -314,4 +315,25 @@ mod tests {
let json3 = make_nip98_event(&keys, loopback_url, TEST_METHOD, None, None);
assert!(verify_nip98_event(&json3, loopback_url, TEST_METHOD, None).is_ok());
}

#[test]
fn url_mismatch_client_message_omits_expected_host() {
let keys = Keys::generate();
let signed = "https://public.example.com/query";
let expected = "http://10.0.0.1:3001/query";
let json = make_nip98_event(&keys, signed, TEST_METHOD, None, None);
let err = verify_nip98_event(&json, expected, TEST_METHOD, None)
.expect_err("cross-host u-tag must fail");
let full = err.to_string();
assert!(
full.contains("10.0.0.1"),
"operator Display must retain the expected host; got {full}"
);
let client = err.client_message();
assert_eq!(client, "NIP-98: URL mismatch");
assert!(
!client.contains("10.0.0.1") && !client.contains("public.example.com"),
"client message must not embed either URL; got {client}"
);
}
}
28 changes: 21 additions & 7 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,14 @@ pub(crate) fn verify_bridge_auth_with_options(
));
}

let pubkey = buzz_auth::verify_nip98_event(&event_json, url, method, body)
.map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?;
let pubkey =
buzz_auth::verify_nip98_event(&event_json, url, method, body).map_err(|e| {
// Log the full AuthError (may include expected Host / signed `u`)
// server-side only. Client bodies use client_message() so a
// reverse-proxied internal origin is never disclosed to probes.
tracing::warn!(error = %e, "NIP-98 auth failed");
api_error(StatusCode::UNAUTHORIZED, e.client_message())
})?;

return Ok((pubkey, event_id_bytes));
}
Expand Down Expand Up @@ -2498,10 +2504,14 @@ mod tests {
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert_eq!(
msg, "NIP-98: URL mismatch",
"rejection must carry the URL-mismatch signal without embedding \
either host URL; got body = {body:?}"
);
assert!(
msg.contains("URL mismatch"),
"rejection must carry the URL-mismatch signal so callers can \
distinguish it from other auth failures; got body = {body:?}"
!msg.contains("host-a.example") && !msg.contains("host-b.example"),
"client body must not disclose signed or expected hosts; got {msg}"
);
}

Expand Down Expand Up @@ -2632,9 +2642,13 @@ mod tests {
.get("error")
.and_then(|v| v.as_str())
.unwrap_or_default();
assert_eq!(
msg, "NIP-98: URL mismatch",
"rejection must be a URL mismatch without URL payloads; got body = {body:?}"
);
assert!(
msg.contains("URL mismatch"),
"rejection must be a URL mismatch; got body = {body:?}"
!msg.contains("host-a.example") && !msg.contains('?'),
"client body must not echo the signed URL; got {msg}"
);
}

Expand Down
72 changes: 72 additions & 0 deletions crates/buzz-relay/src/nip11.rs
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,17 @@ fn push_descriptor(
) -> Option<serde_json::Value> {
let host = tenant_host?;
push_configured.then_some(())?;
// Unauthenticated NIP-11 must never advertise a private/loopback origin.
// When a reverse proxy forwards the internal bind Host (or a client hits
// the origin directly), omit the push descriptor rather than disclosing
// topology that the fronting proxy exists to hide.
if !push_origin_host_is_public(host) {
tracing::warn!(
host,
"omitting NIP-11 push descriptor: tenant host is not a public origin"
);
return None;
}
let scheme = if relay_url.starts_with("wss://") {
"wss"
} else {
Expand Down Expand Up @@ -231,6 +242,29 @@ fn push_descriptor(
}))
}

/// Whether `host` (a tenant authority, possibly with a non-default port) is
/// safe to advertise as `push.origin` on the unauthenticated NIP-11 document.
///
/// Domains other than `localhost` are treated as public. Literal IP hosts use
/// [`buzz_core::network::is_private_ip`]. Unparseable authorities fail closed.
fn push_origin_host_is_public(host: &str) -> bool {
// Same Host normalization as tenant binding (trailing FQDN dot, default
// ports) so `localhost.` cannot slip past the loopback deny.
let host = buzz_core::tenant::normalize_host(host);
if host.is_empty() {
return false;
}
let Ok(url) = url::Url::parse(&format!("http://{host}/")) else {
return false;
};
match url.host() {
Some(url::Host::Domain(domain)) => !domain.eq_ignore_ascii_case("localhost"),
Some(url::Host::Ipv4(ip)) => !buzz_core::network::is_private_ip(&std::net::IpAddr::V4(ip)),
Some(url::Host::Ipv6(ip)) => !buzz_core::network::is_private_ip(&std::net::IpAddr::V6(ip)),
None => false,
}
}

/// Builds the served NIP-11 document for a request arriving on `raw_host`.
///
/// Centralised so the content-negotiated root handler and the dedicated
Expand Down Expand Up @@ -359,6 +393,44 @@ mod tests {
);
}

#[test]
fn push_descriptor_omits_private_and_loopback_origins() {
let keys = nostr::Keys::generate();
for host in [
"10.0.0.1:3001",
"192.168.1.10",
"127.0.0.1:3000",
"localhost:3000",
"localhost",
"localhost.",
"[::1]:3000",
] {
assert!(
push_descriptor(true, "ws://relay", "key", &keys, Some(host)).is_none(),
"private/loopback host {host:?} must not be advertised as push.origin"
);
}
let public = push_descriptor(
true,
"wss://relay",
"key",
&keys,
Some("community.example.com"),
)
.expect("public hostname must still advertise push");
assert_eq!(public["origin"], "wss://community.example.com");
}

#[test]
fn push_origin_host_classification() {
assert!(push_origin_host_is_public("tenant.example"));
assert!(push_origin_host_is_public("community.example.com:8443"));
assert!(!push_origin_host_is_public("10.0.0.1:3001"));
assert!(!push_origin_host_is_public("localhost:3000"));
assert!(!push_origin_host_is_public("localhost."));
assert!(!push_origin_host_is_public(""));
}

#[test]
fn supported_nips_includes_nip23_and_nip33() {
// Tests the production SUPPORTED_NIPS constant directly — no Config::from_env()
Expand Down