diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt index d80e86d7..a1916389 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/SignerProvider.kt @@ -13,6 +13,7 @@ import com.greenart7c3.nostrsigner.models.kindToNip import com.greenart7c3.nostrsigner.models.permissionTypeFromContent import com.greenart7c3.nostrsigner.service.AmberUtils import com.greenart7c3.nostrsigner.service.IntentUtils +import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.model.AmberEvent import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent @@ -170,13 +171,7 @@ class SignerProvider : ContentProvider() { // For kind 22242 (NIP-42 relay auth), extract relay host once for both whitelist and permission checks val relayHost = if (event.kind == 22242) { - AmberEvent.relay(event)?.let { url -> - try { - java.net.URI(url).host ?: url - } catch (e: Exception) { - url - } - } ?: "" + RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event)) } else { "" } diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/RelayUrlUtils.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/RelayUrlUtils.kt new file mode 100644 index 00000000..363b3364 --- /dev/null +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/RelayUrlUtils.kt @@ -0,0 +1,29 @@ +package com.greenart7c3.nostrsigner.service + +import java.net.URI + +object RelayUrlUtils { + /** + * Normalizes a relay URL/host into a comparable "host[:port]" form used by + * the auth whitelist and per-relay permission lookups. A bare host:port + * input (e.g. "relay.example.com:8080") would otherwise be parsed by + * java.net.URI as a scheme, so we prepend "wss://" when no scheme is + * present. + */ + fun extractHostAndPort(input: String?): String { + if (input.isNullOrBlank()) return "" + val trimmed = input.trim().trimEnd('/') + return try { + val withScheme = if (trimmed.contains("://")) trimmed else "wss://$trimmed" + val uri = URI(withScheme) + val host = uri.host + when { + host.isNullOrBlank() -> trimmed + uri.port != -1 -> "$host:${uri.port}" + else -> host + } + } catch (e: Exception) { + trimmed + } + } +} diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AuthWhitelistScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AuthWhitelistScreen.kt index 8a3ee501..14c4219a 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/AuthWhitelistScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/AuthWhitelistScreen.kt @@ -36,6 +36,7 @@ import com.greenart7c3.nostrsigner.Amber import com.greenart7c3.nostrsigner.BuildFlavorChecker import com.greenart7c3.nostrsigner.LocalPreferences import com.greenart7c3.nostrsigner.R +import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.TrustScoreService import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.Dispatchers @@ -72,11 +73,7 @@ fun AuthWhitelistScreen( fun addEntry() { val input = textFieldRelay.value.text.trim() - val url = try { - java.net.URI(input).host ?: input - } catch (e: Exception) { - input - } + val url = RelayUrlUtils.extractHostAndPort(input) if (url.isNotBlank() && url !in whitelist) { whitelist.add(url) Amber.instance.settings = Amber.instance.settings.copy(authWhitelist = whitelist.toList()) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt index 31dc7a9d..dfff13af 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerMultiEventHomeScreen.kt @@ -53,6 +53,7 @@ import com.greenart7c3.nostrsigner.service.ApplicationNameCache import com.greenart7c3.nostrsigner.service.BunkerRequestUtils import com.greenart7c3.nostrsigner.service.EventNotificationConsumer import com.greenart7c3.nostrsigner.service.MultiEventScreenIntents +import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.model.AmberEvent import com.greenart7c3.nostrsigner.service.toShortenHex import com.greenart7c3.nostrsigner.ui.RememberType @@ -260,15 +261,7 @@ fun BunkerMultiEventHomeScreen( if (relayAuthScope == RelayAuthScope.ALL) { "*" } else { - ( - AmberEvent.relay(request.request.event)?.let { url -> - try { - java.net.URI(url).host ?: url - } catch (e: Exception) { - url - } - } ?: "" - ) + RelayUrlUtils.extractHostAndPort(AmberEvent.relay(request.request.event)) } } else { "" @@ -363,15 +356,7 @@ fun BunkerMultiEventHomeScreen( if (relayAuthScope == RelayAuthScope.ALL) { "*" } else { - ( - AmberEvent.relay(localEvent)?.let { url -> - try { - java.net.URI(url).host ?: url - } catch (e: Exception) { - url - } - } ?: "" - ) + RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent)) } } else { "" diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt index b756cbc9..0f775b12 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/BunkerSingleEventHomeScreen.kt @@ -37,6 +37,7 @@ import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.kindToNip import com.greenart7c3.nostrsigner.models.toPermissionType import com.greenart7c3.nostrsigner.service.BunkerRequestUtils +import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.isPrivateEvent import com.greenart7c3.nostrsigner.service.model.AmberEvent import com.greenart7c3.nostrsigner.service.toShortenHex @@ -662,13 +663,7 @@ fun BunkerSingleEventHomeScreen( } else if (event.kind == 22242) { // Kind 22242 = relay client authentication (NIP-42) // Permission is per-relay hostname extracted from the event's "relay" tag - val relayUrl = AmberEvent.relay(event)?.let { url -> - try { - java.net.URI(url).host ?: url - } catch (e: Exception) { - url - } - } ?: "" + val relayUrl = RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event)) // Check for a relay-specific permission first, then wildcard "*" (all relays) val permission = applicationEntity?.permissions?.firstOrNull { diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentMultiEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentMultiEventHomeScreen.kt index 516ebfbf..113c5c66 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentMultiEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentMultiEventHomeScreen.kt @@ -54,6 +54,7 @@ import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.TagArrayEncryptedDataKind import com.greenart7c3.nostrsigner.service.AmberUtils import com.greenart7c3.nostrsigner.service.MultiEventScreenIntents +import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.model.AmberEvent import com.greenart7c3.nostrsigner.ui.RememberType import com.greenart7c3.nostrsigner.ui.theme.orange @@ -231,15 +232,7 @@ fun IntentMultiEventHomeScreen( if (relayAuthScope == RelayAuthScope.ALL) { "*" } else { - ( - AmberEvent.relay(intentData.event)?.let { url -> - try { - java.net.URI(url).host ?: url - } catch (e: Exception) { - url - } - } ?: "" - ) + RelayUrlUtils.extractHostAndPort(AmberEvent.relay(intentData.event)) } } else { "" @@ -341,15 +334,7 @@ fun IntentMultiEventHomeScreen( if (relayAuthScope == RelayAuthScope.ALL) { "*" } else { - ( - AmberEvent.relay(localEvent)?.let { url -> - try { - java.net.URI(url).host ?: url - } catch (e: Exception) { - url - } - } ?: "" - ) + RelayUrlUtils.extractHostAndPort(AmberEvent.relay(localEvent)) } } else { "" diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt index d0c25f71..8e1e30d5 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/ui/components/IntentSingleEventHomeScreen.kt @@ -31,6 +31,7 @@ import com.greenart7c3.nostrsigner.models.SignerType import com.greenart7c3.nostrsigner.models.kindToNip import com.greenart7c3.nostrsigner.models.toPermissionType import com.greenart7c3.nostrsigner.service.IntentUtils +import com.greenart7c3.nostrsigner.service.RelayUrlUtils import com.greenart7c3.nostrsigner.service.isPrivateEvent import com.greenart7c3.nostrsigner.service.model.AmberEvent import com.greenart7c3.nostrsigner.service.toShortenHex @@ -257,13 +258,7 @@ fun IntentSingleEventHomeScreen( } else if (event.kind == 22242) { // Kind 22242 = relay client authentication (NIP-42) // Permission is per-relay hostname extracted from the event's "relay" tag - val relayUrl = AmberEvent.relay(event)?.let { url -> - try { - java.net.URI(url).host ?: url - } catch (e: Exception) { - url - } - } ?: "" + val relayUrl = RelayUrlUtils.extractHostAndPort(AmberEvent.relay(event)) // Check for relay-specific permission first, then wildcard "*" (all relays) val permission = applicationEntity?.permissions?.firstOrNull { diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/RelayUrlUtilsTest.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/RelayUrlUtilsTest.kt new file mode 100644 index 00000000..e058bd27 --- /dev/null +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/RelayUrlUtilsTest.kt @@ -0,0 +1,122 @@ +package com.greenart7c3.nostrsigner.service + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RelayUrlUtilsTest { + + // --- bare host inputs (no scheme) --- + + @Test + fun `bare hostname is returned unchanged`() { + assertEquals("relay.example.com", RelayUrlUtils.extractHostAndPort("relay.example.com")) + } + + @Test + fun `bare hostname with port is preserved`() { + // Regression: URI("relay.example.com:8080") treats the host as a scheme, + // so a naive URI.host call returns null and drops the port. The helper + // prepends "wss://" so the port is preserved. + assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort("relay.example.com:8080")) + } + + @Test + fun `bare IPv4 with port is preserved`() { + assertEquals("127.0.0.1:7777", RelayUrlUtils.extractHostAndPort("127.0.0.1:7777")) + } + + // --- inputs with scheme --- + + @Test + fun `wss URL without port returns host only`() { + assertEquals("relay.example.com", RelayUrlUtils.extractHostAndPort("wss://relay.example.com")) + } + + @Test + fun `wss URL with port returns host and port`() { + assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080")) + } + + @Test + fun `ws URL with port returns host and port`() { + assertEquals("relay.example.com:8443", RelayUrlUtils.extractHostAndPort("ws://relay.example.com:8443")) + } + + @Test + fun `https URL with port returns host and port`() { + assertEquals("relay.example.com:443", RelayUrlUtils.extractHostAndPort("https://relay.example.com:443")) + } + + @Test + fun `trailing slash is stripped`() { + assertEquals("relay.example.com", RelayUrlUtils.extractHostAndPort("wss://relay.example.com/")) + } + + @Test + fun `trailing slash on host with port is stripped`() { + assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080/")) + } + + @Test + fun `surrounding whitespace is trimmed`() { + assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort(" wss://relay.example.com:8080 ")) + } + + @Test + fun `path after host is ignored`() { + assertEquals("relay.example.com:8080", RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080/some/path")) + } + + // --- equivalence: the whitelist check (uses `in`) is essentially a string compare --- + + @Test + fun `bare host-port and wss URL with same port normalize to same value`() { + // This is the core whitelist regression: a user adds "relay.example.com:8080" + // to the whitelist; the event's "relay" tag is "wss://relay.example.com:8080". + // Both must normalize to the same string for `relayHost in authWhitelist` to pass. + val whitelistEntry = RelayUrlUtils.extractHostAndPort("relay.example.com:8080") + val eventTag = RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080") + assertEquals(whitelistEntry, eventTag) + } + + @Test + fun `different ports on the same host do not collide`() { + val portA = RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080") + val portB = RelayUrlUtils.extractHostAndPort("wss://relay.example.com:9090") + assertEquals("relay.example.com:8080", portA) + assertEquals("relay.example.com:9090", portB) + } + + @Test + fun `host with port is distinct from same host without port`() { + val withPort = RelayUrlUtils.extractHostAndPort("wss://relay.example.com:8080") + val withoutPort = RelayUrlUtils.extractHostAndPort("wss://relay.example.com") + assertEquals("relay.example.com:8080", withPort) + assertEquals("relay.example.com", withoutPort) + } + + // --- edge cases --- + + @Test + fun `null input returns empty string`() { + assertEquals("", RelayUrlUtils.extractHostAndPort(null)) + } + + @Test + fun `empty input returns empty string`() { + assertEquals("", RelayUrlUtils.extractHostAndPort("")) + } + + @Test + fun `blank input returns empty string`() { + assertEquals("", RelayUrlUtils.extractHostAndPort(" ")) + } + + @Test + fun `unparseable input falls back to the trimmed original`() { + // Garbage that URI cannot parse should fall through to the trimmed input + // rather than throw. + val weird = "not a valid uri at all <<>>" + assertEquals(weird, RelayUrlUtils.extractHostAndPort(" $weird ")) + } +}