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
58 changes: 58 additions & 0 deletions crates/buzz-relay/src/api/bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,30 @@ pub(crate) fn nip42_expected_relay_url(config_relay_url: &str, tenant: &TenantCo
format!("{scheme}://{}", tenant.host())
}

/// Candidate relay URLs accepted for NIP-42 AUTH on this connection.
///
/// The per-tenant bare origin remains the primary host-binding check. A
/// configured pairing relay URL is also accepted because mobile QR flows sign
/// the exact scanned `BUZZ_PAIRING_RELAY_URL`, which may include a public proxy
/// path even though the upstream relay sees a different internal origin.
pub(crate) fn nip42_acceptable_relay_urls(
config_relay_url: &str,
tenant: &TenantContext,
pairing_relay_url: Option<&str>,
) -> Vec<String> {
let primary = nip42_expected_relay_url(config_relay_url, tenant);
let mut urls = vec![primary.clone()];

if let Some(pairing) = pairing_relay_url
.map(str::trim)
.filter(|value| !value.is_empty() && *value != primary)
{
urls.push(pairing.to_string());
}

urls
}

/// Extract a channel UUID from a single filter's `#h` tag.
fn extract_channel_from_filter(filter: &nostr::Filter) -> Option<uuid::Uuid> {
let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H);
Expand Down Expand Up @@ -2804,6 +2828,40 @@ mod tests {
.expect("matching-host NIP-42 AUTH event must verify");
}

/// Pairing QR flows sign the configured public pairing URL exactly. Keep
/// the primary tenant-origin check, but also expose the configured pairing
/// URL as an explicit fallback candidate so path-based HTTPS/WSS proxies do
/// not break NIP-42 auth with `relay url mismatch`.
#[test]
fn nip42_acceptable_relay_urls_includes_configured_pairing_relay_url() {
let tenant = fresh_tenant("100.69.147.38:3000");
let urls = nip42_acceptable_relay_urls(
"ws://100.69.147.38:3000",
&tenant,
Some("wss://kn8-m1-mbp.tail83606f.ts.net/buzz-relay"),
);

assert_eq!(
urls,
vec![
"ws://100.69.147.38:3000".to_string(),
"wss://kn8-m1-mbp.tail83606f.ts.net/buzz-relay".to_string(),
]
);

let challenge = "fixed-challenge-for-test";
let tenant_expected = &urls[0];
let pairing_expected = &urls[1];
let signed_pairing_url = "wss://kn8-m1-mbp.tail83606f.ts.net/buzz-relay";

let err = verify_nip42_with_urls(challenge, signed_pairing_url, tenant_expected)
.expect_err("tenant-origin check alone should reject path-based pairing URL");
assert!(matches!(err, buzz_auth::AuthError::RelayUrlMismatch));

verify_nip42_with_urls(challenge, signed_pairing_url, pairing_expected)
.expect("configured pairing relay URL should verify as explicit fallback");
}

/// `nip42_expected_relay_url` derives host from `tenant`, not from
/// `config_relay_url`. Pin both directions: changing the tenant's host
/// changes the output; changing the config's host does NOT.
Expand Down
47 changes: 40 additions & 7 deletions crates/buzz-relay/src/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,37 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option<String> {
serde_json::to_string(first.as_slice()).ok()
}

async fn verify_auth_event_against_any_relay_url(
auth_svc: Arc<buzz_auth::AuthService>,
event: nostr::Event,
challenge: &str,
relay_urls: &[String],
) -> Result<buzz_auth::AuthContext, buzz_auth::AuthError> {
let Some((first, rest)) = relay_urls.split_first() else {
return Err(buzz_auth::AuthError::RelayUrlMismatch);
};

match auth_svc
.verify_auth_event(event.clone(), challenge, first)
.await
{
Err(buzz_auth::AuthError::RelayUrlMismatch) => {}
other => return other,
}

for relay_url in rest {
match auth_svc
.verify_auth_event(event.clone(), challenge, relay_url)
.await
{
Err(buzz_auth::AuthError::RelayUrlMismatch) => continue,
other => return other,
}
}

Err(buzz_auth::AuthError::RelayUrlMismatch)
}

/// Handle a NIP-42 AUTH message: verify the challenge response and transition
/// the connection to authenticated state.
///
Expand Down Expand Up @@ -77,17 +108,19 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc<ConnectionState>, state:
// tampered, NIP-42 verification will fail before we ever inspect it.
let auth_tag_json = extract_auth_tag_json(&event);

let relay_url =
crate::api::bridge::nip42_expected_relay_url(&state.config.relay_url, &conn.tenant);
let relay_urls = crate::api::bridge::nip42_acceptable_relay_urls(
&state.config.relay_url,
&conn.tenant,
state.config.pairing_relay_url.as_deref(),
);
let auth_svc = Arc::clone(&state.auth);

metrics::counter!("buzz_auth_attempts_total", "method" => "nip42").increment(1);

// Pure NIP-42 verification — crypto only, no DB lookups.
match auth_svc
.verify_auth_event(event, &challenge, &relay_url)
.await
{
// Pure NIP-42 verification — crypto only, no DB lookups. The primary
// tenant-origin URL preserves cross-host binding; the optional configured
// pairing URL handles public HTTPS/WSS proxy paths used by QR import.
match verify_auth_event_against_any_relay_url(auth_svc, event, &challenge, &relay_urls).await {
Ok(mut auth_ctx) => {
let pubkey = auth_ctx.pubkey;

Expand Down
6 changes: 3 additions & 3 deletions desktop/src-tauri/src/commands/pairing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ use tokio_util::sync::CancellationToken;
use zeroize::Zeroizing;

use crate::app_state::AppState;
use crate::relay::{relay_api_base_url_with_override, relay_ws_url_with_override};
use crate::relay::{relay_http_base_url, relay_ws_url_with_override};

#[derive(Serialize, Clone)]
struct PairingSasPayload {
Expand Down Expand Up @@ -95,7 +95,6 @@ pub async fn start_pairing(
let pubkey_hex = keys.public_key().to_hex();

let ws_url = relay_ws_url_with_override(&state);
let http_url = relay_api_base_url_with_override(&state);

// NIP-43 relays gate connections on membership, so an unpaired peer can't
// reach the main relay yet — it must go through the /pair sidecar. Open
Expand All @@ -104,12 +103,13 @@ pub async fn start_pairing(
// which is also true for plain NIP-42 / NIP-OA relays where the main
// relay is reachable.
let pairing_relay_url = resolve_pairing_relay_url(&ws_url, probe_pairing_relay(&ws_url).await)?;
let payload_relay_url = relay_http_base_url(&pairing_relay_url);

let (session, qr_payload) = PairingSession::new_source(pairing_relay_url.clone());
let qr_uri = encode_qr(&qr_payload);

let payload_json = serde_json::json!({
"relayUrl": http_url,
"relayUrl": payload_relay_url,
"pubkey": pubkey_hex,
"nsec": nsec,
});
Expand Down