From 888b3df13a67c470db48a0d118a1870f2615c45d Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:01:10 -0500 Subject: [PATCH 1/6] Select geohash relays the way iOS selects them Both platforms use only the five relays nearest a geohash, and a message crosses platforms only if the two selections share a relay. Android and iOS read different relay files; measured Aug 11 over 6,013 points against iOS's own selection code, the two selections shared 2.3 of 5 relays on average, and about one in nine points shared none. Fetch the file iOS reads. Key each row by the host string iOS builds, which collapses the 115 hosts listed twice. Order distance ties by that key. With all three, both platforms select the same five, in order, at all 6,013 points. The bundled asset is left to the weekly job. --- .../bitchat/android/nostr/RelayDirectory.kt | 58 ++++++-- .../android/nostr/RelayDirectoryTest.kt | 134 ++++++++++++++++++ 2 files changed, 181 insertions(+), 11 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index 980b95ef..cf85bb03 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -26,7 +26,13 @@ import okhttp3.Request object RelayDirectory { private const val TAG = "RelayDirectory" - private const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv" + + // The same file iOS reads (GeoRelayDirectory.swift). Both platforms take the 5 + // nearest relays from their directory and use them without the defaults, so a + // geohash message crosses platforms only if the two selections share a relay. + // Reading different files made the selections diverge. Selecting from the same + // file, with rows keyed and ordered the same way, keeps them aligned. + internal const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv" private const val ASSET_FILE = "nostr_relays.csv" private const val DOWNLOADED_FILE = "nostr_relays_latest.csv" private const val PREFS_NAME = "relay_directory_prefs" @@ -97,14 +103,21 @@ object RelayDirectory { } val (lat, lon) = center - return snapshot - .asSequence() - .sortedBy { haversineMeters(lat, lon, it.latitude, it.longitude) } - .take(nRelays.coerceAtLeast(0)) - .map { it.url } - .toList() + return closestRelays(snapshot, lat, lon, nRelays) } + // Distance ties are the directory's common case, not an edge: rows are geocoded + // to city centroids, so whole groups of relays sit at one exact coordinate. iOS + // breaks ties by host so every device with the same directory picks the same set + // (GeoRelayDirectory.closestRelays). Urls here are canonical hosts behind a fixed + // prefix, so ordering by url reproduces iOS's order. + internal fun closestRelays(entries: List, lat: Double, lon: Double, nRelays: Int): List = + entries + .map { it to haversineMeters(lat, lon, it.latitude, it.longitude) } + .sortedWith(compareBy({ it.second }, { it.first.url })) + .take(nRelays.coerceAtLeast(0)) + .map { it.first.url } + private fun haversineMeters(lat1: Double, lon1: Double, lat2: Double, lon2: Double): Double { val R = 6371000.0 // meters val dLat = Math.toRadians(lat2 - lat1) @@ -255,8 +268,12 @@ object RelayDirectory { Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash") } - private fun parseCsv(input: InputStream): List { + internal fun parseCsv(input: InputStream): List { val result = mutableListOf() + // The directory lists some relays twice, once bare and once with an explicit + // :443, which is the same server over wss. Without this check both copies can + // land in a nearest-N selection, and one of its slots connects nowhere new. + val seenEndpoints = HashSet() BufferedReader(InputStreamReader(input)).use { reader -> var line: String? while (true) { @@ -267,16 +284,35 @@ object RelayDirectory { if (trimmed.lowercase().startsWith("relay url")) continue val parts = trimmed.split(",") if (parts.size < 3) continue - val url = normalizeRelayUrl(parts[0].trim()) + val raw = normalizeRelayUrl(parts[0].trim()) val lat = parts[1].trim().toDoubleOrNull() val lon = parts[2].trim().toDoubleOrNull() - if (url.isEmpty() || lat == null || lon == null) continue - result.add(RelayInfo(url = url, latitude = lat, longitude = lon)) + if (raw.isEmpty() || lat == null || lon == null) continue + val canonical = canonicalHost(raw) + if (canonical.isEmpty() || !seenEndpoints.add(canonical)) continue + result.add(RelayInfo(url = "wss://$canonical", latitude = lat, longitude = lon)) } } return result } + /** + * The host string iOS builds for the same row (GeoRelayDirectory's + * validatedDirectoryAddress): host lowercased, an explicit port kept unless it is + * 443, the wss default. A relay on :8443 stays distinct from one on :443. Dedup + * and tie ordering both key on this, which is what keeps the two platforms' + * selections aligned row for row. + */ + internal fun canonicalHost(url: String): String { + val hostPort = url.substringAfter("://").substringBefore("/") + val idx = hostPort.lastIndexOf(':') + val hasPort = idx > 0 && idx < hostPort.length - 1 && + hostPort.substring(idx + 1).all { it.isDigit() } + val host = (if (hasPort) hostPort.substring(0, idx) else hostPort).lowercase() + val port = if (hasPort) hostPort.substring(idx + 1).toInt() else 443 + return if (port == 443) host else "$host:$port" + } + private fun fileSha256Hex(file: File): String = try { FileInputStream(file).use { input -> streamSha256Hex(input) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt new file mode 100644 index 00000000..c94b578b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt @@ -0,0 +1,134 @@ +package com.bitchat.android.nostr + +import java.io.ByteArrayInputStream +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins the three properties cross-platform geohash delivery depends on. Both platforms + * take the 5 relays nearest to a geohash and use them without the default relays, so a + * geohash message crosses platforms only if the two selections intersect. + * + * 1. Android reads the SAME directory file iOS reads. + * 2. Rows are deduplicated and emitted by the same key iOS builds: host lowercased, + * an explicit port kept unless it is 443. The directory lists many relays twice, + * once bare and once as host:443, and both forms are one server over wss. + * 3. Distance ties order by that key, the way iOS orders them. Rows are geocoded to + * city centroids, so whole tie groups sit at one coordinate and tie order decides + * most selections. + */ +class RelayDirectoryTest { + + private fun parse(csv: String) = + RelayDirectory.parseCsv(ByteArrayInputStream(csv.toByteArray())) + + private val header = "Relay URL,Latitude,Longitude\n" + + @Test + fun `fetch url is the file ios reads`() { + assertEquals( + "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv", + RelayDirectory.ASSET_FILE_URL + ) + } + + @Test + fun `canonical host matches the key ios builds`() { + assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://relay.example.com")) + assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://relay.example.com:443")) + assertEquals("relay.example.com:8443", RelayDirectory.canonicalHost("wss://relay.example.com:8443")) + assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://Relay.Example.Com/")) + } + + @Test + fun `port 443 variant of a host is the same server and is not listed twice`() { + val entries = parse( + header + + "relay.example.com,10.0,20.0\n" + + "relay.example.com:443,10.0,20.0\n" + ) + assertEquals(1, entries.size) + assertEquals("wss://relay.example.com", entries[0].url) + } + + @Test + fun `a nonstandard port is a different server and stays`() { + // Real case from the live directory: bendernostur.duckdns.org is listed bare + // and on 8443. iOS keeps both too; it drops only an explicit 443. + val entries = parse( + header + + "bendernostur.duckdns.org,1.0,1.0\n" + + "bendernostur.duckdns.org:8443,1.0,1.0\n" + ) + assertEquals(2, entries.size) + assertEquals("wss://bendernostur.duckdns.org", entries[0].url) + assertEquals("wss://bendernostur.duckdns.org:8443", entries[1].url) + } + + @Test + fun `first row wins when an endpoint is listed twice`() { + val entries = parse( + header + + "relay.example.com,10.0,20.0\n" + + "relay.example.com:443,50.0,60.0\n" + ) + assertEquals(1, entries.size) + assertEquals(10.0, entries[0].latitude, 0.0) + } + + @Test + fun `host case does not create a second endpoint`() { + val entries = parse( + header + + "Relay.Example.Com:443,10.0,20.0\n" + + "relay.example.com,10.0,20.0\n" + ) + assertEquals(1, entries.size) + assertEquals("wss://relay.example.com", entries[0].url) + } + + @Test + fun `distance ties order by host the way ios orders them`() { + // The directory's dominant shape: a whole tie group at one city centroid + // (the live file has 137 rows at a single coordinate). File order is + // deliberately not alphabetical; the selection must not depend on it. + val csv = header + + "delta.example.com,43.6532,-79.3832\n" + + "foxtrot.example.com,43.6532,-79.3832\n" + + "alpha.example.com:443,43.6532,-79.3832\n" + + "echo.example.com,43.6532,-79.3832\n" + + "bravo.example.com,43.6532,-79.3832\n" + + "charlie.example.com,43.6532,-79.3832\n" + val five = RelayDirectory.closestRelays(parse(csv), 43.6532, -79.3832, 5) + assertEquals( + listOf( + "wss://alpha.example.com", + "wss://bravo.example.com", + "wss://charlie.example.com", + "wss://delta.example.com", + "wss://echo.example.com" + ), + five + ) + } + + @Test + fun `five nearest means five distinct servers`() { + // The shape measured for London on Aug 11 2026: the nearest relay listed twice + // (bare and :443), which used to occupy two of the five selection slots and + // push out the fifth distinct server. + val csv = header + + "nearest.example.com,51.50,-0.12\n" + + "nearest.example.com:443,51.50,-0.12\n" + + "second.example.com,51.60,-0.10\n" + + "third.example.com,51.70,-0.10\n" + + "fourth.example.com,51.80,-0.10\n" + + "fifth.example.com,51.90,-0.10\n" + + "faraway.example.com,40.0,30.0\n" + val five = RelayDirectory.closestRelays(parse(csv), 51.5074, -0.1278, 5) + assertEquals(5, five.size) + assertEquals("every selected relay is a distinct server", 5, five.toSet().size) + assertTrue("the fifth distinct server makes the cut", five.contains("wss://fifth.example.com")) + } +} From 43ce034bcd412ad5cd5dc6d3e2be0f22b7dee7dc Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:01:10 -0500 Subject: [PATCH 2/6] Use synthetic fixtures and record the selection contract Review follow-up from the automated pass. The selection tests carried a real relay hostname and real city coordinates; AGENTS.md requires synthetic, non-identifying fixtures. Both are replaced with clearly synthetic values that keep the same shapes: the port-variant pair, the one-coordinate tie group, and the duplicated nearest relay that used to displace the fifth server. The selection rules are also now recorded in docs/client-rewrite-contracts.md, since agreeing clients are the point of the change: same source file, same host key, same tie order. Behavior is unchanged; RelayDirectoryTest covers it. --- .../android/nostr/RelayDirectoryTest.kt | 48 +++++++++---------- docs/client-rewrite-contracts.md | 9 ++++ 2 files changed, 33 insertions(+), 24 deletions(-) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt index c94b578b..d5541d91 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt @@ -54,16 +54,16 @@ class RelayDirectoryTest { @Test fun `a nonstandard port is a different server and stays`() { - // Real case from the live directory: bendernostur.duckdns.org is listed bare - // and on 8443. iOS keeps both too; it drops only an explicit 443. + // The live directory lists some relays bare and on a nonstandard port. + // iOS keeps both too; it drops only an explicit 443. val entries = parse( header + - "bendernostur.duckdns.org,1.0,1.0\n" + - "bendernostur.duckdns.org:8443,1.0,1.0\n" + "port-variant.relay.example,1.0,1.0\n" + + "port-variant.relay.example:8443,1.0,1.0\n" ) assertEquals(2, entries.size) - assertEquals("wss://bendernostur.duckdns.org", entries[0].url) - assertEquals("wss://bendernostur.duckdns.org:8443", entries[1].url) + assertEquals("wss://port-variant.relay.example", entries[0].url) + assertEquals("wss://port-variant.relay.example:8443", entries[1].url) } @Test @@ -90,17 +90,17 @@ class RelayDirectoryTest { @Test fun `distance ties order by host the way ios orders them`() { - // The directory's dominant shape: a whole tie group at one city centroid - // (the live file has 137 rows at a single coordinate). File order is + // The directory's dominant shape: a whole tie group at one shared coordinate + // (the live file has 137 rows at a single point). File order is // deliberately not alphabetical; the selection must not depend on it. val csv = header + - "delta.example.com,43.6532,-79.3832\n" + - "foxtrot.example.com,43.6532,-79.3832\n" + - "alpha.example.com:443,43.6532,-79.3832\n" + - "echo.example.com,43.6532,-79.3832\n" + - "bravo.example.com,43.6532,-79.3832\n" + - "charlie.example.com,43.6532,-79.3832\n" - val five = RelayDirectory.closestRelays(parse(csv), 43.6532, -79.3832, 5) + "delta.example.com,12.34,56.78\n" + + "foxtrot.example.com,12.34,56.78\n" + + "alpha.example.com:443,12.34,56.78\n" + + "echo.example.com,12.34,56.78\n" + + "bravo.example.com,12.34,56.78\n" + + "charlie.example.com,12.34,56.78\n" + val five = RelayDirectory.closestRelays(parse(csv), 12.34, 56.78, 5) assertEquals( listOf( "wss://alpha.example.com", @@ -115,18 +115,18 @@ class RelayDirectoryTest { @Test fun `five nearest means five distinct servers`() { - // The shape measured for London on Aug 11 2026: the nearest relay listed twice + // A shape the live directory produces: the nearest relay listed twice // (bare and :443), which used to occupy two of the five selection slots and // push out the fifth distinct server. val csv = header + - "nearest.example.com,51.50,-0.12\n" + - "nearest.example.com:443,51.50,-0.12\n" + - "second.example.com,51.60,-0.10\n" + - "third.example.com,51.70,-0.10\n" + - "fourth.example.com,51.80,-0.10\n" + - "fifth.example.com,51.90,-0.10\n" + - "faraway.example.com,40.0,30.0\n" - val five = RelayDirectory.closestRelays(parse(csv), 51.5074, -0.1278, 5) + "nearest.example.com,10.10,20.20\n" + + "nearest.example.com:443,10.10,20.20\n" + + "second.example.com,10.20,20.20\n" + + "third.example.com,10.30,20.20\n" + + "fourth.example.com,10.40,20.20\n" + + "fifth.example.com,10.50,20.20\n" + + "faraway.example.com,80.0,120.0\n" + val five = RelayDirectory.closestRelays(parse(csv), 10.10, 20.20, 5) assertEquals(5, five.size) assertEquals("every selected relay is a distinct server", 5, five.toSet().size) assertTrue("the fifth distinct server makes the cut", five.contains("wss://fifth.example.com")) diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 05d5e1ad..f37809f0 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -36,6 +36,15 @@ randomization used by senders. Android caps outbound seal and gift-wrap randomization at 22h, leaving 2 hours of slack inside iOS's 24-hour subscription window, while retaining its 48-hour receive lookback. +Geohash relay selection is part of the cross-client contract. Both platforms +read `relays/online_relays_gps.csv` from the bitchat repo, key each row by its +host string (lowercased, an explicit port kept unless it is 443, the wss +default), deduplicate by that key, and order candidates by distance with ties +broken by the same key. Clients that select differently can end up on disjoint +relay sets for the same geohash and silently fail to exchange messages. +`RelayDirectoryTest` covers the Android side; iOS implements the same rules in +`GeoRelayDirectory`. + ## Rewrite acceptance gate From a configured Android development environment, run: From 54a0b4dd4546e6a1e6ff0bcd1bd6280994ad0b40 Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:01:10 -0500 Subject: [PATCH 3/6] Record the source of the bundled and cached relay files Review follow-up on the naming: the bundled asset and the download cache kept their old file names when the fetch moved to online_relays_gps.csv. A comment now records where both come from; the names then do not send anyone looking for a georelays fetch that is gone. --- app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index cf85bb03..fd7d5d2a 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -33,6 +33,8 @@ object RelayDirectory { // Reading different files made the selections diverge. Selecting from the same // file, with rows keyed and ordered the same way, keeps them aligned. internal const val ASSET_FILE_URL = "https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv" + // Download cache of ASSET_FILE_URL above, and the bundled list the weekly job + // refreshes from it; the file names predate the source's move to online_relays_gps.csv. private const val ASSET_FILE = "nostr_relays.csv" private const val DOWNLOADED_FILE = "nostr_relays_latest.csv" private const val PREFS_NAME = "relay_directory_prefs" From 369b047fc45aadccc8b3384ee52ef4986a52c12a Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:01:10 -0500 Subject: [PATCH 4/6] Fetch the weekly relay update from the file the app reads fetch-georelays.yml downloads the relay list from the georelays repo every week and pushes it straight to main. This branch moves the app's fetch to online_relays_gps.csv, and a job still reading the old source would keep the bundled asset on a different list from the one the app downloads. Pointed at online_relays_gps.csv, the job keeps the bundled asset on the app's source. When the file has not changed, the job downloads the same file and pushes nothing; when it changes, the asset follows. The asset itself is not changed in this branch. --- .github/workflows/fetch-georelays.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/fetch-georelays.yml b/.github/workflows/fetch-georelays.yml index 8f91c53d..f2300bd0 100644 --- a/.github/workflows/fetch-georelays.yml +++ b/.github/workflows/fetch-georelays.yml @@ -19,9 +19,11 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} - name: Fetch GeoRelays + # Must match RelayDirectory.ASSET_FILE_URL, the file the app fetches at + # runtime; the bundled asset is a snapshot of the same file. run: | - wget https://raw.githubusercontent.com/permissionlesstech/georelays/refs/heads/main/nostr_relays.csv - mv nostr_relays.csv ./app/src/main/assets/nostr_relays.csv + wget https://raw.githubusercontent.com/permissionlesstech/bitchat/refs/heads/main/relays/online_relays_gps.csv + mv online_relays_gps.csv ./app/src/main/assets/nostr_relays.csv - name: Check for changes id: git-check From 46f84b61f091b267663100a57db7bc147a46801b Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:56:21 -0500 Subject: [PATCH 5/6] Validate relay directories with iOS's rules #914 aligned the file both platforms read and how rows are keyed and ordered; the acceptance rules still differed. iOS rejects a whole directory on one malformed or conflicting row, validates the header, caps size and rows, and screens every host. This client skipped bad rows and accepted almost any host. The bitchat repo validates the file before it lands; this port bounds what the client accepts as well. parseCsv and canonicalHost are replaced by a port of GeoRelayDirectory.validatedEntries and validatedDirectoryAddress. A rejected download keeps the current list; one that keeps less than half of the known entries is rejected. Five details follow iOS's compiled validator rather than a plain reading of the Swift, each with a test. The cache refetches at startup when its source URL changed. Cross-checked against the Swift validator on 84 fixture runs and 100,000 fuzzed inputs. Full suite, lint and build pass. --- .../bitchat/android/nostr/RelayDirectory.kt | 353 +++++++++++++++--- .../RelayDirectoryCacheInvalidationTest.kt | 82 ++++ .../android/nostr/RelayDirectoryTest.kt | 37 +- .../nostr/RelayDirectoryValidationTest.kt | 233 ++++++++++++ docs/client-rewrite-contracts.md | 9 + 5 files changed, 649 insertions(+), 65 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryCacheInvalidationTest.kt create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt index fd7d5d2a..938b39bc 100644 --- a/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt +++ b/app/src/main/java/com/bitchat/android/nostr/RelayDirectory.kt @@ -3,12 +3,10 @@ package com.bitchat.android.nostr import android.app.Application import android.content.SharedPreferences import android.util.Log -import java.io.BufferedReader import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream -import java.io.InputStreamReader import java.security.MessageDigest import java.util.concurrent.TimeUnit import kotlin.math.* @@ -39,8 +37,20 @@ object RelayDirectory { private const val DOWNLOADED_FILE = "nostr_relays_latest.csv" private const val PREFS_NAME = "relay_directory_prefs" private const val KEY_LAST_UPDATE_MS = "last_update_ms" + private const val KEY_SOURCE_URL = "source_url" private val ONE_DAY_MS = TimeUnit.DAYS.toMillis(1) + // GeoRelayDirectoryValidationPolicy.live, ported verbatim. The directory is an + // unsigned third-party file (docs/security-review-jul-27.md M9); iOS bounds what + // it will accept from it and rejects the rest, and both platforms must bound it + // the same way or a file one side accepts and the other rejects splits their + // relay selections at every geohash at once. + internal const val MAX_DIRECTORY_BYTES = 512 * 1024 + internal const val MAX_DIRECTORY_ROWS = 5_000 + internal const val MAX_DIRECTORY_ENTRIES = 5_000 + internal const val MIN_REMOTE_ENTRIES = 50 + internal const val MIN_RETAINED_FRACTION = 0.5 + private val ioScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val httpClient: OkHttpClient get() = com.bitchat.android.net.OkHttpProvider.httpClient() @@ -63,6 +73,7 @@ object RelayDirectory { if (initialized) return try { val downloaded = getDownloadedFile(application) + invalidateCacheIfSourceChanged(getPrefs(application), downloaded) val loadedFromDownloaded = if (downloaded.exists() && downloaded.canRead()) { loadFromFile(downloaded, sourceLabel = "downloaded") } else { @@ -129,17 +140,26 @@ object RelayDirectory { return R * c } - private fun normalizeRelayUrl(raw: String): String { - val trimmed = raw.trim() - if (trimmed.isEmpty()) return trimmed - return if ("://" in trimmed) trimmed else "wss://$trimmed" - } - // ===== Implementation details ===== private fun getPrefs(application: Application): SharedPreferences = application.getSharedPreferences(PREFS_NAME, Application.MODE_PRIVATE) + /** + * An install upgraded across the source move still holds a cache fetched from + * the old URL. Drop it and clear the update stamp, and the staleness check + * refetches, rather than keep selecting from a file the current source no + * longer matches. Returns whether a cache was dropped. + */ + internal fun invalidateCacheIfSourceChanged(prefs: SharedPreferences, downloaded: File): Boolean { + if (!downloaded.exists()) return false + if (prefs.getString(KEY_SOURCE_URL, null) == ASSET_FILE_URL) return false + downloaded.delete() + prefs.edit().remove(KEY_LAST_UPDATE_MS).apply() + Log.i(TAG, "Dropped cached relay list fetched from a previous source URL") + return true + } + private fun getDownloadedFile(application: Application): File = File(application.filesDir, DOWNLOADED_FILE) @@ -174,9 +194,17 @@ object RelayDirectory { return } - val parsed = parseCsv(FileInputStream(tmpFile)) - if (parsed.isEmpty()) { - Log.w(TAG, "Downloaded relay CSV parsed to 0 entries; ignoring") + if (tmpFile.length() > MAX_DIRECTORY_BYTES) { + Log.w(TAG, "Downloaded relay CSV exceeds $MAX_DIRECTORY_BYTES bytes; keeping current list") + tmpFile.delete() + return + } + // The current directory is the baseline: a rejected download keeps it, + // in memory and on disk, the way iOS keeps its previous copy. + val baseline = synchronized(relaysLock) { relays.toSet() } + val parsed = validatedEntries(tmpFile.readBytes(), MIN_REMOTE_ENTRIES, baseline) + if (parsed == null) { + Log.w(TAG, "Downloaded relay CSV failed validation; keeping current list") tmpFile.delete() return } @@ -197,7 +225,10 @@ object RelayDirectory { relays.addAll(parsed) } - getPrefs(application).edit().putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis()).apply() + getPrefs(application).edit() + .putLong(KEY_LAST_UPDATE_MS, System.currentTimeMillis()) + .putString(KEY_SOURCE_URL, ASSET_FILE_URL) + .apply() Log.i(TAG, "✅ Using downloaded relay list (${dest.absolutePath}), entries=$entries, sha256=$hash, updatedAtMs=${getPrefs(application).getLong(KEY_LAST_UPDATE_MS, 0L)}") } catch (e: Exception) { @@ -214,9 +245,24 @@ object RelayDirectory { return false } val body = resp.body ?: return false + if (body.contentLength() > MAX_DIRECTORY_BYTES) { + Log.w(TAG, "Relay CSV content length exceeds $MAX_DIRECTORY_BYTES bytes; aborting") + return false + } FileOutputStream(dest).use { out -> body.byteStream().use { input -> - input.copyTo(out) + val buf = ByteArray(8192) + var total = 0L + while (true) { + val read = input.read(buf) + if (read <= 0) break + total += read + if (total > MAX_DIRECTORY_BYTES.toLong()) { + Log.w(TAG, "Relay CSV download exceeded $MAX_DIRECTORY_BYTES bytes; aborting") + return false + } + out.write(buf, 0, read) + } } } true @@ -229,9 +275,9 @@ object RelayDirectory { private fun loadFromFile(file: File, sourceLabel: String): Boolean { return try { - val list = parseCsv(FileInputStream(file)) - if (list.isEmpty()) { - Log.w(TAG, "${sourceLabel} relay CSV has 0 entries; ignoring") + val list = validatedEntries(file.readBytes(), minimumEntries = 1) + if (list == null) { + Log.w(TAG, "${sourceLabel} relay CSV failed validation; ignoring it") false } else { synchronized(relaysLock) { @@ -250,7 +296,11 @@ object RelayDirectory { private fun loadFromAssets(application: Application) { val list = try { - parseCsv(application.assets.open(ASSET_FILE)) + val bytes = application.assets.open(ASSET_FILE).use { it.readBytes() } + validatedEntries(bytes, minimumEntries = 1) ?: run { + Log.e(TAG, "Bundled asset $ASSET_FILE failed validation") + emptyList() + } } catch (e: Exception) { Log.e(TAG, "Failed to open asset $ASSET_FILE: ${e.message}") emptyList() @@ -270,49 +320,248 @@ object RelayDirectory { Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash") } - internal fun parseCsv(input: InputStream): List { - val result = mutableListOf() - // The directory lists some relays twice, once bare and once with an explicit - // :443, which is the same server over wss. Without this check both copies can - // land in a nearest-N selection, and one of its slots connects nowhere new. - val seenEndpoints = HashSet() - BufferedReader(InputStreamReader(input)).use { reader -> - var line: String? - while (true) { - line = reader.readLine() - if (line == null) break - val trimmed = line!!.trim() - if (trimmed.isEmpty()) continue - if (trimmed.lowercase().startsWith("relay url")) continue - val parts = trimmed.split(",") - if (parts.size < 3) continue - val raw = normalizeRelayUrl(parts[0].trim()) - val lat = parts[1].trim().toDoubleOrNull() - val lon = parts[2].trim().toDoubleOrNull() - if (raw.isEmpty() || lat == null || lon == null) continue - val canonical = canonicalHost(raw) - if (canonical.isEmpty() || !seenEndpoints.add(canonical)) continue - result.add(RelayInfo(url = "wss://$canonical", latitude = lat, longitude = lon)) + /** + * GeoRelayDirectory.validatedEntries, ported in full. One malformed or + * conflicting row rejects the complete dataset, and the caller keeps whatever + * directory it already has: a partial parse would leave this client selecting + * from a different row set than iOS, the failure #914 exists to close. Returns + * null when the data is rejected. + * + * baselineEntries carries the current directory when validating a download. A + * new file that keeps less than half of the known entries is rejected even when + * well formed, matching iOS: a hijacked or truncated upstream cannot swap the + * whole relay population in one fetch. + */ + internal fun validatedEntries( + data: ByteArray, + minimumEntries: Int, + baselineEntries: Set? = null + ): List? { + if (data.isEmpty() || data.size > MAX_DIRECTORY_BYTES) return null + var text = decodeUtf8Strict(data) ?: return null + // Foundation's UTF-8 decode strips one leading BOM before iOS's own BOM + // check runs, so a single BOM passes on iOS and only a doubled one is + // rejected. Mirror that exactly (verified against the real Swift code). + if (text.startsWith('\uFEFF')) text = text.substring(1) + if (text.startsWith('\uFEFF')) return null + + val lines = text + .split('\u000A', '\u000B', '\u000C', '\u000D', '\u0085', '\u2028', '\u2029') + .map { it.trim() } + .filter { it.isNotEmpty() } + val header = lines.firstOrNull() ?: return null + if (lines.size - 1 > MAX_DIRECTORY_ROWS) return null + + val headerParts = header.split(",").map { it.trim().lowercase() } + val supportedHeaders = listOf( + listOf("relay url", "latitude", "longitude"), + listOf("relay url", "lat", "lon") + ) + if (headerParts !in supportedHeaders) return null + + val entriesByHost = LinkedHashMap() + for (line in lines.drop(1)) { + val parts = line.split(",").map { it.trim() } + if (parts.size != 3) return null + val host = validatedDirectoryAddress(parts[0]) ?: return null + val latitude = parseCoordinate(parts[1]) ?: return null + if (latitude !in -90.0..90.0) return null + val longitude = parseCoordinate(parts[2]) ?: return null + if (longitude !in -180.0..180.0) return null + + val entry = RelayInfo(url = "wss://$host", latitude = latitude, longitude = longitude) + val existing = entriesByHost[host] + // One endpoint cannot truthfully occupy two coordinates. Matching iOS, + // row order does not get to choose which location clients trust. The + // comparison is IEEE equality, not equals(): Swift's == calls -0.0 and + // 0.0 the same coordinate, and the last equal row's value is kept, raw + // bits included, the way Swift dictionary assignment keeps it. + if (existing != null && !sameEntry(existing, entry)) { + return null } + entriesByHost[host] = entry + if (entriesByHost.size > MAX_DIRECTORY_ENTRIES) return null } - return result + + val parsed = entriesByHost.values.toList() + if (parsed.size < minimumEntries) return null + + if (baselineEntries != null) { + val required = ceil(baselineEntries.size * MIN_RETAINED_FRACTION).toInt() + val overlap = parsed.count { p -> baselineEntries.any { b -> sameEntry(p, b) } } + if (overlap < required) return null + } + + return parsed.sortedWith(compareBy({ it.url }, { it.latitude }, { it.longitude })) } /** - * The host string iOS builds for the same row (GeoRelayDirectory's - * validatedDirectoryAddress): host lowercased, an explicit port kept unless it is - * 443, the wss default. A relay on :8443 stays distinct from one on :443. Dedup + * GeoRelayDirectory.validatedDirectoryAddress, ported in full: the host + * key both platforms build for a row, or null when the address is one the + * directory must not carry (non-ASCII, credentials, paths, queries, local and + * internal names, malformed labels, out-of-range ports). An explicit port stays + * in the key unless it is 443, the wss default, which keeps a relay on :8443 + * distinct and collapses the directory's bare and :443 duplicate rows. Dedup * and tie ordering both key on this, which is what keeps the two platforms' * selections aligned row for row. */ - internal fun canonicalHost(url: String): String { - val hostPort = url.substringAfter("://").substringBefore("/") - val idx = hostPort.lastIndexOf(':') - val hasPort = idx > 0 && idx < hostPort.length - 1 && - hostPort.substring(idx + 1).all { it.isDigit() } - val host = (if (hasPort) hostPort.substring(0, idx) else hostPort).lowercase() - val port = if (hasPort) hostPort.substring(idx + 1).toInt() else 443 - return if (port == 443) host else "$host:$port" + internal fun validatedDirectoryAddress(rawValue: String): String? { + val value = rawValue.trim() + if (value.isEmpty()) return null + if (!value.all { it.code in 0x20..0x7E }) return null + + val encoded = if ("://" in value) value else "wss://$value" + // URLComponents percent-decodes before iOS's checks run, so re%6Cay.example + // is relay.example to iOS; java.net.URI does not decode. Decode the same + // way first, and reject invalid escapes the way URLComponents rejects them. + val candidate = percentDecodedOrNull(encoded) ?: return null + val uri = try { java.net.URI(candidate) } catch (_: Exception) { return null } + val scheme = uri.scheme?.lowercase() ?: return null + if (scheme != "wss" && scheme != "https") return null + if (uri.userInfo != null) return null + if (uri.query != null) return null + if (uri.fragment != null) return null + val path = uri.path ?: "" + if (path.isNotEmpty() && path != "/") return null + // java.net.URI follows RFC 2396, which requires the final host label to + // start with a letter, and returns no host for names like b.08obllot + // that iOS's RFC 3986 parser accepts. When URI refuses only for that + // reason, a plain hostname[:port] authority is taken as the host and + // the screens below judge it; fuzzing found this as the dominant + // divergence class (Android rejecting what iOS accepts). + val rawHost = uri.host + ?: plainAuthorityHost(candidate) + ?: return null + + val host = rawHost.lowercase() + if (host.isEmpty() || host.length > 253) return null + if (!host.all { it.code <= 0x7F }) return null + if (host.endsWith(".")) return null + if (host == "localhost" || host.endsWith(".localhost") || + host.endsWith(".local") || host.endsWith(".internal")) return null + + val labels = host.split(".") + if (labels.size < 2) return null + // URLComponents IDNA-decodes xn-- labels: valid punycode decodes to + // non-ASCII and fails iOS's screen, invalid punycode fails its parse, and + // both reject the address (verified against the real validator). java.net + // URI passes the label through, so the label form itself is refused here. + if (labels.any { it.startsWith("xn--") }) return null + if (labels.all { label -> label.all { it.isDigit() } }) return null + if (!labels.all { label -> + label.length in 1..63 && label.first() != '-' && label.last() != '-' && + label.all { it in 'a'..'z' || it in '0'..'9' || it == '-' } + } + ) return null + + val port = if (uri.host != null) uri.port else plainAuthorityPort(candidate) + if (port != -1) { + if (port !in 1..65535) return null + if (port != 443) return "$host:$port" + } + return host + } + + + // The authority of the candidate when it is nothing but hostname[:port] in + // the host charset. Anything with userinfo, brackets, escapes, or other + // structure stays with java.net.URI's verdict. + private fun plainAuthority(candidate: String): Pair? { + val afterScheme = candidate.substringAfter("://", "") + if (afterScheme.isEmpty()) return null + val authority = afterScheme.takeWhile { it != '/' && it != '?' && it != '#' } + if (authority.length != afterScheme.length) { + val rest = afterScheme.substring(authority.length) + if (rest != "/") return null + } + val colon = authority.lastIndexOf(':') + val hostPart: String + val port: Int + if (colon >= 0) { + val portPart = authority.substring(colon + 1) + // RFC 3986 allows an empty port ("host:"), and Foundation treats it + // as no port at all. + if (portPart.isNotEmpty() && !portPart.all { it in '0'..'9' }) return null + hostPart = authority.substring(0, colon) + port = if (portPart.isEmpty()) -1 else portPart.toIntOrNull() ?: return null + } else { + hostPart = authority + port = -1 + } + if (hostPart.isEmpty()) return null + if (!hostPart.all { it in 'a'..'z' || it in 'A'..'Z' || it in '0'..'9' || it == '.' || it == '-' }) return null + return hostPart to port + } + + private fun plainAuthorityHost(candidate: String): String? = plainAuthority(candidate)?.first + + private fun plainAuthorityPort(candidate: String): Int = plainAuthority(candidate)?.second ?: -1 + + // IEEE equality on the coordinates: -0.0 equals 0.0 here, as it does in the + // Swift Entry's ==, where equals() would call them different. + private fun sameEntry(a: RelayInfo, b: RelayInfo): Boolean = + a.url == b.url && a.latitude == b.latitude && a.longitude == b.longitude + + // Strict %XX decoding with UTF-8 byte semantics and no '+' handling. Returns + // null on an invalid or truncated escape, matching URLComponents. + private fun percentDecodedOrNull(value: String): String? { + if ('%' !in value) return value + val bytes = java.io.ByteArrayOutputStream(value.length) + var i = 0 + while (i < value.length) { + val c = value[i] + if (c == '%') { + if (i + 2 >= value.length) return null + val hi = Character.digit(value[i + 1], 16) + val lo = Character.digit(value[i + 2], 16) + if (hi < 0 || lo < 0) return null + val decoded = ((hi shl 4) or lo).toChar() + // URLComponents decodes AFTER structural parsing, so a decoded + // ':' stays inside the host and fails iOS's label screen; decoding + // it here first would instead create a port. Only escapes that + // decode to host-legal characters may pass (verified against the + // real validator: %6C and %2E accept, %3A and the rest reject). + if (!(decoded in 'A'..'Z' || decoded in 'a'..'z' || + decoded in '0'..'9' || decoded == '.' || decoded == '-')) return null + bytes.write(decoded.code) + i += 3 + } else { + bytes.write(c.code) + i += 1 + } + } + return decodeUtf8Strict(bytes.toByteArray()) + } + + // iOS String(data:encoding:.utf8) fails on invalid UTF-8 where Kotlin's + // String(bytes) substitutes replacement characters. Decode strictly so both + // platforms reject the same bytes. + private fun decodeUtf8Strict(data: ByteArray): String? = try { + Charsets.UTF_8.newDecoder() + .onMalformedInput(java.nio.charset.CodingErrorAction.REPORT) + .onUnmappableCharacter(java.nio.charset.CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(data)) + .toString() + } catch (_: Exception) { + null + } + + // Two Swift-vs-Java parsing differences, both verified against the real + // validator: Double.parseDouble takes trailing f/F/d/D suffixes that Swift + // rejects (and in hex those characters are digits, not suffixes), and Swift + // accepts hex like "0x10" without the binary exponent Java requires. + // Infinity and NaN spellings parse on both and fail the finite check. + private fun parseCoordinate(raw: String): Double? { + if (raw.isEmpty()) return null + val body = raw.removePrefix("+").removePrefix("-") + val isHex = body.startsWith("0x") || body.startsWith("0X") + if (!isHex) { + val last = raw.last() + if (last == 'f' || last == 'F' || last == 'd' || last == 'D') return null + } + val candidate = if (isHex && !raw.contains('p') && !raw.contains('P')) raw + "p0" else raw + val value = candidate.toDoubleOrNull() ?: return null + return if (value.isFinite()) value else null } private fun fileSha256Hex(file: File): String = try { diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryCacheInvalidationTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryCacheInvalidationTest.kt new file mode 100644 index 00000000..be6f541b --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryCacheInvalidationTest.kt @@ -0,0 +1,82 @@ +package com.bitchat.android.nostr + +import android.app.Application +import android.content.Context +import android.os.Build +import androidx.test.core.app.ApplicationProvider +import java.io.File +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * A cached directory is only as current as the URL it was fetched from. An install + * upgraded across the source move still holds a cache from the old URL; these pin + * that it is dropped and refetched instead of selecting from stale data. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [Build.VERSION_CODES.P], manifest = Config.NONE) +class RelayDirectoryCacheInvalidationTest { + + private val application: Application = ApplicationProvider.getApplicationContext() + + private fun prefs() = application.getSharedPreferences("relay_directory_prefs", Context.MODE_PRIVATE) + + private fun cacheFile(content: String = "stub"): File = + File(application.filesDir, "test_relay_cache.csv").apply { writeText(content) } + + @Test + fun `a cache with no recorded source url is dropped`() { + val cache = cacheFile() + prefs().edit().clear().putLong("last_update_ms", 123L).commit() + + val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache) + + assertTrue(dropped) + assertFalse(cache.exists()) + assertFalse(prefs().contains("last_update_ms")) + } + + @Test + fun `a cache recorded from a different source url is dropped`() { + val cache = cacheFile() + prefs().edit().clear() + .putString("source_url", "https://old.host.example/nostr_relays.csv") + .putLong("last_update_ms", 123L) + .commit() + + val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache) + + assertTrue(dropped) + assertFalse(cache.exists()) + } + + @Test + fun `a cache recorded from the current source url is kept`() { + val cache = cacheFile() + prefs().edit().clear() + .putString("source_url", RelayDirectory.ASSET_FILE_URL) + .putLong("last_update_ms", 123L) + .commit() + + val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache) + + assertFalse(dropped) + assertTrue(cache.exists()) + assertTrue(prefs().contains("last_update_ms")) + } + + @Test + fun `a missing cache changes nothing`() { + val cache = File(application.filesDir, "absent.csv") + prefs().edit().clear().putLong("last_update_ms", 123L).commit() + + val dropped = RelayDirectory.invalidateCacheIfSourceChanged(prefs(), cache) + + assertFalse(dropped) + assertTrue(prefs().contains("last_update_ms")) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt index d5541d91..e14d8a61 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt @@ -1,7 +1,7 @@ package com.bitchat.android.nostr -import java.io.ByteArrayInputStream import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -17,11 +17,17 @@ import org.junit.Test * 3. Distance ties order by that key, the way iOS orders them. Rows are geocoded to * city centroids, so whole tie groups sit at one coordinate and tie order decides * most selections. + * + * Parsing goes through the validator ported from GeoRelayDirectory.validatedEntries; + * RelayDirectoryValidationTest pins its acceptance rules. These tests parse files the + * validator accepts. */ class RelayDirectoryTest { private fun parse(csv: String) = - RelayDirectory.parseCsv(ByteArrayInputStream(csv.toByteArray())) + requireNotNull(RelayDirectory.validatedEntries(csv.toByteArray(), minimumEntries = 1)) { + "fixture unexpectedly rejected" + } private val header = "Relay URL,Latitude,Longitude\n" @@ -34,11 +40,13 @@ class RelayDirectoryTest { } @Test - fun `canonical host matches the key ios builds`() { - assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://relay.example.com")) - assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://relay.example.com:443")) - assertEquals("relay.example.com:8443", RelayDirectory.canonicalHost("wss://relay.example.com:8443")) - assertEquals("relay.example.com", RelayDirectory.canonicalHost("wss://Relay.Example.Com/")) + fun `validated addresses match the key ios builds`() { + assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com")) + assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com:443")) + assertEquals("relay.example.com:8443", RelayDirectory.validatedDirectoryAddress("wss://relay.example.com:8443")) + assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("wss://Relay.Example.Com/")) + assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("relay.example.com")) + assertEquals("relay.example.com", RelayDirectory.validatedDirectoryAddress("https://relay.example.com")) } @Test @@ -67,14 +75,17 @@ class RelayDirectoryTest { } @Test - fun `first row wins when an endpoint is listed twice`() { - val entries = parse( - header + + fun `an endpoint listed at two different coordinates rejects the file`() { + // One endpoint cannot truthfully occupy two coordinates. iOS rejects the + // whole file rather than letting row order choose which location clients + // trust; the earlier first-row-wins behavior is gone with it. + val rejected = RelayDirectory.validatedEntries( + (header + "relay.example.com,10.0,20.0\n" + - "relay.example.com:443,50.0,60.0\n" + "relay.example.com:443,50.0,60.0\n").toByteArray(), + minimumEntries = 1 ) - assertEquals(1, entries.size) - assertEquals(10.0, entries[0].latitude, 0.0) + assertNull(rejected) } @Test diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt new file mode 100644 index 00000000..52ea0c30 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt @@ -0,0 +1,233 @@ +package com.bitchat.android.nostr + +import java.io.File +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Pins the directory validation ported from GeoRelayDirectory.validatedEntries and + * validatedDirectoryAddress. The rules are part of the cross-client contract: a file + * one platform accepts and the other rejects splits the two relay selections at every + * geohash at once, which is a larger divergence than the two-file split #914 closed. + * Every rejection here is a whole-file rejection; the caller keeps its previous copy. + */ +class RelayDirectoryValidationTest { + + private val header = "Relay URL,Latitude,Longitude\n" + + private fun validate(csv: String, minimumEntries: Int = 1, baseline: Set? = null) = + RelayDirectory.validatedEntries(csv.toByteArray(), minimumEntries, baseline) + + // MARK: file-level rules + + @Test + fun `an empty file is rejected`() { + assertNull(RelayDirectory.validatedEntries(ByteArray(0), minimumEntries = 1)) + } + + @Test + fun `a file with only a header is rejected`() { + assertNull(validate(header)) + } + + @Test + fun `both header forms ios accepts parse here`() { + assertNotNull(validate("Relay URL,Latitude,Longitude\nrelay-a.example,1.0,2.0\n")) + assertNotNull(validate("relay url,lat,lon\nrelay-a.example,1.0,2.0\n")) + } + + @Test + fun `a header ios rejects rejects the file here`() { + assertNull(validate("Relay URL,Lat,Long\nrelay-a.example,1.0,2.0\n")) + assertNull(validate("url,latitude,longitude\nrelay-a.example,1.0,2.0\n")) + } + + @Test + fun `a single byte order mark is stripped the way ios strips it`() { + // Foundation's UTF-8 decode removes one leading BOM before iOS's BOM + // check runs; only a doubled BOM reaches the check. Verified against the + // real Swift code over these exact bytes. + assertNotNull(validate("" + header + "relay-a.example,1.0,2.0\n")) + assertNull(validate("" + header + "relay-a.example,1.0,2.0\n")) + } + + @Test + fun `percent escapes decode into the host key the way ios decodes them`() { + assertEquals("relay.example", RelayDirectory.validatedDirectoryAddress("re%6Cay.example")) + assertEquals("a.b.example", RelayDirectory.validatedDirectoryAddress("a%2Eb.example")) + assertNull("invalid escape", RelayDirectory.validatedDirectoryAddress("wss://h%GGx.example")) + assertNull("truncated escape", RelayDirectory.validatedDirectoryAddress("wss://hx.example%2")) + assertNull("escape decoding to a query", RelayDirectory.validatedDirectoryAddress("wss://h%3Fx.example")) + assertNull("escape decoding to non-ascii", RelayDirectory.validatedDirectoryAddress("re%C3%A9seau.example")) + // iOS decodes after structural parsing, so a decoded colon stays in the + // host and fails the label screen; decoded first it would become a port. + assertNull("escape decoding to a colon", RelayDirectory.validatedDirectoryAddress("relay-a.example%3A8443")) + } + + @Test + fun `hex coordinates parse the way swift parses them`() { + val plain = validate(header + "relay-a.example,0x10,2.0\n") + assertNotNull(plain) + assertEquals(16.0, plain!![0].latitude, 0.0) + val hexDigitTail = validate(header + "relay-a.example,0x1d,2.0\n") + assertNotNull("d is a hex digit, not a suffix", hexDigitTail) + assertEquals(29.0, hexDigitTail!![0].latitude, 0.0) + } + + @Test + fun `signed zero is one coordinate and the last row's bits are kept`() { + val entries = validate(header + "z.example,0.0,1.0\n" + "z.example,-0.0,1.0\n") + assertNotNull("swift's == treats -0.0 and 0.0 as the same coordinate", entries) + assertEquals(1, entries!!.size) + assertEquals((-0.0).toRawBits(), entries[0].latitude.toRawBits()) + } + + @Test + fun `every punycode label is rejected`() { + // iOS IDNA-decodes xn-- labels: valid punycode becomes non-ASCII and is + // rejected, and most invalid forms fail its parse, both pinned in the + // battery. Foundation lets a few exotic invalid forms through + // literally; this port rejects every xn-- label instead, stricter in + // the safe direction, measured by the fuzz round. + assertNull(RelayDirectory.validatedDirectoryAddress("xn--bcher-kva.example")) + assertNull(RelayDirectory.validatedDirectoryAddress("xn--x.example")) + } + + @Test + fun `hosts uri refuses but ios accepts are salvaged by the fallback`() { + // java.net.URI follows RFC 2396 and returns no host when the final + // label starts with a digit, or when a trailing colon carries no port; + // iOS's RFC 3986 parser accepts both. The plain-authority fallback + // covers exactly these shapes. + assertEquals("b.08relay", RelayDirectory.validatedDirectoryAddress("https://b.08relay")) + assertEquals("relay.example.1", RelayDirectory.validatedDirectoryAddress("relay.example.1:")) + assertEquals("d.7ex:8443", RelayDirectory.validatedDirectoryAddress("wss://d.7ex:8443")) + } + + @Test + fun `invalid utf8 rejects the file`() { + assertNull(RelayDirectory.validatedEntries(byteArrayOf(0xFF.toByte(), 0xFE.toByte(), 0x41), minimumEntries = 1)) + } + + @Test + fun `a file over the byte cap is rejected`() { + val oversized = ByteArray(RelayDirectory.MAX_DIRECTORY_BYTES + 1) { 'a'.code.toByte() } + assertNull(RelayDirectory.validatedEntries(oversized, minimumEntries = 1)) + } + + @Test + fun `more rows than the row cap rejects the file`() { + val rows = buildString { + append(header) + repeat(RelayDirectory.MAX_DIRECTORY_ROWS + 1) { append("relay-a.example,1.0,2.0\n") } + } + assertNull(validate(rows)) + } + + // MARK: row-level rules, each rejecting the whole file + + @Test + fun `one malformed row rejects the whole file`() { + val csv = header + + "relay-a.example,1.0,2.0\n" + + "relay-b.example,2.0\n" + + "relay-c.example,3.0,4.0\n" + assertNull(validate(csv)) + } + + @Test + fun `an out of range coordinate rejects the whole file`() { + assertNull(validate(header + "relay-a.example,91.0,2.0\n")) + assertNull(validate(header + "relay-a.example,1.0,181.0\n")) + assertNull(validate(header + "relay-a.example,abc,2.0\n")) + } + + @Test + fun `a coordinate spelling only java parses rejects the file`() { + // Double.parseDouble takes "1.5f"; Swift's Double(String) does not. Both + // platforms must refuse the row, or one keeps a file the other drops. + assertNull(validate(header + "relay-a.example,1.5f,2.0\n")) + assertNull(validate(header + "relay-a.example,1.0,2.0d\n")) + } + + @Test + fun `a row with a rejected host rejects the whole file`() { + val csv = header + + "relay-a.example,1.0,2.0\n" + + "localhost,1.0,2.0\n" + assertNull(validate(csv)) + } + + @Test + fun `hosts ios rejects are rejected here`() { + val rejected = mapOf( + "wss://relay.example.com/path" to "path beyond /", + "wss://user@relay.example.com" to "userinfo", + "wss://relay.example.com?x=1" to "query", + "wss://relay.example.com#frag" to "fragment", + "ws://relay.example.com" to "scheme other than wss or https", + "localhost" to "localhost", + "node.local" to ".local", + "svc.internal" to ".internal", + "a.localhost" to ".localhost", + "singlelabel" to "single label", + "192.0.2.7" to "all-numeric labels", + "réseau.example" to "non-ascii", + "-bad.example" to "label starting with hyphen", + "bad-.example" to "label ending with hyphen", + "${"a".repeat(64)}.example" to "label over 63 chars", + "${(1..4).joinToString(".") { "a".repeat(63) }}.ex" to "host over 253 chars", + "relay.example.com:0" to "port below 1", + "relay.example.com:70000" to "port above 65535", + "relay.example." to "trailing dot" + ) + for ((raw, reason) in rejected) { + assertNull(reason, RelayDirectory.validatedDirectoryAddress(raw)) + } + } + + // MARK: floors and the hijack guard + + @Test + fun `a remote file below the entry floor is rejected`() { + val csv = header + "relay-a.example,1.0,2.0\n" + assertNull(validate(csv, minimumEntries = RelayDirectory.MIN_REMOTE_ENTRIES)) + assertNotNull(validate(csv, minimumEntries = 1)) + } + + @Test + fun `a download keeping less than half of the known entries is rejected`() { + val baseline = setOf( + RelayDirectory.RelayInfo("wss://base-a.example", 1.0, 1.0), + RelayDirectory.RelayInfo("wss://base-b.example", 2.0, 2.0), + RelayDirectory.RelayInfo("wss://base-c.example", 3.0, 3.0), + RelayDirectory.RelayInfo("wss://base-d.example", 4.0, 4.0) + ) + val keepsTwo = header + + "base-a.example,1.0,1.0\n" + + "base-b.example,2.0,2.0\n" + + "fresh-a.example,5.0,5.0\n" + assertNotNull(validate(keepsTwo, baseline = baseline)) + + val keepsOne = header + + "base-a.example,1.0,1.0\n" + + "fresh-a.example,5.0,5.0\n" + + "fresh-b.example,6.0,6.0\n" + assertNull(validate(keepsOne, baseline = baseline)) + } + + // MARK: the shipped asset + + @Test + fun `the bundled asset passes validation with the expected entry count`() { + val asset = listOf( + File("src/main/assets/nostr_relays.csv"), + File("app/src/main/assets/nostr_relays.csv") + ).firstOrNull { it.isFile } ?: error("bundled relay asset not found") + val entries = RelayDirectory.validatedEntries(asset.readBytes(), minimumEntries = 1) + assertNotNull("the shipped snapshot must pass its own gate", entries) + assertEquals(326, entries!!.size) + } +} diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index f37809f0..6fc1b5a3 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -45,6 +45,15 @@ relay sets for the same geohash and silently fail to exchange messages. `RelayDirectoryTest` covers the Android side; iOS implements the same rules in `GeoRelayDirectory`. +Directory acceptance is part of the same contract. Both platforms validate a +directory file with the same rules (exact header, per-row host and coordinate +checks, size, row, and entry caps, and a minimum overlap with the previous +entries for downloads) and reject a violating file whole, keeping the previous +copy. A file one platform accepts and the other rejects splits the two relay +selections at every geohash at once. `RelayDirectoryValidationTest` covers the +Android side; iOS implements the same rules in +`GeoRelayDirectory.validatedEntries`. + ## Rewrite acceptance gate From a configured Android development environment, run: From 91b529eb7055d3e52b72a5f953abb1c9acd956ab Mon Sep 17 00:00:00 2001 From: heyaim <223061694+heyaim@users.noreply.github.com> Date: Mon, 7 Sep 2026 20:56:21 -0500 Subject: [PATCH 6/6] Check the entry count against a committed snapshot fetch-georelays.yml rewrites the bundled asset every week; a test asserting the asset's exact entry count would fail on the first data update after this merges. The exact-count check moves to a committed snapshot of online_relays_gps.csv, which never changes: a different count there is a change in the validator, never a change in the data. The bundled asset is still tested on every run: it must pass the same validation a download must pass, including the minimum entry count. A weekly update the validator refuses, or one that shrinks below that minimum, fails the test suite. A bad file gets caught in the repo instead of shipping inside the app. The snapshot is a copy of online_relays_gps.csv as of Aug 30. --- .../nostr/RelayDirectoryValidationTest.kt | 26 +- .../test/resources/nostr_relays_snapshot.csv | 442 ++++++++++++++++++ 2 files changed, 465 insertions(+), 3 deletions(-) create mode 100644 app/src/test/resources/nostr_relays_snapshot.csv diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt index 52ea0c30..7a5bb89f 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt @@ -221,13 +221,33 @@ class RelayDirectoryValidationTest { // MARK: the shipped asset @Test - fun `the bundled asset passes validation with the expected entry count`() { + fun `the bundled asset passes the same validation a download must pass`() { + // fetch-georelays.yml rewrites the bundled asset every week, so this + // test checks validity, not contents: whatever the job ships must pass + // the same validation a download must pass. The exact-count check + // lives on the fixed snapshot below. val asset = listOf( File("src/main/assets/nostr_relays.csv"), File("app/src/main/assets/nostr_relays.csv") ).firstOrNull { it.isFile } ?: error("bundled relay asset not found") - val entries = RelayDirectory.validatedEntries(asset.readBytes(), minimumEntries = 1) - assertNotNull("the shipped snapshot must pass its own gate", entries) + val entries = RelayDirectory.validatedEntries( + asset.readBytes(), + minimumEntries = RelayDirectory.MIN_REMOTE_ENTRIES + ) + assertNotNull("the bundled asset must pass validation", entries) + } + + @Test + fun `a fixed snapshot of the directory yields the exact entry count`() { + // A copy of online_relays_gps.csv taken 2026-08-30, 441 rows collapsing + // to 326 entries. This file never changes, so a different count here is + // a change in the validator, never a change in the data. + val snapshot = listOf( + File("src/test/resources/nostr_relays_snapshot.csv"), + File("app/src/test/resources/nostr_relays_snapshot.csv") + ).firstOrNull { it.isFile } ?: error("relay snapshot fixture not found") + val entries = RelayDirectory.validatedEntries(snapshot.readBytes(), minimumEntries = 1) + assertNotNull(entries) assertEquals(326, entries!!.size) } } diff --git a/app/src/test/resources/nostr_relays_snapshot.csv b/app/src/test/resources/nostr_relays_snapshot.csv new file mode 100644 index 00000000..f83e5ad0 --- /dev/null +++ b/app/src/test/resources/nostr_relays_snapshot.csv @@ -0,0 +1,442 @@ +Relay URL,Latitude,Longitude +bitchat.nostr1.com,40.7057,-74.0136 +relay.fundstr.me,42.3601,-71.0589 +nostr.2b9t.xyz,34.0549,-118.243 +armada.sharegap.net,43.6532,-79.3832 +nostr.chaima.info,51.5072,-0.127586 +nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832 +ribo.eu.nostria.app:443,43.6532,-79.3832 +relay.lightning.pub,39.0438,-77.4874 +relay.nostu.be,40.4167,-3.70329 +nostr.whitenode45.ddns.net,40.55,-74.4758 +nostr.carroarmato0.be:443,50.914,3.21378 +cdn.satellite.earth,40.8302,-74.1299 +relay2.veganostr.com,60.1699,24.9384 +relay.layer.systems:443,49.0291,8.35695 +relay0.gfcom.info,13.7653,100.647 +relay.mmwaves.de:443,48.8575,2.35138 +offchain.pub,39.1585,-94.5728 +bcast.girino.org,43.6532,-79.3832 +staging.yabu.me,35.6092,139.73 +nostr.overpay.com,29.7449,-95.5343 +bridge.tagomago.me,42.3601,-71.0589 +nostr-01.yakihonne.com,1.32123,103.695 +strfry.bonsai.com,39.0438,-77.4874 +relay.sharegap.net,43.6532,-79.3832 +nostr.islandarea.net,35.4669,-97.6473 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +treuzkas.branruz.com,48.8575,2.35138 +relay-rpi.edufeed.org:443,49.4521,11.0767 +vault.iris.to:443,43.6532,-79.3832 +node.kommonzenze.de,49.4521,11.0767 +nostr.thalheim.io:443,60.1699,24.9384 +soloco.nl,43.6532,-79.3832 +strfry.shock.network,39.0438,-77.4874 +nostr-relay.zimage.com,34.0549,-118.243 +public.crostr.com:443,43.6532,-79.3832 +nostr.sathoarder.com:443,48.5734,7.75211 +relay.angor.io,48.1046,11.6002 +relay.wellorder.net,45.5201,-122.99 +relay.mwaters.net,50.9871,2.12554 +relay.staging.commonshub.brussels,49.4543,11.0746 +nostr-verified.wellorder.net,45.5201,-122.99 +nostr-pub.wellorder.net,45.5201,-122.99 +nostr-2.21crypto.ch,47.5356,8.73209 +relay.kaleidoswap.com,50.8476,4.35717 +relay.libernet.app:443,43.6532,-79.3832 +relay.homeinhk.xyz,35.694,139.754 +relay.manneken.brussels,49.4543,11.0746 +nostr.spicyz.io:443,43.6532,-79.3832 +relay.lanacoin-eternity.com:443,40.8302,-74.1299 +ribo.us.nostria.app:443,43.6532,-79.3832 +relay.loveisbitcoin.com,43.6532,-79.3832 +relay.angor.io:443,48.1046,11.6002 +relay02.lnfi.network,35.6764,139.65 +relay.cosmicbolt.net:443,37.3986,-121.964 +nostr-rs-relay-qj1h.onrender.com,37.7775,-122.397 +nrs-01.darkcloudarcade.com,39.0997,-94.5786 +relay.endfiat.money:443,59.3327,18.0656 +relay.paulstephenborile.com,49.4543,11.0746 +rele.speyhard.fi,51.5072,-0.127586 +relay.froth.zone,60.1699,24.9384 +relay.nostr.blockhenge.com,39.0438,-77.4874 +nrl.ceskar.xyz,50.5145,16.0119 +rilo.nostria.app,43.6532,-79.3832 +nostr.overmind.lol:443,43.6532,-79.3832 +nostr.snowbla.de:443,50.4754,12.3683 +nostrrelay.taylorperron.com,45.5029,-73.5723 +chorus.pjv.me,45.5201,-122.99 +relay.nostr.place,43.6532,-79.3832 +bucket.coracle.social,37.7775,-122.397 +nostr.girino.org:443,43.6532,-79.3832 +relay.aarpia.com,37.3986,-121.964 +nostr.thalheim.io,60.1699,24.9384 +ec2.f7z.io,60.1699,24.9384 +relay.trotters.cc,43.6532,-79.3832 +relay.mccormick.cx:443,52.3563,4.95714 +relay.momostr.pink,43.6532,-79.3832 +relay.nostr.net,43.6532,-79.3832 +conduitl2.fly.dev,37.7648,-122.432 +chat-relay.zap-work.com,43.6532,-79.3832 +relay.ditto.pub,43.6532,-79.3832 +relay.veganostr.com,60.1699,24.9384 +relay.minibolt.info:443,43.6532,-79.3832 +adre.su,59.9311,30.3609 +bitcoinostr.duckdns.org,41.1976,1.11167 +nostr.computingcache.com:443,34.0356,-118.442 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +nostr.hekster.org:443,37.3986,-121.964 +nostr.88mph.life,52.1941,-2.21905 +wot.dergigi.com,64.1476,-21.9392 +nostr.planix.org,43.6532,-79.3832 +relay.satsmarkt.club,52.6907,4.8181 +nostrcity-club.fly.dev:443,37.7648,-122.432 +aeon.libretechsystems.xyz,55.486,9.86577 +testnet.samt.st,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +wot.sudocarlos.com,43.6532,-79.3832 +relay-fra.zombi.cloudrodion.com:443,48.8566,2.35222 +shu01.shugur.net,21.4902,39.2246 +relay.gulugulu.moe:443,43.6532,-79.3832 +relay2.angor.io:443,48.1046,11.6002 +relay.libernet.app,43.6532,-79.3832 +directories-safe-motherboard-recipients.trycloudflare.com,43.6532,-79.3832 +wot.nostr.party,36.1659,-86.7844 +relay.zone667.com,60.1699,24.9384 +nostr.wild-vibes.ts.net,48.8566,2.35222 +relay.nostr.com,50.1109,8.68213 +nostr.iskarion.ddns.net,43.3076,-2.95421 +relay-dev.satlantis.io,39.0438,-77.4874 +relay.sovereignresonance.org,48.9006,2.25929 +relay.nostrian-conquest.com,41.223,-111.974 +relay.aidatanorge.no,43.6532,-79.3832 +strfry.apps3.slidestr.net,40.4167,-3.70329 +relay.klabo.world,47.2343,-119.853 +nostr.data.haus:443,50.4754,12.3683 +testr.nymble.world,40.8054,-74.0241 +relay.inforsupports.com,43.6532,-79.3832 +relay.nostrmap.net:443,60.1699,24.9384 +nostr.stakey.net:443,52.3676,4.90414 +dev-relay.nostreon.com,60.1699,24.9384 +nostr.islandarea.net:443,35.4669,-97.6473 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.bowlafterbowl.com,32.9483,-96.7299 +nostr.quali.chat:443,60.1699,24.9384 +relay.plebeian.market,50.1109,8.68213 +relay-rpi.edufeed.org,49.4521,11.0767 +r.0kb.io,32.789,-96.7989 +nostr.notribe.net:443,40.8302,-74.1299 +relay.getsafebox.app:443,43.6532,-79.3832 +nostr.dlcdevkit.com:443,40.0992,-83.1141 +nostrelites.org,34.9582,-81.9907 +nostr.hoppe-relay.it.com,42.8864,-78.8784 +nostr.thebiglake.org,32.71,-96.6745 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +relay.nostriot.com,41.5695,-83.9786 +nostr.christiansass.de,51.7634,7.8887 +relay.btcforplebs.com,43.6532,-79.3832 +nostr.tagomago.me,42.3601,-71.0589 +relayone.geektank.ai,39.0997,-94.5786 +relay.dreamith.to:443,43.6532,-79.3832 +nostr.liberty.fans,36.8767,-89.5879 +wot.makenomistakes.ca,43.7064,-79.3986 +relay.goodmorningbitcoin.com,43.6532,-79.3832 +relay.layer.systems,49.0291,8.35695 +relay.paulstephenborile.com:443,49.4543,11.0746 +relay.ohstr.com,43.6532,-79.3832 +nostr-relay.xbytez.io:443,50.6924,3.20113 +nostr.ac,38.958,-77.3592 +ribo.us.nostria.app,43.6532,-79.3832 +nostr.21crypto.ch,47.5356,8.73209 +relay.chorus.community:443,48.5333,10.7 +relay.cypherflow.ai,48.8575,2.35138 +relay.agorist.space:443,52.3734,4.89406 +relay.nostrian-conquest.com:443,41.223,-111.974 +relay.keykeeper.world,40.7824,-74.0711 +relay.getvia.xyz,60.1699,24.9384 +relay.nuts.cash,52.3676,4.90414 +kotukonostr.onrender.com,37.7775,-122.397 +relay.minibolt.info,43.6532,-79.3832 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.fountain.fm:443,43.6532,-79.3832 +relay.fountain.fm,43.6532,-79.3832 +nostr-02.uid.ovh,50.9871,2.12554 +relay.lanavault.space:443,60.1699,24.9384 +nostr.carroarmato0.be,50.914,3.21378 +nexus.libernet.app:443,43.6532,-79.3832 +relay.artio.inf.unibe.ch,46.9501,7.43678 +blossom.gnostr.cloud,43.6532,-79.3832 +relay.binaryrobot.com,43.6532,-79.3832 +relay.earthly.city,34.1749,-118.54 +nostr.hifish.org,47.4244,8.57658 +offchain.pub:443,39.1585,-94.5728 +relay.bullishbounty.com:443,43.6532,-79.3832 +strfry.openhoofd.nl:443,51.5717,3.70417 +cs-relay.nostrdev.com:443,50.4754,12.3683 +strfry.ymir.cloud,43.6532,-79.3832 +nostrbtc.com,43.6532,-79.3832 +relay.directsponsor.net,42.8864,-78.8784 +nostr2.girino.org,43.6532,-79.3832 +relay.sigit.io:443,50.4754,12.3683 +relay.getsafebox.app,43.6532,-79.3832 +antiprimal.net,43.6532,-79.3832 +nostr.sathoarder.com,48.5734,7.75211 +inbox.scuba323.com,40.8218,-74.45 +nrs-01.darkcloudarcade.com:443,39.0997,-94.5786 +nostr.tac.lol,47.4748,-122.273 +nostr.davenov.com,50.1109,8.68213 +relay.trotters.cc:443,43.6532,-79.3832 +nostr.plantroon.com:443,50.1013,8.62643 +relay.nostreon.com,60.1699,24.9384 +nostr.easycryptosend.it,43.6532,-79.3832 +nostr-01.yakihonne.com:443,1.32123,103.695 +relay-testnet.k8s.layer3.news,37.3387,-121.885 +nostr.purpura.cloud,43.6532,-79.3832 +insta-relay.apps3.slidestr.net,40.4167,-3.70329 +nostr.mifen.me,43.6532,-79.3832 +testnet-relay.samt.st:443,40.8302,-74.1299 +nostr.2b9t.xyz:443,34.0549,-118.243 +relay.wavlake.com:443,41.2619,-95.8608 +relay.wisp.talk:443,49.4543,11.0746 +relay-dev.satlantis.io:443,39.0438,-77.4874 +relay.satlantis.io,39.0438,-77.4874 +relay.staging.plebeian.market,51.5072,-0.127586 +relay.openfarmtools.org,60.1699,24.9384 +relay.nostrhub.fr,48.1045,11.6004 +nostr-relay.xbytez.io,50.6924,3.20113 +relay.binaryrobot.com:443,43.6532,-79.3832 +relay.samt.st,40.8302,-74.1299 +relay.illuminodes.com,43.6532,-79.3832 +relay.liberbitworld.org,43.6532,-79.3832 +relay.olas.app:443,60.1699,24.9384 +no.str.cr,8.96171,-83.5246 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +wot.rejecttheframe.xyz,43.6532,-79.3832 +relay.nostriot.com:443,41.5695,-83.9786 +nostr.plantroon.com,50.1013,8.62643 +nostr-01.uid.ovh,50.9871,2.12554 +relay.openresist.com:443,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +relay.internationalright-wing.org,-22.5022,-48.7114 +nostr.myshosholoza.co.za:443,52.3676,4.90414 +nostr.pbfs.io:443,50.4754,12.3683 +21milionidinostr.duckdns.org,41.8967,12.4822 +nostr.4rs.nl,49.0291,8.35696 +relay.lanavault.space,60.1699,24.9384 +relay.mostr.pub,43.6532,-79.3832 +relay.nostar.org,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +relay.decentralia.fr,48.122,11.589 +relay.agentry.com,42.8864,-78.8784 +relay2.angor.io,48.1046,11.6002 +slick.mjex.me,39.0418,-77.4744 +relay-us.zombi.cloudrodion.com,40.7862,-74.0743 +relay.vrtmrz.net:443,43.6532,-79.3832 +relay.beginningend.com,35.2227,-97.4786 +chat-relay.zap-work.com:443,43.6532,-79.3832 +relay.underorion.se,50.1109,8.68213 +relay.mitchelltribe.com,39.0438,-77.4874 +relay.qstr.app,51.5072,-0.127586 +relay.cyberguy.fyi,52.6907,4.8181 +strfry.bonsai.com:443,39.0438,-77.4874 +relayone.soundhsa.com:443,39.0997,-94.5786 +relay.sigit.io,50.4754,12.3683 +relay.npubhaus.com,43.6532,-79.3832 +relayrs.notoshi.win,43.6532,-79.3832 +relay.mitchelltribe.com:443,39.0438,-77.4874 +relay.44billion.net,43.6532,-79.3832 +reraw.pbla2fish.cc,43.6532,-79.3832 +articles.layer3.news:443,37.3387,-121.885 +nostr.sovereignservices.xyz,43.6532,-79.3832 +relay.nostx.io,43.6532,-79.3832 +nostr-relay.amethyst.name,39.0067,-77.4291 +0x-nostr-relay.fly.dev,37.7648,-122.432 +relay.ohstr.com:443,43.6532,-79.3832 +00f2e774.relay.dev.thunderegg.us,39.0438,-77.4874 +nostr-relay.cbrx.io,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +purplerelay.com:443,43.6532,-79.3832 +nostr-pr02.redscrypt.org,52.3676,4.90414 +fanfares.nostr1.com:443,40.7057,-74.0136 +kasztanowa.bieda.it,43.6532,-79.3832 +relay.flashapp.me,43.6548,-79.3885 +relay.typedcypher.com,51.5072,-0.127586 +nostr.bond,50.1109,8.68213 +nostr.azzamo.net,52.2633,21.0283 +nexus.libernet.app,43.6532,-79.3832 +relay.cosmicbolt.net,37.3986,-121.964 +schnorr.me,43.6532,-79.3832 +relay.mostro.network:443,40.8302,-74.1299 +relay-arg.zombi.cloudrodion.com,1.35208,103.82 +relay.chorus.community,48.5333,10.7 +blossom.gnostr.cloud:443,43.6532,-79.3832 +syb.lol:443,34.0549,-118.243 +relay.dyne.org,49.0291,8.35705 +btc.klendazu.com,41.2861,1.24993 +wot.nostr.place,43.6532,-79.3832 +relay.openresist.com,43.6532,-79.3832 +rilo.nostria.app:443,43.6532,-79.3832 +no.str.cr:443,8.96171,-83.5246 +relay.mostr.pub:443,43.6532,-79.3832 +relay.edufeed.org:443,49.4521,11.0767 +nostr.debate.report,50.1109,8.68213 +relay.satmaxt.xyz:443,43.6532,-79.3832 +relay.artx.market:443,43.6548,-79.3885 +relay-dev.gulugulu.moe,43.6532,-79.3832 +relay.novospes.com,43.6532,-79.3832 +relay.nostr-check.me,43.6532,-79.3832 +nostr.computingcache.com,34.0356,-118.442 +nostr.oxtr.dev,50.4754,12.3683 +relay.fckstate.net,59.3293,18.0686 +relay.vrtmrz.net,43.6532,-79.3832 +relay.bornheimer.app,51.5072,-0.127586 +relay.guggero.org,46.5971,9.59652 +relay01.lnfi.network,35.6764,139.65 +wot.shaving.kiwi,43.6532,-79.3832 +nostr.twinkle.lol,51.902,7.6657 +relay.edufeed.org,49.4521,11.0767 +relay.lanacoin-eternity.com,40.8302,-74.1299 +relay.satmaxt.xyz,43.6532,-79.3832 +nostr.hifish.org:443,47.4244,8.57658 +relay.cypherflow.ai:443,48.8575,2.35138 +infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 +nostr.na.social:443,43.6532,-79.3832 +nostr.rtvslawenia.com:443,49.4543,11.0746 +relay.mypathtofire.de,42.8864,-78.8784 +public.crostr.com,43.6532,-79.3832 +relay.olas.app,60.1699,24.9384 +relay.agora.social,50.7383,15.0648 +ribo.nostria.app,43.6532,-79.3832 +relay.lab.rytswd.com,49.4543,11.0746 +relay.ditto.pub:443,43.6532,-79.3832 +porchlight.social,43.6532,-79.3832 +nostr.notribe.net,40.8302,-74.1299 +relay.endfiat.money,59.3327,18.0656 +nostr.myshosholoza.co.za,52.3676,4.90414 +relay.nearhood.co.uk,51.5134,-0.0890675 +relay.degmods.com,50.4754,12.3683 +nostr.novacisko.cz,52.2026,20.9397 +prl.plus,55.7628,37.5983 +bruh.samt.st,43.6532,-79.3832 +strfry.openhoofd.nl,51.5717,3.70417 +nostr.spicyz.io,43.6532,-79.3832 +nostr.na.social,43.6532,-79.3832 +nip85.nosfabrica.com,39.0997,-94.5786 +premium.primal.net,43.6532,-79.3832 +fanfares.nostr1.com,40.7057,-74.0136 +relay.scuba323.com,40.8218,-74.45 +nostr2.girino.org:443,43.6532,-79.3832 +relay.mmwaves.de,48.8575,2.35138 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +strfry.shock.network:443,39.0438,-77.4874 +nostr.snowbla.de,50.4754,12.3683 +nostr.spaceshell.xyz,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +wot.utxo.one,43.6532,-79.3832 +relay.mccormick.cx,52.3563,4.95714 +mostro-p2p.tech,50.1109,8.68213 +basspistol.org,49.0291,8.35696 +ribo.nostria.app:443,43.6532,-79.3832 +chorus.mikedilger.com:444,-36.8906,174.794 +nostr.oxtr.dev:443,50.4754,12.3683 +nostr.nodesmap.com,59.3327,18.0656 +offchain.bostr.online,43.6532,-79.3832 +purplerelay.com,43.6532,-79.3832 +relayrs.notoshi.win:443,43.6532,-79.3832 +relay.wavefunc.live,41.8781,-87.6298 +relay.dreamith.to,43.6532,-79.3832 +bendernostur.duckdns.org:8443,50.1109,8.68213 +relay.nmail.li,50.9871,2.12554 +nostr-relay.corb.net,39.6478,-104.988 +relay.staging.plebeian.market:443,51.5072,-0.127586 +spamspamspamspam.rest,43.6532,-79.3832 +relay1.gfcom.info,13.9215,100.538 +schnorr.me:443,43.6532,-79.3832 +relay.lab.rytswd.com:443,49.4543,11.0746 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +relay.nostrmap.net,60.1699,24.9384 +nostr.relay.hedwig.sh,60.1699,24.9384 +relay.veganostr.com:443,60.1699,24.9384 +relay.wavefunc.live:443,41.8781,-87.6298 +nostr.mikoshi.de,52.52,13.405 +syb.lol,34.0549,-118.243 +relay1.nostrchat.io,60.1699,24.9384 +nostr.wecsats.io:443,43.6532,-79.3832 +nostr.chaima.info:443,51.5072,-0.127586 +nostr.azzamo.net:443,52.2633,21.0283 +relay-can.zombi.cloudrodion.com,43.6532,-79.3832 +nostr.unkn0wn.world,46.8499,9.53287 +relayone.soundhsa.com,39.0997,-94.5786 +x.kojira.io,43.6532,-79.3832 +dm-test-strfry-discovery.samt.st:443,43.6532,-79.3832 +nostrelay.circum.space,52.6907,4.8181 +relay.primal.net,43.6532,-79.3832 +nostr.girino.org,43.6532,-79.3832 +nostr.pbfs.io,50.4754,12.3683 +relay.kalcafe.xyz,37.3986,-121.964 +relay.gulugulu.moe,43.6532,-79.3832 +top.testrelay.top,43.6532,-79.3832 +relay.kilombino.com,43.6532,-79.3832 +nos.lol:443,50.4754,12.3683 +nos.lol,50.4754,12.3683 +relay.nostr.place:443,43.6532,-79.3832 +cache.trustr.ing,43.6548,-79.3885 +relay.internationalright-wing.org:443,-22.5022,-48.7114 +relay.laantungir.net,-19.4692,-42.5315 +relay.lightning.pub:443,39.0438,-77.4874 +nostr.stakey.net,52.3676,4.90414 +articles.layer3.news,37.3387,-121.885 +relay.wisp.talk,49.4543,11.0746 +relay.pyramid.li,47.4093,8.46503 +relay.typedcypher.com:443,51.5072,-0.127586 +dev.relay.stream,43.6532,-79.3832 +relay.bullishbounty.com,43.6532,-79.3832 +nostr.mom:443,50.4754,12.3683 +relay.plebeian.market:443,50.1109,8.68213 +nostr.hekster.org,37.3986,-121.964 +nostrcity-club.fly.dev,37.7648,-122.432 +nostr.vulpem.com,49.4543,11.0746 +relay-dev.gulugulu.moe:443,43.6532,-79.3832 +weboftrust.libretechsystems.xyz,55.4724,9.87335 +nostr-relay.corb.net:443,39.6478,-104.988 +wheat.happytavern.co,43.6532,-79.3832 +relay.mappingbitcoin.com,43.6532,-79.3832 +testnet-relay.samt.st,40.8302,-74.1299 +relay.bitmacro.cloud,43.6532,-79.3832 +dev.relay.edufeed.org,49.4521,11.0767 +myvoiceourstory.org,37.3598,-121.981 +relay.stickeroo.is-cool.dev,37.3387,-121.885 +relay.agorist.space,52.3734,4.89406 +freelay.sovbit.host,60.1699,24.9384 +nostr-dev.wellorder.net,45.5201,-122.99 +nostr.middling.mydns.jp,35.8099,140.12 +cs-relay.nostrdev.com,50.4754,12.3683 +x.kojira.io:443,43.6532,-79.3832 +nostrelay.circum.space:443,52.6907,4.8181 +nostr.janx.com,43.6532,-79.3832 +relay.mrmave.work,43.6532,-79.3832 +espelho.girino.org,43.6532,-79.3832 +hol.is,43.6532,-79.3832 +ribo.eu.nostria.app,43.6532,-79.3832 +nostr.yutakobayashi.com,43.6532,-79.3832 +relay.mostro.network,40.8302,-74.1299 +communities.nos.social,40.8302,-74.1299 +relay.solife.me,43.6532,-79.3832 +yabu.me,35.6092,139.73 +relay.islandbitcoin.com,12.8498,77.6545 +nostr.wecsats.io,43.6532,-79.3832 +nostr.tac.lol:443,47.4748,-122.273 +relay.arx-ccn.com,50.4754,12.3683 +nostrride.io,37.3986,-121.964 +r.0kb.io:443,32.789,-96.7989 +herbstmeister.com,34.0549,-118.243 +relay.artx.market,43.6548,-79.3885 +vault.iris.to,43.6532,-79.3832 +relay.ru.ac.th,13.7607,100.627 +temp.iris.to,43.6532,-79.3832 +social.amanah.eblessing.co,48.1046,11.6002 +nostr-relay.nextblockvending.com,47.2343,-119.853 +wot.codingarena.top,50.4754,12.3683 +relay.sincensura.org,43.6532,-79.3832 +nostr.dlcdevkit.com,40.0992,-83.1141