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 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..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.* @@ -26,13 +24,33 @@ 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" + // 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" 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() @@ -55,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 { @@ -97,14 +116,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) @@ -114,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) @@ -159,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 } @@ -182,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) { @@ -199,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 @@ -214,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) { @@ -235,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() @@ -255,26 +320,248 @@ object RelayDirectory { Log.i(TAG, "📦 Loaded ${list.size} relay entries from assets/$ASSET_FILE, sha256=$hash") } - private fun parseCsv(input: InputStream): List { - val result = mutableListOf() - 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 url = 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)) + /** + * 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 + } + + 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 })) + } + + /** + * 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 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 result + 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 new file mode 100644 index 00000000..e14d8a61 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryTest.kt @@ -0,0 +1,145 @@ +package com.bitchat.android.nostr + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +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. + * + * 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) = + requireNotNull(RelayDirectory.validatedEntries(csv.toByteArray(), minimumEntries = 1)) { + "fixture unexpectedly rejected" + } + + 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 `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 + 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`() { + // 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 + + "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://port-variant.relay.example", entries[0].url) + assertEquals("wss://port-variant.relay.example:8443", entries[1].url) + } + + @Test + 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").toByteArray(), + minimumEntries = 1 + ) + assertNull(rejected) + } + + @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 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,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", + "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`() { + // 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,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/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..7a5bb89f --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/RelayDirectoryValidationTest.kt @@ -0,0 +1,253 @@ +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 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 = 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 diff --git a/docs/client-rewrite-contracts.md b/docs/client-rewrite-contracts.md index 05d5e1ad..6fc1b5a3 100644 --- a/docs/client-rewrite-contracts.md +++ b/docs/client-rewrite-contracts.md @@ -36,6 +36,24 @@ 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`. + +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: