fix(nip11): advertise auth_required: true to match actual enforcement (#556)

Signed-off-by: Tyler Longwell <109685178+tlongwell-block@users.noreply.github.com>
This commit is contained in:
tlongwell-block
2026-05-12 20:08:53 -04:00
committed by GitHub
parent 8004bafaff
commit cacfe6b84e
4 changed files with 47 additions and 29 deletions

View File

@@ -39,7 +39,8 @@ pub struct Config {
pub send_buffer_size: usize,
/// Authentication provider configuration.
pub auth: sprout_auth::AuthConfig,
/// Whether clients must authenticate via NIP-42 before sending events.
/// Whether REST API requests must present a valid token. Independent of
/// WebSocket protocol auth, which is *always* required by REQ/EVENT/COUNT.
pub require_auth_token: bool,
/// Comma-separated list of allowed CORS origins.
/// If empty, permissive CORS is used (dev mode).
@@ -197,8 +198,8 @@ impl Config {
if !require_auth_token {
warn!(
"SPROUT_REQUIRE_AUTH_TOKEN is false — relay accepts unauthenticated connections. \
Set to true for production."
"SPROUT_REQUIRE_AUTH_TOKEN is false — REST API requests bypass token auth. \
WebSocket protocol auth is unaffected. Set to true for production."
);
}

View File

@@ -48,7 +48,8 @@ pub struct RelayLimitation {
pub max_subid_length: Option<u32>,
/// Minimum proof-of-work difficulty required for events.
pub min_pow_difficulty: Option<u32>,
/// Whether NIP-42 authentication is required before sending events.
/// Whether NIP-42 authentication is required before subscribing or
/// publishing events.
pub auth_required: bool,
/// Whether payment is required to use the relay.
pub payment_required: bool,
@@ -56,12 +57,32 @@ pub struct RelayLimitation {
pub restricted_writes: bool,
}
/// Canonical `RelayLimitation` advertised by this relay.
///
/// `auth_required` is always `true`: the REQ, EVENT, and COUNT handlers
/// unconditionally reject connections that are not in
/// `AuthState::Authenticated`. This is independent of the REST API token
/// toggle (`config.require_auth_token`).
fn relay_limitation() -> RelayLimitation {
RelayLimitation {
max_message_length: Some(MAX_FRAME_BYTES as u64),
max_subscriptions: Some(1024),
max_filters: Some(10),
max_limit: Some(10_000),
max_subid_length: Some(256),
min_pow_difficulty: None,
auth_required: true,
payment_required: false,
restricted_writes: true,
}
}
impl RelayInfo {
/// Builds a `RelayInfo` document from the relay's runtime config.
/// Builds the relay's NIP-11 information document.
///
/// `relay_pubkey` is the relay's own signing pubkey (hex), advertised as the
/// NIP-11 `self` field for NIP-43 membership verification.
pub fn from_config(config: &crate::config::Config, relay_pubkey: Option<&str>) -> Self {
pub fn build(relay_pubkey: Option<&str>) -> Self {
Self {
name: "Sprout Relay".to_string(),
description: "Sprout — private team communication relay".to_string(),
@@ -70,17 +91,7 @@ impl RelayInfo {
supported_nips: SUPPORTED_NIPS.to_vec(),
software: "https://github.com/sprout-rs/sprout".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
limitation: Some(RelayLimitation {
max_message_length: Some(MAX_FRAME_BYTES as u64),
max_subscriptions: Some(1024),
max_filters: Some(10),
max_limit: Some(10_000),
max_subid_length: Some(256),
min_pow_difficulty: None,
auth_required: config.require_auth_token,
payment_required: false,
restricted_writes: true,
}),
limitation: Some(relay_limitation()),
relay_self: relay_pubkey.map(|s| s.to_string()),
}
}
@@ -97,10 +108,7 @@ pub async fn relay_info_handler(
} else {
None
};
axum::response::Json(RelayInfo::from_config(
&state.config,
relay_pubkey.as_deref(),
))
axum::response::Json(RelayInfo::build(relay_pubkey.as_deref()))
}
#[cfg(test)]
@@ -129,6 +137,15 @@ mod tests {
);
}
#[test]
fn auth_required_is_advertised_true() {
// REQ, EVENT, and COUNT all unconditionally require
// `AuthState::Authenticated` (see `crates/sprout-relay/src/handlers/`).
// The NIP-11 doc must reflect that or clients (e.g. the desktop pair
// flow) misroute unauthenticated peers.
assert!(relay_limitation().auth_required);
}
#[test]
fn supported_nips_are_sorted() {
let mut sorted = SUPPORTED_NIPS.to_vec();

View File

@@ -160,7 +160,7 @@ async fn nip11_or_ws_handler(
};
if accept.contains("application/nostr+json") {
let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
let info = RelayInfo::build(relay_pubkey.as_deref());
return Json(info).into_response();
}
@@ -179,7 +179,7 @@ async fn nip11_or_ws_handler(
}
}
// Not a WS request and not asking for nostr+json — serve NIP-11 as fallback.
let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref());
let info = RelayInfo::build(relay_pubkey.as_deref());
Json(info).into_response()
}
}

View File

@@ -605,12 +605,12 @@ async fn test_nip11_relay_info() {
Some(1024),
"limitation.max_subscriptions must be 1024"
);
assert!(
limitation
.get("auth_required")
.and_then(|v| v.as_bool())
.is_some(),
"limitation.auth_required must be a boolean"
// The REQ, EVENT, and COUNT handlers unconditionally require an
// authenticated connection, so the NIP-11 doc must advertise that.
assert_eq!(
limitation.get("auth_required").and_then(|v| v.as_bool()),
Some(true),
"limitation.auth_required must be true — REQ/EVENT/COUNT require NIP-42 auth"
);
}