diff --git a/crates/buzz-relay/src/api/bridge.rs b/crates/buzz-relay/src/api/bridge.rs index a118ff453f..86490f0b2c 100644 --- a/crates/buzz-relay/src/api/bridge.rs +++ b/crates/buzz-relay/src/api/bridge.rs @@ -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 { + 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 { let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); @@ -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. diff --git a/crates/buzz-relay/src/handlers/auth.rs b/crates/buzz-relay/src/handlers/auth.rs index 127f1fc40e..ca4b6cb1c1 100644 --- a/crates/buzz-relay/src/handlers/auth.rs +++ b/crates/buzz-relay/src/handlers/auth.rs @@ -35,6 +35,37 @@ pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { serde_json::to_string(first.as_slice()).ok() } +async fn verify_auth_event_against_any_relay_url( + auth_svc: Arc, + event: nostr::Event, + challenge: &str, + relay_urls: &[String], +) -> Result { + 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. /// @@ -77,17 +108,19 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, 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; diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index fc874a0150..8f5ccbc865 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -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 { @@ -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 @@ -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, });