From 129401bdaf5f51025e3b89409199fd0fd043be7f Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 21:36:22 +0000 Subject: [PATCH 1/3] test(relay): characterize Yggdrasil/IPv6 relay handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assesses how the app fares when relays live on an Yggdrasil overlay, where every relay is a bracketed IPv6 literal in 0200::/7 served over plain ws:// (no DNS, no CA-issuable certificate). The happy path works: a hand-typed ws://[...]:port normalizes, survives the RFC 3986 pass and is dialed by OkHttp; nothing in the stack is IPv4-only and cleartext is already permitted globally. Four gaps are pinned by the new characterization tests: 1. RelayUrlNormalizer folds hex case but not zero-compression, so two legal spellings of one address yield two NormalizedRelayUrl values while OkHttp collapses them to one host — duplicate sockets, REQs and stat entries. 2. isLocalHost() does not know 0200::/7, so a schemeless literal defaults to wss:// and can only fail its TLS handshake. 3. An unbracketed literal (what yggdrasilctl getSelf prints) is rejected, and RelayUrlEditField.submitRelay has no else branch — the Add button silently does nothing. 4. TorRelayEvaluation classifies mesh relays as "new", so with Tor on they are dialed through the SOCKS proxy, which cannot route 0200::/7. No behavior is changed. quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md records the full assessment, the NIP-65/outbox propagation consequences of publishing a key-derived mesh address, and what could not be verified here (the analysis container has no IPv6 stack, so nothing below the socket was exercised). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk --- .../commons/tor/YggdrasilTorRoutingTest.kt | 65 ++++++++++ .../plans/2026-08-04-yggdrasil-ipv6-relays.md | 103 ++++++++++++++++ .../YggdrasilCompatCharacterizationTest.kt | 116 ++++++++++++++++++ 3 files changed, 284 insertions(+) create mode 100644 commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt create mode 100644 quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt new file mode 100644 index 0000000000..68a8941821 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.amethyst.commons.tor + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * GAP 4 — an Yggdrasil relay is classified as a plain clearnet relay, so with Tor on it is + * dialed through the SOCKS proxy. Tor cannot route `0200::/7`: the connection can only fail. + * + * Compare `ws://192.168.1.100:8080/`, which [TorRelayEvaluation] correctly keeps off Tor + * because `isLocalHost()` recognizes the LAN prefix. Yggdrasil has no such recognition. + */ +class YggdrasilTorRoutingTest { + private val yggdrasilRelay = NormalizedRelayUrl("ws://[201:d0e:9ba5:8bbc::1]:8080/") + private val lanRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") + + private fun evaluation(newViaTor: Boolean) = + TorRelayEvaluation( + torSettings = + TorRelaySettings( + torType = TorType.INTERNAL, + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = newViaTor, + trustedRelaysViaTor = false, + moneyOperationsViaTor = false, + ), + trustedRelayList = emptySet(), + dmRelayList = emptySet(), + ) + + @Test + fun yggdrasilRelayIsSentThroughTorWhileLanRelayIsNot() { + val eval = evaluation(newViaTor = true) + assertTrue(eval.useTor(yggdrasilRelay), "Yggdrasil relay is routed via Tor, which cannot reach 0200::/7") + assertFalse(eval.useTor(lanRelay), "LAN relay is correctly kept off Tor") + } + + @Test + fun yggdrasilRelayWorksOnlyWhenNewRelaysViaTorIsOff() { + assertFalse(evaluation(newViaTor = false).useTor(yggdrasilRelay)) + } +} diff --git a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md new file mode 100644 index 0000000000..f854bffaab --- /dev/null +++ b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md @@ -0,0 +1,103 @@ +# Amethyst over Yggdrasil (IPv6 overlay) — compatibility assessment + +Status: **analysis only** — no behavior changed. Characterization tests landed alongside +this doc pin the current behavior so a fix has a baseline to diff against. + +## What Yggdrasil looks like to the app + +Yggdrasil is an encrypted end-to-end mesh. Every node gets an IPv6 address derived from its +public key inside `0200::/7`, and hands out `0300::/8` subnets. Consequences that matter here: + +- **No DNS.** A relay on the mesh is addressed as a bracketed IPv6 literal, always. +- **No certificates.** No CA issues for `0200::/7` literals, so relays run plain `ws://`. + This is not a downgrade — the overlay already provides end-to-end encryption and + authenticates the peer by its address. +- **On Android it is a `VpnService`**, so the app's default network becomes the VPN network. +- The address is a **stable node identifier**, so publishing it is equivalent to publishing + a long-lived pseudonymous handle for the device. + +## Verdict + +A hand-typed `ws://[…]:port` relay works end to end: it normalizes, survives the RFC 3986 +pass, and OkHttp parses and dials it. Nothing in the stack is IPv4-only, `TcpNoDelaySocketFactory` +is family-agnostic, and `network_security_config.xml` permits cleartext globally, so the +`ws://` requirement is already satisfied. + +Everything around that happy path is where it degrades. Four gaps, in severity order. + +### GAP 1 — one relay, two identities (correctness) + +`RelayUrlNormalizer` folds hex case but does **not** canonicalize zero-compression or +leading zeros: + +| input | `NormalizedRelayUrl` | OkHttp host | +|---|---|---| +| `ws://[201:d0e:9ba5:8bbc::1]:8080` | `ws://[201:d0e:9ba5:8bbc::1]:8080/` | `201:d0e:9ba5:8bbc::1` | +| `ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080` | `ws://[201:0d0e:…:0001]:8080/` | `201:d0e:9ba5:8bbc::1` | + +OkHttp collapses both to one host; the app does not. `NormalizedRelayUrl` is the key of the +connection pool (`PoolRequests`, `RelayPool`), every relay-list set, the NIP-11 cache and the +per-relay stat maps — so the same relay written two ways gets **two sockets, two REQ sets and +doubled traffic**, and appears twice in the relay UI. This affects all IPv6 literals, but it +only bites Yggdrasil users in practice, because on the mesh a literal is the *only* way to +name a relay. Fix: canonicalize the bracketed literal to RFC 5952 inside `fix()`. + +### GAP 2 — schemeless entry defaults to `wss://` (dead end) + +`RelayUrlNormalizer.isLocalHost()` recognizes `127.0.0.1`, `localhost`, `//umbrel:`, +`192.168.`, `.local:` / `.local/`. An Yggdrasil address matches none of them, so a schemeless +`[201:…]:8080` falls through to the clearnet default and becomes `wss://[201:…]:8080/` — a +URL whose TLS handshake can never succeed. The user must know to type `ws://` themselves. +Fix: teach `isLocalHost()` (or a sibling `isOverlayNetwork()`) the `0200::/7` prefix. + +### GAP 3 — unbracketed literal is silently rejected (UX) + +`yggdrasilctl getSelf` prints the address **unbracketed**, which is exactly what a user +copies into the "add a relay" box. `isBareHostAndPath()` rejects it (correctly — it is +ambiguous with a scheme), so `normalizeOrNull` returns null. But +`RelayUrlEditField.submitRelay()` has no else branch: the Add button just does nothing, with +no error. Fix: either auto-bracket a candidate that parses as an IPv6 address, or surface a +validation message instead of a silent no-op. + +### GAP 4 — Tor routing sends mesh traffic into the SOCKS proxy (breaks the relay) + +`TorRelayEvaluation` classifies relays as localhost / onion / dm / trusted / new. Yggdrasil +lands in **new**, so with Tor on and the default "new relays via Tor", the relay is dialed +through the Tor SOCKS proxy — which cannot route `0200::/7`. The connection can only fail. +Compare `ws://192.168.1.100:8080/`, which is correctly kept off Tor because `isLocalHost()` +knows the LAN prefix. Today the only workaround is turning "new relays via Tor" off, which +weakens the setting for every genuine clearnet relay. The same gap exists on desktop +(`DesktopHttpClient`) and for non-relay HTTP (`RoleBasedHttpClientBuilder`). Fixing GAP 2's +prefix check fixes this one too, since both read the same predicate. + +## Propagation / privacy note (not a bug, a decision) + +An Yggdrasil relay is not filtered out of NIP-65 publishing (`AdvertisedRelayInfoTag` only +rejects localhost) nor out of the outbox model +(`RelayListRecommendationProcessor.filterValidRelays`). So a mesh relay in your relay list is +**published to public relays and recommended to other users**. Two effects: + +- Peers not on the mesh dial `[201:…]` and burn reconnect attempts on an unreachable host. +- Your Yggdrasil address — a stable, key-derived node identifier — becomes public. + +Onion relays get special handling here (`hasOnionConnection` gates whether they are even +considered). An overlay-network classification would let Yggdrasil be treated the same way. + +## Not covered + +- **No live socket test.** The analysis container has no IPv6 stack at all + (`AF_INET6` → `EAFNOSUPPORT`), so everything above is verified below the socket: URL + normalization, OkHttp URL/host parsing, and the Tor routing decision. An on-device run + against a real mesh relay is still needed to confirm the happy path end to end. +- **Android VPN interaction untested.** `ConnectivityFlow` uses + `registerDefaultNetworkCallback`, so it follows the app's default network into the VPN. + Whether `isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends + on whether that app declares underlying networks; worth checking on device before assuming + data-saving mode behaves. +- Media loading (Coil) and NIP-05 resolution against mesh hosts were not exercised. + +## Tests + +- `quartz/src/jvmAndroidTest/…/relay/YggdrasilCompatCharacterizationTest.kt` — GAPs 1–3 plus + the working happy path. +- `commons/src/commonTest/…/tor/YggdrasilTorRoutingTest.kt` — GAP 4. diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt new file mode 100644 index 0000000000..638858dbd8 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt @@ -0,0 +1,116 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp +import okhttp3.Request +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Characterization of how relay URLs on an Yggdrasil overlay behave today. + * + * Yggdrasil gives every node an IPv6 address inside `0200::/7` (nodes) and hands out + * `0300::/8` subnets, with no DNS and no CA-issuable certificate. A relay on the mesh is + * therefore always reached as a **bracketed IPv6 literal over plain `ws://`** — a shape + * the relay stack only partially handles. + * + * These tests document the CURRENT behavior (including the gaps) so a later fix has a + * baseline to diff against. Each gap is marked GAP with what a user sees. + */ +class YggdrasilCompatCharacterizationTest { + // Same node, three legal RFC 4291 spellings of one address. + private val canonical = "ws://[201:d0e:9ba5:8bbc::1]:8080" + private val expanded = "ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080" + private val uppercase = "ws://[201:D0E:9BA5:8BBC::1]:8080" + + private fun host(url: String) = + Request + .Builder() + .url(url) + .build() + .url.host + + @Test + fun bracketedLiteralsSurviveNormalizationAndReachOkHttp() { + val n = canonical.normalizeRelayUrl() + assertEquals("ws://[201:d0e:9ba5:8bbc::1]:8080/", n.url) + assertEquals("201:d0e:9ba5:8bbc::1", host(n.url)) + // NIP-11 / relay-icon fetches derive their http url from the same string. + assertEquals("http://[201:d0e:9ba5:8bbc::1]:8080/", n.toHttp()) + } + + @Test + fun yggdrasilSubnetAddressesAndUppercaseHexWork() { + assertEquals("ws://[300:1b5d:d0e9:ba58::1]:4848/", "ws://[300:1b5d:d0e9:ba58::1]:4848".normalizeRelayUrl().url) + // Hex case IS folded, so the uppercase spelling collapses onto the canonical one. + assertEquals(canonical.normalizeRelayUrl(), uppercase.normalizeRelayUrl()) + } + + /** + * GAP 1 — zero-compression is NOT canonicalized, so one relay gets two identities. + * + * `NormalizedRelayUrl` is the key of the connection pool, the relay-list sets, the NIP-11 + * cache and every per-relay stat map. OkHttp collapses both spellings to one host (below), + * so the app opens two sockets to the same relay and counts it twice everywhere. + */ + @Test + fun gapZeroCompressionSplitsOneRelayIntoTwoIdentities() { + assertNotEquals(canonical.normalizeRelayUrl(), expanded.normalizeRelayUrl()) + // ...even though they are literally the same host on the wire: + assertEquals(host(canonical), host(expanded)) + } + + /** + * GAP 2 — a schemeless IPv6 literal defaults to `wss://`. + * + * `isLocalHost()` only knows 127.0.0.1 / localhost / umbrel / 192.168. / .local, so an + * Yggdrasil address falls through to the clearnet default. No CA issues certificates for + * `0200::/7` literals, so the resulting wss:// url can only ever fail its TLS handshake. + */ + @Test + fun gapSchemelessYggdrasilAddressDefaultsToWss() { + assertEquals("wss://[201:d0e:9ba5:8bbc::1]:8080/", "[201:d0e:9ba5:8bbc::1]:8080".normalizeRelayUrl().url) + assertFalse("ws://[201:d0e:9ba5:8bbc::1]:8080/".normalizeRelayUrl().isLocalHost()) + } + + /** + * GAP 3 — an unbracketed IPv6 literal is rejected outright. + * + * `yggdrasilctl getSelf` prints the address unbracketed, which is what a user copies into + * the "add a relay" field. Normalization returns null and `RelayUrlEditField.submitRelay` + * has no else branch, so the Add button silently does nothing. + */ + @Test + fun gapUnbracketedYggdrasilAddressIsRejected() { + assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5")) + assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:8080")) + // Bracketing it by hand is the only accepted form. + assertTrue(RelayUrlNormalizer.normalizeOrNull("[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]:8080") != null) + } +} From 067d68b89cced66b7ea0caede630004310bb7c04 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 22:28:07 +0000 Subject: [PATCH 2/3] feat(relay): canonicalize IPv6 relay urls and support overlay meshes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four gaps the previous commit characterized for relays on an Yggdrasil overlay, where every relay is an IPv6 literal in 0200::/7 served over plain ws:// (no DNS, no CA-issuable certificate). New quartz/utils/Ipv6.kt: pure-Kotlin literal parsing, RFC 5952 canonical formatting and range classification. No java.net, so it works on every KMP target. - Canonicalize the bracketed host in RelayUrlNormalizer.norm(). RFC 4291 lets one address be spelled many ways and the RFC 3986 pass only folded hex case, so two spellings survived as two NormalizedRelayUrl values for one host — and that value keys the connection pool, the relay-list sets, the NIP-11 cache and the per-relay stats, so the app dialed one relay twice. The canonical form matches what OkHttp renders when it dials; the tests assert that agreement differentially. Relay lists rehydrate through normalizeOrNull, so stored entries fold on load and no migration is needed. - Add isOverlayNetwork() for 0200::/7 and default those relays to ws://: nothing can issue a certificate for the range, so wss:// could only fail its handshake, and the overlay already encrypts end to end. - Teach isLocalHost() the IPv6 twins of the literals it already knew — ::1, fc00::/7 and fe80::/10 — so a relay on one skips TLS and Tor and stays out of published relay lists, as its IPv4 equivalent already did. - Never route an overlay relay through Tor: the range is unroutable there, so proxying guaranteed failure rather than privacy. TorRelayEvaluation covers both the Android and desktop relay paths; RoleBasedHttpClientBuilder covers non-relay HTTP. - Bracket a bare IPv6 literal automatically (what yggdrasilctl getSelf prints), but only when the whole string parses as an address, so host:port and addressable pointers still fall through. RelayUrlEditField now shows an error instead of no-opping, fixing the silent Add button for all invalid input. Mesh relays are still published in NIP-65 and offered by the outbox model; the plan doc explains why that is left as a maintainer's call, and records that no live socket test was possible here (the container has no IPv6 stack). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk --- .../RoleBasedHttpClientBuilder.kt | 6 +- .../relays/common/RelayUrlEditField.kt | 17 ++ amethyst/src/main/res/values/strings.xml | 1 + .../commons/tor/TorRelayEvaluation.kt | 6 + .../commons/tor/YggdrasilTorRoutingTest.kt | 25 +- .../plans/2026-08-04-yggdrasil-ipv6-relays.md | 149 +++++----- .../relay/normalizer/NormalizedRelayUrl.kt | 3 + .../relay/normalizer/RelayUrlNormalizer.kt | 93 +++++- .../com/vitorpamplona/quartz/utils/Ipv6.kt | 264 ++++++++++++++++++ .../vitorpamplona/quartz/utils/Ipv6Test.kt | 139 +++++++++ .../YggdrasilCompatCharacterizationTest.kt | 118 +++++--- 11 files changed, 696 insertions(+), 125 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6Test.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt index b2ca4dcafc..1f2a0722e1 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/privacyOptions/RoleBasedHttpClientBuilder.kt @@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder( normalizedUrl: String, final: Boolean, ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) { + // Overlay-mesh hosts (0200::/7) are reachable only through the local mesh + // interface — Tor cannot route the range, so proxying only breaks the fetch. false } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { true @@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder( isOnionRelaysActive: Boolean, final: Boolean, ): Boolean = - if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) { + if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) { false } else if (RelayUrlNormalizer.isOnion(normalizedUrl)) { isOnionRelaysActive diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index e14ca5c66f..d523058cfb 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -170,6 +170,7 @@ fun RelayUrlEditField( nav: INav, ) { var url by remember { mutableStateOf("") } + var isInvalid by remember { mutableStateOf(false) } fun submitRelay() { if (url.isNotBlank()) { @@ -177,7 +178,13 @@ fun RelayUrlEditField( if (relay != null) { onNewRelay(relay) url = "" + isInvalid = false relaySuggestions.reset() + } else { + // Without this the Add button is a silent no-op, which reads as a broken button. + // Bare IPv6 literals are the common way to land here: an overlay-mesh address + // pasted straight out of `yggdrasilctl getSelf` needs brackets to carry a port. + isInvalid = true } } } @@ -189,8 +196,18 @@ fun RelayUrlEditField( value = url, onValueChange = { url = it + isInvalid = false relaySuggestions.processInput(it) }, + isError = isInvalid, + supportingText = { + if (isInvalid) { + Text( + text = stringRes(R.string.relay_url_not_valid), + color = MaterialTheme.colorScheme.error, + ) + } + }, placeholder = { Text( text = "server.com", diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index 1843dbfdd4..2221700aea 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -214,6 +214,7 @@ Percentage of successful connections to the relay Search and add user Add a Relay + Not a valid relay address. Use a host name, or an IP address in brackets (for example [201:d0e:9ba5:8bbc::1]:8080). My @tag name Display Name My display name diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt index 42987dfb56..40695348b8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/tor/TorRelayEvaluation.kt @@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.commons.tor import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork class TorRelayEvaluation( val torSettings: TorRelaySettings, @@ -36,6 +37,11 @@ class TorRelayEvaluation( } else { if (relay.isLocalHost()) { false + } else if (relay.isOverlayNetwork()) { + // An overlay-mesh relay (0200::/7, e.g. Yggdrasil) is reachable only through the + // local mesh interface: Tor cannot route the range at all, so proxying it would + // guarantee failure rather than privacy. The overlay already encrypts end to end. + false } else if (relay.isOnion()) { // .onion is only reachable over Tor regardless of any other classification. torSettings.onionRelaysViaTor diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt index 68a8941821..9a2dbb577b 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt @@ -26,15 +26,16 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue /** - * GAP 4 — an Yggdrasil relay is classified as a plain clearnet relay, so with Tor on it is - * dialed through the SOCKS proxy. Tor cannot route `0200::/7`: the connection can only fail. - * - * Compare `ws://192.168.1.100:8080/`, which [TorRelayEvaluation] correctly keeps off Tor - * because `isLocalHost()` recognizes the LAN prefix. Yggdrasil has no such recognition. + * An overlay-mesh relay (`0200::/7`, e.g. Yggdrasil) must never be dialed through the Tor SOCKS + * proxy: Tor cannot route the range, so proxying guarantees failure rather than privacy. The + * overlay already encrypts end to end and authenticates the peer by its key-derived address. */ class YggdrasilTorRoutingTest { private val yggdrasilRelay = NormalizedRelayUrl("ws://[201:d0e:9ba5:8bbc::1]:8080/") + private val yggdrasilSubnetRelay = NormalizedRelayUrl("ws://[300:1b5d:d0e9:ba58::1]:4848/") private val lanRelay = NormalizedRelayUrl("ws://192.168.1.100:8080/") + private val ulaRelay = NormalizedRelayUrl("ws://[fd12:3456::1]:8080/") + private val clearnetIpv6Relay = NormalizedRelayUrl("wss://[2001:db8::1]:8080/") private fun evaluation(newViaTor: Boolean) = TorRelayEvaluation( @@ -52,14 +53,18 @@ class YggdrasilTorRoutingTest { ) @Test - fun yggdrasilRelayIsSentThroughTorWhileLanRelayIsNot() { + fun overlayRelaysAreNeverTorifiedEvenWhenNewRelaysViaTorIsOn() { val eval = evaluation(newViaTor = true) - assertTrue(eval.useTor(yggdrasilRelay), "Yggdrasil relay is routed via Tor, which cannot reach 0200::/7") - assertFalse(eval.useTor(lanRelay), "LAN relay is correctly kept off Tor") + assertFalse(eval.useTor(yggdrasilRelay), "0200::/8 node address must not be proxied") + assertFalse(eval.useTor(yggdrasilSubnetRelay), "0300::/8 subnet address must not be proxied") + assertFalse(eval.useTor(lanRelay), "LAN relay stays off Tor") + assertFalse(eval.useTor(ulaRelay), "IPv6 unique local address stays off Tor") } @Test - fun yggdrasilRelayWorksOnlyWhenNewRelaysViaTorIsOff() { - assertFalse(evaluation(newViaTor = false).useTor(yggdrasilRelay)) + fun clearnetIpv6RelaysStillFollowTheTorSetting() { + // The overlay exemption must not leak into ordinary IPv6 relays. + assertTrue(evaluation(newViaTor = true).useTor(clearnetIpv6Relay)) + assertFalse(evaluation(newViaTor = false).useTor(clearnetIpv6Relay)) } } diff --git a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md index f854bffaab..33d8aaef31 100644 --- a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md +++ b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md @@ -1,103 +1,114 @@ -# Amethyst over Yggdrasil (IPv6 overlay) — compatibility assessment +# Amethyst over Yggdrasil (IPv6 overlay) -Status: **analysis only** — no behavior changed. Characterization tests landed alongside -this doc pin the current behavior so a fix has a baseline to diff against. +Status: **fixed** — the four gaps found in the original assessment are closed. The last +section records what was deliberately left alone. ## What Yggdrasil looks like to the app Yggdrasil is an encrypted end-to-end mesh. Every node gets an IPv6 address derived from its -public key inside `0200::/7`, and hands out `0300::/8` subnets. Consequences that matter here: +public key inside `0200::/7` (nodes in `0200::/8`, subnets in `0300::/8`). Consequences: -- **No DNS.** A relay on the mesh is addressed as a bracketed IPv6 literal, always. -- **No certificates.** No CA issues for `0200::/7` literals, so relays run plain `ws://`. - This is not a downgrade — the overlay already provides end-to-end encryption and - authenticates the peer by its address. +- **No DNS.** A relay on the mesh is addressed as an IPv6 literal, always. +- **No certificates.** No CA issues for `0200::/7`, so relays run plain `ws://`. Not a + downgrade — the overlay already encrypts end to end and authenticates the peer by an + address derived from its public key. - **On Android it is a `VpnService`**, so the app's default network becomes the VPN network. -- The address is a **stable node identifier**, so publishing it is equivalent to publishing - a long-lived pseudonymous handle for the device. +- `0200::/7` is deprecated NSAP space, so nothing else routes there. An address in the range + is reachable *only* through a running mesh interface — which is what makes it safe to key + behavior off the prefix. -## Verdict +## What was wrong, and what fixed it -A hand-typed `ws://[…]:port` relay works end to end: it normalizes, survives the RFC 3986 -pass, and OkHttp parses and dials it. Nothing in the stack is IPv4-only, `TcpNoDelaySocketFactory` -is family-agnostic, and `network_security_config.xml` permits cleartext globally, so the -`ws://` requirement is already satisfied. +### 1. One relay, two identities -Everything around that happy path is where it degrades. Four gaps, in severity order. +`RelayUrlNormalizer` folded hex case but not zero-compression, so +`[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` stayed two distinct +`NormalizedRelayUrl`s for one host — while OkHttp collapsed both to the same host when +dialing. Since that value keys the connection pool, the relay-list sets, the NIP-11 cache and +the per-relay stat maps, the app opened two sockets to one relay and counted it twice. -### GAP 1 — one relay, two identities (correctness) +**Fix:** new `Ipv6` util (`quartz/utils/Ipv6.kt`) — pure-Kotlin parse, RFC 5952 canonical +format and range classification, no `java.net`, so it works on every KMP target. +`RelayUrlNormalizer.norm()` now canonicalizes the bracketed host. The canonical form is +byte-for-byte what OkHttp renders, so the key the app stores is the host it actually dials — +asserted differentially against OkHttp in `YggdrasilCompatCharacterizationTest`. -`RelayUrlNormalizer` folds hex case but does **not** canonicalize zero-compression or -leading zeros: +Affects every IPv6 relay, not just mesh ones; it only bit Yggdrasil users because on the mesh +a literal is the *only* way to name a relay. No migration needed: relay lists are rehydrated +from event tags through `normalizeOrNull`, so stored entries fold on load. -| input | `NormalizedRelayUrl` | OkHttp host | -|---|---|---| -| `ws://[201:d0e:9ba5:8bbc::1]:8080` | `ws://[201:d0e:9ba5:8bbc::1]:8080/` | `201:d0e:9ba5:8bbc::1` | -| `ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080` | `ws://[201:0d0e:…:0001]:8080/` | `201:d0e:9ba5:8bbc::1` | +### 2. Schemeless entry defaulted to `wss://` -OkHttp collapses both to one host; the app does not. `NormalizedRelayUrl` is the key of the -connection pool (`PoolRequests`, `RelayPool`), every relay-list set, the NIP-11 cache and the -per-relay stat maps — so the same relay written two ways gets **two sockets, two REQ sets and -doubled traffic**, and appears twice in the relay UI. This affects all IPv6 literals, but it -only bites Yggdrasil users in practice, because on the mesh a literal is the *only* way to -name a relay. Fix: canonicalize the bracketed literal to RFC 5952 inside `fix()`. +`isLocalHost()` knew `127.0.0.1` / `localhost` / `//umbrel:` / `192.168.` / `.local`, so a +mesh address fell through to the clearnet default and produced a `wss://` url whose TLS +handshake could never succeed. -### GAP 2 — schemeless entry defaults to `wss://` (dead end) +**Fix:** new `RelayUrlNormalizer.isOverlayNetwork()` recognizes `0200::/7` and joins +`isOnion` / `isLocalHost` in choosing `ws://`. Clearnet IPv6 (`2001:db8::1`) still gets +`wss://`. -`RelayUrlNormalizer.isLocalHost()` recognizes `127.0.0.1`, `localhost`, `//umbrel:`, -`192.168.`, `.local:` / `.local/`. An Yggdrasil address matches none of them, so a schemeless -`[201:…]:8080` falls through to the clearnet default and becomes `wss://[201:…]:8080/` — a -URL whose TLS handshake can never succeed. The user must know to type `ws://` themselves. -Fix: teach `isLocalHost()` (or a sibling `isOverlayNetwork()`) the `0200::/7` prefix. +`isLocalHost()` separately grew the IPv6 twins of the literals it already knew — `::1` +(loopback), `fc00::/7` (unique local, the 192.168. analogue) and `fe80::/10` (link-local). +Those are the same question every caller is asking, so a relay on one now correctly skips TLS +and Tor and stays out of published relay lists. -### GAP 3 — unbracketed literal is silently rejected (UX) +### 3. Unbracketed literal silently rejected -`yggdrasilctl getSelf` prints the address **unbracketed**, which is exactly what a user -copies into the "add a relay" box. `isBareHostAndPath()` rejects it (correctly — it is -ambiguous with a scheme), so `normalizeOrNull` returns null. But -`RelayUrlEditField.submitRelay()` has no else branch: the Add button just does nothing, with -no error. Fix: either auto-bracket a candidate that parses as an IPv6 address, or surface a -validation message instead of a silent no-op. +`yggdrasilctl getSelf` prints the address unbracketed — exactly what gets pasted into "add a +relay". Normalization returned null (correctly: it is ambiguous with a scheme) and +`RelayUrlEditField.submitRelay` had no else branch, so the Add button did nothing at all. -### GAP 4 — Tor routing sends mesh traffic into the SOCKS proxy (breaks the relay) +**Fix, two halves:** +- `fix()` brackets a bare literal automatically, but only when the whole string parses as an + IPv6 address — so `31990:hex:dtag` (addressable pointer), `abcd:1234` (host:port) and + `relay.example.com:8080` still fall through untouched. +- The edit field now sets `isError` and shows `relay_url_not_valid` instead of no-opping. + That fixes the dead button for *all* invalid input, not just IPv6. -`TorRelayEvaluation` classifies relays as localhost / onion / dm / trusted / new. Yggdrasil -lands in **new**, so with Tor on and the default "new relays via Tor", the relay is dialed -through the Tor SOCKS proxy — which cannot route `0200::/7`. The connection can only fail. -Compare `ws://192.168.1.100:8080/`, which is correctly kept off Tor because `isLocalHost()` -knows the LAN prefix. Today the only workaround is turning "new relays via Tor" off, which -weakens the setting for every genuine clearnet relay. The same gap exists on desktop -(`DesktopHttpClient`) and for non-relay HTTP (`RoleBasedHttpClientBuilder`). Fixing GAP 2's -prefix check fixes this one too, since both read the same predicate. +### 4. Tor routing broke mesh relays -## Propagation / privacy note (not a bug, a decision) +`TorRelayEvaluation` classified mesh relays as "new", so with Tor on and the default "new +relays via Tor" they were dialed through the SOCKS proxy — which cannot route `0200::/7`. +Guaranteed failure, not privacy. -An Yggdrasil relay is not filtered out of NIP-65 publishing (`AdvertisedRelayInfoTag` only -rejects localhost) nor out of the outbox model -(`RelayListRecommendationProcessor.filterValidRelays`). So a mesh relay in your relay list is -**published to public relays and recommended to other users**. Two effects: +**Fix:** `useTor()` returns false for `isOverlayNetwork()`, checked right after the localhost +branch. Both the Android and desktop relay paths delegate here (`TorRelayState`, +`DesktopHttpClient`), so one change covers both. `RoleBasedHttpClientBuilder` got the same +treatment for non-relay HTTP (images, previews, NIP-05, money ops). Clearnet IPv6 relays keep +following the Tor setting — asserted in `YggdrasilTorRoutingTest`. + +## Deliberately not changed + +**Mesh relays are still published and recommended.** `AdvertisedRelayInfoTag` (NIP-65) and +`RelayListRecommendationProcessor.filterValidRelays` only exclude localhost, so a mesh relay +in your relay list is still published to public relays and offered to other users via the +outbox model. Two consequences worth a maintainer's decision: - Peers not on the mesh dial `[201:…]` and burn reconnect attempts on an unreachable host. - Your Yggdrasil address — a stable, key-derived node identifier — becomes public. -Onion relays get special handling here (`hasOnionConnection` gates whether they are even -considered). An overlay-network classification would let Yggdrasil be treated the same way. +Onion relays already have precedent for both readings: they *are* published, but +`filterValidRelays` gates them behind `hasOnionConnection`. The equivalent for overlay relays +would be a `hasMeshConnection` gate. That is a product call about whether mesh relays are +meant to be discoverable, so it is flagged rather than decided here. -## Not covered +## Not verified here -- **No live socket test.** The analysis container has no IPv6 stack at all - (`AF_INET6` → `EAFNOSUPPORT`), so everything above is verified below the socket: URL - normalization, OkHttp URL/host parsing, and the Tor routing decision. An on-device run - against a real mesh relay is still needed to confirm the happy path end to end. +- **No live socket test.** The analysis container has no IPv6 stack at all (`AF_INET6` → + `EAFNOSUPPORT`), so everything is verified below the socket: normalization, OkHttp URL/host + agreement, and the Tor routing decision. An on-device run against a real mesh relay is + still needed to confirm the happy path end to end. - **Android VPN interaction untested.** `ConnectivityFlow` uses - `registerDefaultNetworkCallback`, so it follows the app's default network into the VPN. - Whether `isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends - on whether that app declares underlying networks; worth checking on device before assuming + `registerDefaultNetworkCallback`, so it follows the app into the VPN network. Whether + `isMeteredOrMobileData()` reads correctly through Yggdrasil's `VpnService` depends on + whether that app declares underlying networks — worth checking on device before assuming data-saving mode behaves. - Media loading (Coil) and NIP-05 resolution against mesh hosts were not exercised. ## Tests -- `quartz/src/jvmAndroidTest/…/relay/YggdrasilCompatCharacterizationTest.kt` — GAPs 1–3 plus - the working happy path. -- `commons/src/commonTest/…/tor/YggdrasilTorRoutingTest.kt` — GAP 4. +- `quartz/…/utils/Ipv6Test.kt` — parser, RFC 5952 formatting, range classification. +- `quartz/…/relay/YggdrasilCompatCharacterizationTest.kt` — normalization end to end, plus + the differential assertions that our identity matches the host OkHttp dials. +- `commons/…/tor/YggdrasilTorRoutingTest.kt` — overlay relays never Torified, clearnet IPv6 + still follows the setting. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index ac3f69a1f3..0196ac0bbf 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -47,3 +47,6 @@ fun NormalizedRelayUrl.toHttp() = fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) + +/** True for a relay inside an encrypted IPv6 overlay mesh. See [RelayUrlNormalizer.isOverlayNetwork]. */ +fun NormalizedRelayUrl.isOverlayNetwork() = RelayUrlNormalizer.isOverlayNetwork(this.url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index bc5c524482..18d83c66af 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.normalizer import androidx.collection.LruCache +import com.vitorpamplona.quartz.utils.Ipv6 import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Rfc3986 import kotlinx.coroutines.CancellationException @@ -44,7 +45,51 @@ class RelayUrlNormalizer { url.contains("//umbrel:") || url.contains("192.168.") || url.contains(".local:") || - url.contains(".local/") + url.contains(".local/") || + isPrivateIpv6(url) + + /** + * The IPv6 twins of the literals above: `::1` (127.0.0.1), `fc00::/7` unique local + * addresses (192.168.0.0/16) and `fe80::/10` link-local. All three name a host that + * only exists on this machine or this LAN, which is what every caller of [isLocalHost] + * means by the question — so a relay on one must not be Torified, must not need TLS, + * and must not be advertised to the network. + */ + private fun isPrivateIpv6(url: String): Boolean { + val bytes = ipv6HostOf(url) ?: return false + return Ipv6.isLoopback(bytes) || Ipv6.isUniqueLocal(bytes) || Ipv6.isLinkLocal(bytes) + } + + /** + * True for a relay inside an encrypted IPv6 overlay mesh — today `0200::/7`, the range + * Yggdrasil derives node addresses and subnets from. + * + * Unlike [isLocalHost] this is not a private address: it is reachable from anywhere on + * the mesh. But it is unreachable *off* the mesh, which has two consequences the relay + * stack has to honour — it can never be dialed through a SOCKS/Tor proxy, and it can + * never present a CA-issued certificate, so it speaks plain `ws://`. Both are safe: + * the overlay already encrypts end to end and authenticates the peer by its address, + * which is derived from the peer's public key. + */ + fun isOverlayNetwork(url: String): Boolean { + val bytes = ipv6HostOf(url) ?: return false + return Ipv6.isOverlayMesh(bytes) + } + + /** + * Extracts the bracketed IPv6 host of [url] as raw bytes, dropping any `%zone` suffix. + * Returns null — cheaply, on a single `indexOf` — for the overwhelmingly common case of + * a url with a DNS host. + */ + private fun ipv6HostOf(url: String): ByteArray? { + val open = url.indexOf('[') + if (open < 0) return null + val close = url.indexOf(']', open + 1) + if (close <= open + 1) return null + val zone = url.indexOf('%', open + 1) + val end = if (zone in (open + 1) until close) zone else close + return Ipv6.parse(url.substring(open + 1, end)) + } fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") @@ -82,7 +127,31 @@ class RelayUrlNormalizer { return false } - private fun norm(url: String) = NormalizedRelayUrl(Rfc3986.normalize(url)) + private fun norm(url: String) = NormalizedRelayUrl(canonicalizeIpv6Host(Rfc3986.normalize(url))) + + /** + * Rewrites a bracketed IPv6 host into its RFC 5952 canonical form. + * + * RFC 4291 lets one address be spelled many ways, and the RFC 3986 pass only folds hex + * case — so `[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` + * survive as two different [NormalizedRelayUrl]s for one host. That value keys the + * connection pool, the relay-list sets, the NIP-11 cache and the per-relay stats, so the + * app would dial the same relay twice and count it twice. OkHttp canonicalizes to this + * exact form when it dials, so folding here makes the stored key the host on the wire. + * + * Returns [url] itself — no allocation — when there is no literal or it is already + * canonical, which is every url with a DNS host. + */ + private fun canonicalizeIpv6Host(url: String): String { + val open = url.indexOf('[') + if (open < 0) return url + val close = url.indexOf(']', open + 1) + if (close <= open + 1) return url + val inner = url.substring(open + 1, close) + val canonical = Ipv6.canonicalizeOrNull(inner) ?: return url + if (canonical == inner) return url + return url.substring(0, open + 1) + canonical + url.substring(close) + } private fun isInvisible(c: Char) = c == '\u200B' || c == '\u200C' || c == '\u200D' || c == '\u2060' || c == '\uFEFF' @@ -267,15 +336,29 @@ class RelayUrlNormalizer { } // protocol-relative urls (`//host/`) are just missing the scheme - val bare = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed - if (bare.length < 4) return null + val protocolRelative = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed + if (protocolRelative.length < 4) return null + + // A bare IPv6 literal is missing its brackets, not malformed. This is the shape a + // user actually has in hand — `yggdrasilctl getSelf` prints the address unbracketed + // — and without the brackets `isBareHostAndPath` rejects it below as a host with too + // many colons. Only a string that parses as a whole address is bracketed, so an + // addressable-event pointer (`31990:hex:dtag`) or a `host:port` still falls through. + val bare = + if (protocolRelative[0] != '[' && Ipv6.isLiteral(protocolRelative)) { + "[$protocolRelative]" + } else { + protocolRelative + } if (!isBareHostAndPath(bare)) { Log.d("RelayUrlNormalizer") { "Rejected $url" } return null } - return if (isOnion(bare) || isLocalHost(bare)) { + // Overlay and localhost relays cannot hold a certificate, so wss:// could only ever + // fail its handshake. Both carry their own encryption, so ws:// is not a downgrade. + return if (isOnion(bare) || isLocalHost(bare) || isOverlayNetwork(bare)) { "ws://$bare" } else { "wss://$bare" diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt new file mode 100644 index 0000000000..27d0d724ed --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt @@ -0,0 +1,264 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * Pure-Kotlin IPv6 literal parsing, RFC 5952 canonical formatting and address + * classification. No `java.net`, so it works on every KMP target. + * + * Exists because relay identity is a *string*: [com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl] + * is the key of the connection pool, the relay-list sets, the NIP-11 cache and every + * per-relay stat map. RFC 4291 lets one address be written many ways + * (`[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]` and `[201:d0e:9ba5:8bbc::1]` are the same + * host), and without folding them the app treats one relay as two — two sockets, two REQ + * sets, two rows in the UI. The canonical form here matches what OkHttp renders, so the + * key the app stores is the host it actually dials. + */ +object Ipv6 { + /** Longest legal literal is 45 chars (`::ffff:` + dotted quad is shorter than 8 full groups). */ + private const val MAX_LITERAL = 45 + + /** + * Parses a bracket-less, zone-less IPv6 literal into its 16 bytes, or null when [address] + * is not a valid literal. Accepts `::` compression and a trailing dotted quad + * (`::ffff:192.168.1.1`). + */ + fun parse(address: String): ByteArray? { + val len = address.length + if (len < 2 || len > MAX_LITERAL) return null + + val out = ByteArray(16) + // Bytes written so far, counting from the left. When a `::` is present the bytes after + // it are written contiguously here and shifted to the right end at the very end. + var fill = 0 + var gapAt = -1 + var i = 0 + + if (address[0] == ':') { + if (address[1] != ':') return null + gapAt = 0 + i = 2 + if (i == len) return out + } + + while (true) { + val groupStart = i + var value = 0 + var digits = 0 + while (i < len) { + val digit = hexDigit(address[i]) + if (digit < 0) break + if (digits == 4) return null + value = (value shl 4) or digit + digits++ + i++ + } + + if (i < len && address[i] == '.') { + // Trailing dotted quad: occupies the last four bytes, so nothing may follow it. + if (fill > 12) return null + if (!parseIpv4Into(address, groupStart, len, out, fill)) return null + fill += 4 + i = len + break + } + + if (digits == 0) return null + if (fill + 2 > 16) return null + out[fill++] = (value ushr 8).toByte() + out[fill++] = value.toByte() + + if (i == len) break + if (address[i] != ':') return null + i++ + if (i == len) return null // a single trailing ':' is not a valid literal + if (address[i] == ':') { + if (gapAt >= 0) return null // only one `::` allowed + gapAt = fill + i++ + if (i == len) break + } + } + + if (gapAt < 0) { + if (fill != 16) return null + } else { + // `::` must stand for at least one omitted group. + if (fill == 16) return null + val tail = fill - gapAt + for (k in tail - 1 downTo 0) { + out[16 - tail + k] = out[gapAt + k] + out[gapAt + k] = 0 + } + } + return out + } + + /** + * RFC 5952 text form: lowercase hex, no leading zeros, and the longest run of two or more + * zero groups replaced by `::` (leftmost run wins a tie). IPv4-mapped addresses keep their + * dotted tail. This is byte-for-byte what OkHttp prints for the same address. + */ + fun format(bytes: ByteArray): String { + require(bytes.size == 16) { "An IPv6 address is 16 bytes, got ${bytes.size}" } + + var bestStart = -1 + var bestLen = 0 + var i = 0 + while (i < 16) { + if (bytes[i] == ZERO && bytes[i + 1] == ZERO) { + val runStart = i + var j = i + while (j < 16 && bytes[j] == ZERO && bytes[j + 1] == ZERO) j += 2 + if (j - runStart > bestLen) { + bestLen = j - runStart + bestStart = runStart + } + i = j + } else { + i += 2 + } + } + // A single zero group is written as `0`, never as `::`. + if (bestLen < 4) { + bestStart = -1 + bestLen = 0 + } + + val out = StringBuilder(39) + // ::ffff:a.b.c.d — IPv4-mapped addresses read as IPv4 everywhere else, so keep them that way. + if (bestStart == 0 && bestLen == 10 && bytes[10] == ALL_ONES && bytes[11] == ALL_ONES) { + out.append("::ffff:") + appendIpv4(out, bytes, 12) + return out.toString() + } + + i = 0 + while (i < 16) { + if (i == bestStart) { + out.append(':') + i += bestLen + if (i == 16) out.append(':') + } else { + if (i > 0) out.append(':') + out.append(group(bytes, i).toString(16)) + i += 2 + } + } + return out.toString() + } + + /** + * Canonicalizes a bracket-less literal, preserving any `%zone` suffix verbatim (in URLs the + * zone arrives percent-encoded, e.g. `fe80::1%25wlan0`). Returns null when [address] is not + * a valid literal. + */ + fun canonicalizeOrNull(address: String): String? { + val zoneAt = address.indexOf('%') + if (zoneAt < 0) return parse(address)?.let(::format) + val bytes = parse(address.substring(0, zoneAt)) ?: return null + return format(bytes) + address.substring(zoneAt) + } + + /** True when [address] is a valid bracket-less literal that names more than one group. */ + fun isLiteral(address: String): Boolean = address.indexOf(':') >= 0 && parse(address) != null + + /** `::1` — the IPv6 loopback, twin of 127.0.0.1. */ + fun isLoopback(bytes: ByteArray): Boolean { + for (i in 0 until 15) if (bytes[i] != ZERO) return false + return bytes[15] == ONE + } + + /** `fe80::/10` — link-local, only meaningful on the interface it came from. */ + fun isLinkLocal(bytes: ByteArray): Boolean = bytes[0] == FE.toByte() && (bytes[1].toInt() and 0xC0) == 0x80 + + /** `fc00::/7` — unique local addresses, the IPv6 twin of 192.168.0.0/16. */ + fun isUniqueLocal(bytes: ByteArray): Boolean = (bytes[0].toInt() and 0xFE) == 0xFC + + /** + * `0200::/7` — the range Yggdrasil derives node addresses (`0200::/8`) and subnets + * (`0300::/8`) from. Formally deprecated NSAP space, so nothing else routes here: an + * address in this range is reachable only through a running mesh interface, is already + * end-to-end encrypted by the overlay, and can never hold a CA-issued certificate. + */ + fun isOverlayMesh(bytes: ByteArray): Boolean = (bytes[0].toInt() and 0xFE) == 0x02 + + private fun group( + bytes: ByteArray, + at: Int, + ) = ((bytes[at].toInt() and 0xFF) shl 8) or (bytes[at + 1].toInt() and 0xFF) + + private fun appendIpv4( + out: StringBuilder, + bytes: ByteArray, + from: Int, + ) { + for (k in 0 until 4) { + if (k > 0) out.append('.') + out.append(bytes[from + k].toInt() and 0xFF) + } + } + + /** + * Parses `a.b.c.d` in `[from, to)` into four bytes at [at]. Leading zeros are rejected — + * they invite the octal reading that makes `010.1.1.1` ambiguous across resolvers. + */ + private fun parseIpv4Into( + text: String, + from: Int, + to: Int, + out: ByteArray, + at: Int, + ): Boolean { + var i = from + for (octet in 0 until 4) { + if (octet > 0) { + if (i >= to || text[i] != '.') return false + i++ + } + var value = 0 + var digits = 0 + while (i < to && text[i] in '0'..'9') { + if (digits == 3) return false + if (digits == 1 && value == 0) return false // leading zero + value = value * 10 + (text[i] - '0') + digits++ + i++ + } + if (digits == 0 || value > 255) return false + out[at + octet] = value.toByte() + } + return i == to + } + + private fun hexDigit(c: Char): Int = + when (c) { + in '0'..'9' -> c - '0' + in 'a'..'f' -> c - 'a' + 10 + in 'A'..'F' -> c - 'A' + 10 + else -> -1 + } + + private const val FE = 0xFE + private const val ZERO = 0.toByte() + private const val ONE = 1.toByte() + private const val ALL_ONES = 0xFF.toByte() +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6Test.kt new file mode 100644 index 0000000000..66d4059de4 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6Test.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class Ipv6Test { + private fun canonical(address: String) = Ipv6.canonicalizeOrNull(address) + + @Test + fun rfc5952CanonicalForm() { + // leading zeros suppressed, hex lowercased + assertEquals("201:d0e:9ba5:8bbc::1", canonical("201:0d0e:9ba5:8bbc:0000:0000:0000:0001")) + assertEquals("201:d0e:9ba5:8bbc::1", canonical("201:D0E:9BA5:8BBC::1")) + assertEquals("2001:db8::1", canonical("2001:0DB8:0000:0000:0000:0000:0000:0001")) + // already canonical stays put + assertEquals("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5", canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5")) + assertEquals("::", canonical("::")) + assertEquals("::1", canonical("0:0:0:0:0:0:0:1")) + } + + @Test + fun singleZeroGroupIsNotCompressed() { + // RFC 5952 §4.2.2: `::` must not stand for a single group. + assertEquals("2001:db8:0:1:1:1:1:1", canonical("2001:db8:0:1:1:1:1:1")) + } + + @Test + fun longestZeroRunWinsAndTiesGoLeft() { + assertEquals("2001:0:0:1::1", canonical("2001:0:0:1:0:0:0:1")) + // equal runs of two groups: the leftmost is the one compressed + assertEquals("2001::1:1:0:0:1", canonical("2001:0:0:1:1:0:0:1")) + } + + @Test + fun ipv4MappedKeepsDottedTail() { + assertEquals("::ffff:192.168.1.1", canonical("::ffff:192.168.1.1")) + assertEquals("::ffff:127.0.0.1", canonical("::FFFF:127.0.0.1")) + // an embedded quad that is not ipv4-mapped collapses to plain hex + assertEquals("::c0a8:101", canonical("::192.168.1.1")) + } + + @Test + fun zoneIdIsPreservedVerbatim() { + // In URLs the zone arrives percent-encoded. + assertEquals("fe80::1%25wlan0", canonical("fe80:0000:0000:0000:0000:0000:0000:0001%25wlan0")) + } + + @Test + fun rejectsMalformedLiterals() { + assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2")) // too few groups + assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:1234")) // too many + assertNull(canonical("201::9ba5::1")) // two `::` + assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:")) // trailing colon + assertNull(canonical("201:d0e:9ba5:8bbc:f4a1:d34:1c2:gggg")) // non-hex + assertNull(canonical("201:00d0e:9ba5:8bbc::1")) // five-digit group + assertNull(canonical("192.168.1.1")) // ipv4 + assertNull(canonical("localhost")) + assertNull(canonical("::ffff:192.168.1")) // short quad + assertNull(canonical("::ffff:010.1.1.1")) // leading zero in quad + assertNull(canonical("0:0:0:0:0:0:0:0:0")) + } + + @Test + fun compressionMustCoverAtLeastOneGroup() { + // A `::` that stands for nothing is not a legal literal. + assertNull(canonical("1:2:3:4:5:6:7::8")) + } + + @Test + fun classifiesYggdrasilAndPrivateRanges() { + assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("201:d0e:9ba5:8bbc::1")!!), "0200::/8 node address") + assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("300:1b5d:d0e9:ba58::1")!!), "0300::/8 subnet address") + assertTrue(Ipv6.isOverlayMesh(Ipv6.parse("2ff::1")!!)) + assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("2001:db8::1")!!), "documentation range is clearnet") + assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("400::1")!!), "just past 0200::/7") + assertFalse(Ipv6.isOverlayMesh(Ipv6.parse("::1")!!)) + + assertTrue(Ipv6.isLoopback(Ipv6.parse("::1")!!)) + assertFalse(Ipv6.isLoopback(Ipv6.parse("::2")!!)) + assertFalse(Ipv6.isLoopback(Ipv6.parse("::")!!)) + + assertTrue(Ipv6.isLinkLocal(Ipv6.parse("fe80::1")!!)) + assertTrue(Ipv6.isLinkLocal(Ipv6.parse("febf::1")!!)) + assertFalse(Ipv6.isLinkLocal(Ipv6.parse("fec0::1")!!)) + + assertTrue(Ipv6.isUniqueLocal(Ipv6.parse("fd00::1")!!)) + assertTrue(Ipv6.isUniqueLocal(Ipv6.parse("fc00::1")!!)) + assertFalse(Ipv6.isUniqueLocal(Ipv6.parse("fe00::1")!!)) + } + + @Test + fun isLiteralDiscriminatesAgainstNonAddresses() { + assertTrue(Ipv6.isLiteral("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5")) + assertTrue(Ipv6.isLiteral("201:d0e:9ba5:8bbc::1")) + // Things a relay-url field realistically receives, none of which may pass as an address. + assertFalse(Ipv6.isLiteral("relay.example.com:8080")) + assertFalse(Ipv6.isLiteral("wss:")) + assertFalse(Ipv6.isLiteral("localhost:4869")) + assertFalse(Ipv6.isLiteral("31990:abcdef:mydtag"), "addressable event pointer") + assertFalse(Ipv6.isLiteral("abcd:1234")) + assertFalse(Ipv6.isLiteral("nos.lol")) + } + + @Test + fun roundTripsEveryFormOfTheSameAddress() { + val forms = + listOf( + "201:d0e:9ba5:8bbc:0:0:0:1", + "201:0d0e:9ba5:8bbc:0000:0000:0000:0001", + "201:d0e:9ba5:8bbc::1", + "201:D0E:9BA5:8BBC::0001", + ) + val canonicalForms = forms.map { canonical(it) }.toSet() + assertEquals(setOf("201:d0e:9ba5:8bbc::1"), canonicalForms) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt index 638858dbd8..160ca7c9e2 100644 --- a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt @@ -22,29 +22,30 @@ package com.vitorpamplona.quartz.nip01Core.relay import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import okhttp3.Request import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse -import kotlin.test.assertNotEquals -import kotlin.test.assertNull +import kotlin.test.assertNotNull import kotlin.test.assertTrue /** - * Characterization of how relay URLs on an Yggdrasil overlay behave today. + * Relay URLs on an Yggdrasil overlay, end to end through the normalizer. * - * Yggdrasil gives every node an IPv6 address inside `0200::/7` (nodes) and hands out - * `0300::/8` subnets, with no DNS and no CA-issuable certificate. A relay on the mesh is - * therefore always reached as a **bracketed IPv6 literal over plain `ws://`** — a shape - * the relay stack only partially handles. + * Yggdrasil gives every node an IPv6 address inside `0200::/7` (nodes in `0200::/8`, subnets in + * `0300::/8`), with no DNS and no CA-issuable certificate. A relay on the mesh is therefore + * always a **bracketed IPv6 literal over plain `ws://`**. * - * These tests document the CURRENT behavior (including the gaps) so a later fix has a - * baseline to diff against. Each gap is marked GAP with what a user sees. + * The differential assertions against OkHttp are the point of this file living in + * `jvmAndroidTest`: OkHttp is what actually dials the socket, so a normalized url that + * disagrees with OkHttp's own canonical host is a relay the app tracks under a name it does + * not connect to. */ class YggdrasilCompatCharacterizationTest { - // Same node, three legal RFC 4291 spellings of one address. + // Same node, several legal RFC 4291 spellings of one address. private val canonical = "ws://[201:d0e:9ba5:8bbc::1]:8080" private val expanded = "ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080" private val uppercase = "ws://[201:D0E:9BA5:8BBC::1]:8080" @@ -66,51 +67,90 @@ class YggdrasilCompatCharacterizationTest { } @Test - fun yggdrasilSubnetAddressesAndUppercaseHexWork() { + fun yggdrasilSubnetAddressesWork() { assertEquals("ws://[300:1b5d:d0e9:ba58::1]:4848/", "ws://[300:1b5d:d0e9:ba58::1]:4848".normalizeRelayUrl().url) - // Hex case IS folded, so the uppercase spelling collapses onto the canonical one. - assertEquals(canonical.normalizeRelayUrl(), uppercase.normalizeRelayUrl()) } /** - * GAP 1 — zero-compression is NOT canonicalized, so one relay gets two identities. - * - * `NormalizedRelayUrl` is the key of the connection pool, the relay-list sets, the NIP-11 - * cache and every per-relay stat map. OkHttp collapses both spellings to one host (below), - * so the app opens two sockets to the same relay and counts it twice everywhere. + * Every legal spelling of one address collapses to one [NormalizedRelayUrl] — the key of the + * connection pool, the relay-list sets, the NIP-11 cache and the per-relay stat maps. Without + * this the app dials one relay twice and shows it twice. */ @Test - fun gapZeroCompressionSplitsOneRelayIntoTwoIdentities() { - assertNotEquals(canonical.normalizeRelayUrl(), expanded.normalizeRelayUrl()) - // ...even though they are literally the same host on the wire: - assertEquals(host(canonical), host(expanded)) + fun everySpellingOfOneAddressIsOneRelay() { + val identities = listOf(canonical, expanded, uppercase).map { it.normalizeRelayUrl() }.toSet() + assertEquals(setOf("ws://[201:d0e:9ba5:8bbc::1]:8080/"), identities.map { it.url }.toSet()) + // ...and that one identity is the host OkHttp dials for all of them. + assertEquals(setOf("201:d0e:9ba5:8bbc::1"), listOf(canonical, expanded, uppercase).map { host(it) }.toSet()) + } + + @Test + fun normalizedIdentityAlwaysMatchesTheHostOkHttpDials() { + listOf( + "ws://[201:0d0e:9ba5:8bbc:0000:0000:0000:0001]:8080", + "ws://[300:1b5d:d0e9:ba58:0:0:0:1]:4848", + "ws://[2001:0DB8:0000:0000:0000:0000:0000:0001]:7777", + "ws://[::1]:4869", + ).forEach { raw -> + val normalized = raw.normalizeRelayUrl().url + assertEquals(host(normalized), host(raw), "identity for $raw disagrees with the dialed host") + } } /** - * GAP 2 — a schemeless IPv6 literal defaults to `wss://`. - * - * `isLocalHost()` only knows 127.0.0.1 / localhost / umbrel / 192.168. / .local, so an - * Yggdrasil address falls through to the clearnet default. No CA issues certificates for - * `0200::/7` literals, so the resulting wss:// url can only ever fail its TLS handshake. + * A schemeless overlay address defaults to `ws://`: no CA issues certificates for + * `0200::/7`, so `wss://` could only ever fail its handshake. The mesh already encrypts + * end to end, so this is not a downgrade. */ @Test - fun gapSchemelessYggdrasilAddressDefaultsToWss() { - assertEquals("wss://[201:d0e:9ba5:8bbc::1]:8080/", "[201:d0e:9ba5:8bbc::1]:8080".normalizeRelayUrl().url) + fun schemelessOverlayAddressDefaultsToWs() { + assertEquals("ws://[201:d0e:9ba5:8bbc::1]:8080/", "[201:d0e:9ba5:8bbc::1]:8080".normalizeRelayUrl().url) + assertTrue("ws://[201:d0e:9ba5:8bbc::1]:8080/".normalizeRelayUrl().isOverlayNetwork()) + // A clearnet IPv6 relay keeps requiring TLS. + assertEquals("wss://[2001:db8::1]:8080/", "[2001:db8::1]:8080".normalizeRelayUrl().url) + assertFalse("wss://[2001:db8::1]:8080/".normalizeRelayUrl().isOverlayNetwork()) + } + + /** + * `::1`, `fc00::/7` and `fe80::/10` are the IPv6 twins of 127.0.0.1 and 192.168., so they + * answer [isLocalHost] the same way — no TLS, no Tor, never advertised to the network. + */ + @Test + fun ipv6LoopbackAndPrivateRangesCountAsLocalHost() { + assertEquals("ws://[::1]:4869/", "[::1]:4869".normalizeRelayUrl().url) + assertTrue("ws://[::1]:4869/".normalizeRelayUrl().isLocalHost()) + assertTrue("ws://[fd12:3456::1]:8080/".normalizeRelayUrl().isLocalHost(), "unique local address") + assertTrue("ws://[fe80::1]:8080/".normalizeRelayUrl().isLocalHost(), "link local address") + assertFalse("wss://[2001:db8::1]:8080/".normalizeRelayUrl().isLocalHost(), "clearnet ipv6") + // An overlay relay is reachable across the mesh, so it is NOT localhost. assertFalse("ws://[201:d0e:9ba5:8bbc::1]:8080/".normalizeRelayUrl().isLocalHost()) } /** - * GAP 3 — an unbracketed IPv6 literal is rejected outright. - * - * `yggdrasilctl getSelf` prints the address unbracketed, which is what a user copies into - * the "add a relay" field. Normalization returns null and `RelayUrlEditField.submitRelay` - * has no else branch, so the Add button silently does nothing. + * `yggdrasilctl getSelf` prints the address unbracketed, which is what a user pastes into + * the "add a relay" field. It is bracketed automatically rather than rejected. */ @Test - fun gapUnbracketedYggdrasilAddressIsRejected() { - assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5")) - assertNull(RelayUrlNormalizer.normalizeOrNull("201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5:8080")) - // Bracketing it by hand is the only accepted form. - assertTrue(RelayUrlNormalizer.normalizeOrNull("[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]:8080") != null) + fun bareUnbracketedLiteralIsBracketedAutomatically() { + assertEquals( + "ws://[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]/", + "201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5".normalizeRelayUrl().url, + ) + assertEquals("ws://[201:d0e:9ba5:8bbc::1]/", "201:d0e:9ba5:8bbc::1".normalizeRelayUrl().url) + assertNotNull(RelayUrlNormalizer.normalizeOrNull("[201:d0e:9ba5:8bbc:f4a1:d34:1c2:eae5]:8080")) + } + + /** + * Auto-bracketing must not swallow the other colon-bearing strings that reach the + * normalizer. Only a string that parses as a whole IPv6 address is bracketed. + */ + @Test + fun autoBracketingDoesNotCaptureNonAddresses() { + assertEquals("wss://relay.example.com:8080/", "relay.example.com:8080".normalizeRelayUrl().url) + assertEquals("ws://localhost:4869/", "localhost:4869".normalizeRelayUrl().url) + // addressable-event pointer, not a relay + assertEquals(null, RelayUrlNormalizer.normalizeOrNull("31990:abcdef:mydtag")) + // two hex-looking groups are a host and a port, not an address + assertEquals("wss://abcd:1234/", "abcd:1234".normalizeRelayUrl().url) } } From 70d51cc98b8bea7e6ed3ad2d90573ab6c212b986 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 5 Aug 2026 04:22:59 +0000 Subject: [PATCH 3/3] fix(relay): parse the authority instead of substring-matching the url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit of the IPv6 work found a family of bugs in isLocalHost/isOnion, most predating this branch, all with one root cause: the predicates ran `contains` over the whole url rather than parsing the authority. These decide whether a relay is exempt from Tor, and relay urls arrive from other people (NIP-65 lists, relay hints, r tags), so they are attacker-controlled input. - A path could impersonate the host. `wss://evil.example.com/127.0.0.1` answered isLocalHost() == true, so any relay list could hand the app a url that silently dropped its own Tor routing. The IPv6 lookup added earlier on this branch had the same flaw via `/[fd00::1]`, and IPv6 canonicalization could rewrite a path outright, corrupting the url. - `.onion:8080` never matched the `.onion/` test, so an onion relay on an explicit port was not treated as onion at all: never forced onto Tor, and its hostname went to the clearnet DNS resolver. The fully-qualified `.onion.` spelling missed the same way. - Host tests were case-sensitive, but fix() asks them before the RFC 3986 pass folds case, so LOCALHOST:8080 and ABC.ONION:8080 were handed a wss:// scheme neither host can serve. - Private IPv4 was substring-matched, which missed 10.0.0.5, 172.16.3.4 and 127.1.2.3 — a LAN relay got wss:// and was dialed through Tor — while matching 192.168.evil.com and 127.0.0.1.evil.com, registrable domains that could therefore exempt themselves from Tor. Same for notlocalhost.example.com against `contains("localhost")`. - A `://` inside a path was read as a scheme separator, so `relay.com/x://127.0.0.1` read its path as the authority. Fixes: a shared hostStart/hostEnd/hostEndWithoutPort trio bounds every test to the authority, strips :port and trailing dots and validates the scheme; private ranges are parsed via a new Ipv4 util rather than substring-matched; comparisons are case-insensitive per RFC 4343; NormalizedRelayUrl.isOnion() delegates instead of keeping a second, weaker copy of the test. No performance regression: the old form ran six full-string scans, the new one bounds its work to the authority and rejects a DNS host from an IP parse on one character. Ipv6.isLiteral gained a two-colon gate so the schemeless host:port case answers without allocating the parser's buffer. Ipv6 is now pinned by a differential test: 4000 random addresses round-trip against java.net.InetAddress in both directions, and the canonical form is asserted equal to OkHttp's host for the same address, so the relay identity the app stores provably matches the host it dials. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DQr8CDsznzCRUeB5tS8VYk --- .../relays/common/RelayUrlEditField.kt | 19 +- .../plans/2026-08-04-yggdrasil-ipv6-relays.md | 40 ++++ .../relay/normalizer/NormalizedRelayUrl.kt | 4 +- .../relay/normalizer/RelayUrlNormalizer.kt | 191 +++++++++++++++--- .../com/vitorpamplona/quartz/utils/Ipv4.kt | 99 +++++++++ .../com/vitorpamplona/quartz/utils/Ipv6.kt | 51 ++--- .../relay/RelayUrlAuthorityAnchoringTest.kt | 186 +++++++++++++++++ .../quartz/utils/Ipv6DifferentialTest.kt | 108 ++++++++++ 8 files changed, 623 insertions(+), 75 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv4.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlAuthorityAnchoringTest.kt create mode 100644 quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6DifferentialTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt index d523058cfb..963ddb3724 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/common/RelayUrlEditField.kt @@ -200,14 +200,19 @@ fun RelayUrlEditField( relaySuggestions.processInput(it) }, isError = isInvalid, - supportingText = { + // Null, not an empty lambda: a non-null slot reserves its line height even when it + // draws nothing, which would pad the field permanently for every user. + supportingText = if (isInvalid) { - Text( - text = stringRes(R.string.relay_url_not_valid), - color = MaterialTheme.colorScheme.error, - ) - } - }, + { + Text( + text = stringRes(R.string.relay_url_not_valid), + color = MaterialTheme.colorScheme.error, + ) + } + } else { + null + }, placeholder = { Text( text = "server.com", diff --git a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md index 33d8aaef31..412210a81b 100644 --- a/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md +++ b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md @@ -77,6 +77,46 @@ branch. Both the Android and desktop relay paths delegate here (`TorRelayState`, treatment for non-relay HTTP (images, previews, NIP-05, money ops). Clearnet IPv6 relays keep following the Tor setting — asserted in `YggdrasilTorRoutingTest`. +## Audit round: bugs found in the host predicates + +Auditing the change above turned up a family of bugs in `isLocalHost` / `isOnion` that predate +it. All shared one root cause — the predicates ran `contains` over the **whole url** instead of +parsing the authority — and all are now anchored, parsed and covered by +`RelayUrlAuthorityAnchoringTest`. + +These predicates decide whether a relay is exempt from Tor, and relay urls arrive from other +people (NIP-65 lists, relay hints, `r` tags), so they are attacker-controlled input. + +| # | Bug | Effect | +|---|---|---| +| 1 | A path could impersonate the host: `wss://evil.example.com/127.0.0.1` answered `isLocalHost() == true` | Any relay list could hand the app a url that silently dropped its own Tor routing | +| 2 | `.onion:8080` never matched the `.onion/` test | An onion relay on an explicit port was not treated as onion — never forced onto Tor, hostname sent to the clearnet DNS resolver | +| 3 | `.onion.` / `localhost.` (RFC 1034 fully-qualified form) matched nothing | Same leak as #2, via a different spelling | +| 4 | Host tests were case-sensitive, but `fix()` runs *before* the RFC 3986 pass folds case | `LOCALHOST:8080` and `ABC.ONION:8080` were given a `wss://` scheme neither host can serve | +| 5 | Private IPv4 was substring-matched | `10.0.0.5`, `172.16.3.4`, `127.1.2.3` were not local (LAN relay got `wss://` and Tor), while `192.168.evil.com` — a registrable domain — was | +| 6 | `contains("localhost")` matched `notlocalhost.example.com` | Same Tor exemption as #1, via a registrable domain | +| 7 | A `://` inside a path was read as a scheme separator | `relay.com/x://127.0.0.1` read its path as the authority | + +Two bugs were introduced by this branch and caught in the same pass: the IPv6 host lookup had +the #1 flaw (`wss://evil.example.com/[fd00::1]` read as localhost), and IPv6 canonicalization +could rewrite a **path** (`/x[0:0:0:0:0:0:0:1]y` → `/x[::1]y`), corrupting the url. + +Fixes: a shared `hostStart` / `hostEnd` / `hostEndWithoutPort` trio bounds every test to the +authority, strips `:port` and trailing dots, and validates the scheme; private ranges are +parsed via the new `Ipv4` util and `Ipv6` rather than substring-matched; comparisons are +case-insensitive per RFC 4343; `NormalizedRelayUrl.isOnion()` now delegates instead of keeping +a second, weaker copy of the test. + +Performance: no regression, likely a small win. The old form ran six full-string `contains` +scans; the new one bounds its work to the authority and rejects a DNS host from an IP parse on +a single character. `Ipv6.isLiteral` gained a two-colon gate so the schemeless `host:port` case +answers without allocating the parser's 16-byte buffer. + +`Ipv6` itself is pinned by `Ipv6DifferentialTest`: 4000 random addresses round-trip against +`java.net.InetAddress` in both directions, and the canonical form is asserted equal to +OkHttp's host for the same address — so the relay identity the app stores provably matches the +host it dials. + ## Deliberately not changed **Mesh relays are still published and recommended.** `AdvertisedRelayInfoTag` (NIP-65) and diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt index 0196ac0bbf..5215e1f473 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/NormalizedRelayUrl.kt @@ -44,7 +44,9 @@ fun NormalizedRelayUrl.toHttp() = "https://$url" } -fun NormalizedRelayUrl.isOnion() = url.contains(".onion/") +// Delegates rather than re-implementing `contains(".onion/")`: that copy missed +// `wss://host.onion:8080/`, so an onion relay on an explicit port was never forced onto Tor. +fun NormalizedRelayUrl.isOnion() = RelayUrlNormalizer.isOnion(this.url) fun NormalizedRelayUrl.isLocalHost() = RelayUrlNormalizer.isLocalHost(this.url) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt index 18d83c66af..5d68b066f6 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/normalizer/RelayUrlNormalizer.kt @@ -21,6 +21,7 @@ package com.vitorpamplona.quartz.nip01Core.relay.normalizer import androidx.collection.LruCache +import com.vitorpamplona.quartz.utils.Ipv4 import com.vitorpamplona.quartz.utils.Ipv6 import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Rfc3986 @@ -39,25 +40,60 @@ val normalizedUrls = LruCache(5000) class RelayUrlNormalizer { companion object { - fun isLocalHost(url: String) = - url.contains("127.0.0.1") || - url.contains("localhost") || - url.contains("//umbrel:") || - url.contains("192.168.") || - url.contains(".local:") || - url.contains(".local/") || - isPrivateIpv6(url) + /** + * Every host test below is anchored to the **authority** (`host[:port]`), never to the + * whole url. A plain `contains` reads the path and query too, so + * `wss://evil.example.com/127.0.0.1` used to answer true here — and since [isLocalHost] + * is what exempts a relay from Tor, any relay list could hand the app a url that quietly + * dropped its own Tor routing. Relay urls arrive from other people (NIP-65 lists, relay + * hints, `r` tags), so they are attacker-controlled input and have to be parsed as such. + * + * Anchoring is also what makes `host:port` work: `.onion:8080` never matched the old + * `.onion/` test, so an onion relay on an explicit port was not recognized as onion at + * all and its hostname went to the clearnet DNS resolver. + */ + fun isLocalHost(url: String): Boolean { + val start = hostStart(url) + val end = hostEnd(url, start) + if (end <= start) return false + val hostEnd = hostEndWithoutPort(url, start, end) + return isPrivateIpv4(url, start, hostEnd) || + // RFC 6761: `localhost` and anything under it — the same rule SurgeDns applies + // when deciding whether a loopback answer is legitimate. A substring test would + // also match `notlocalhost.example.com`, which is registrable. + regionEquals(url, "localhost", start, hostEnd) || + regionEndsWith(url, ".localhost", start, hostEnd) || + regionEquals(url, "umbrel", start, hostEnd) || + regionEndsWith(url, ".local", start, hostEnd) || + isPrivateIpv6(url, start, end) + } /** - * The IPv6 twins of the literals above: `::1` (127.0.0.1), `fc00::/7` unique local - * addresses (192.168.0.0/16) and `fe80::/10` link-local. All three name a host that - * only exists on this machine or this LAN, which is what every caller of [isLocalHost] - * means by the question — so a relay on one must not be Torified, must not need TLS, - * and must not be advertised to the network. + * The IPv4 ranges that are never a public relay: `127.0.0.0/8` loopback, the RFC 1918 + * private blocks, `169.254.0.0/16` link-local and `0.0.0.0/8`. + * + * Parsed rather than substring-matched, which was wrong both ways: `contains("192.168.")` + * missed `10.0.0.5` and `172.16.3.4` — so a LAN relay was given `wss://` and dialed + * through Tor — while matching `192.168.evil.com`, a registrable domain that could + * therefore exempt itself from Tor. */ - private fun isPrivateIpv6(url: String): Boolean { - val bytes = ipv6HostOf(url) ?: return false - return Ipv6.isLoopback(bytes) || Ipv6.isUniqueLocal(bytes) || Ipv6.isLinkLocal(bytes) + private fun isPrivateIpv4( + url: String, + start: Int, + end: Int, + ): Boolean { + val bytes = Ipv4.parse(url, start, end) ?: return false + return Ipv4.isLoopback(bytes) || + Ipv4.isPrivate(bytes) || + Ipv4.isLinkLocal(bytes) || + Ipv4.isUnspecified(bytes) + } + + fun isOnion(url: String): Boolean { + val start = hostStart(url) + val end = hostEnd(url, start) + if (end <= start) return false + return regionEndsWith(url, ".onion", start, hostEndWithoutPort(url, start, end)) } /** @@ -72,26 +108,115 @@ class RelayUrlNormalizer { * which is derived from the peer's public key. */ fun isOverlayNetwork(url: String): Boolean { - val bytes = ipv6HostOf(url) ?: return false + val start = hostStart(url) + val bytes = ipv6HostOf(url, start, hostEnd(url, start)) ?: return false return Ipv6.isOverlayMesh(bytes) } /** - * Extracts the bracketed IPv6 host of [url] as raw bytes, dropping any `%zone` suffix. - * Returns null — cheaply, on a single `indexOf` — for the overwhelmingly common case of - * a url with a DNS host. + * The IPv6 twins of the literals in [isLocalHost]: `::1` (127.0.0.1), `fc00::/7` unique + * local addresses (192.168.0.0/16) and `fe80::/10` link-local. All three name a host that + * only exists on this machine or this LAN, which is what every caller of [isLocalHost] + * means by the question — so a relay on one must not be Torified, must not need TLS, + * and must not be advertised to the network. */ - private fun ipv6HostOf(url: String): ByteArray? { - val open = url.indexOf('[') - if (open < 0) return null - val close = url.indexOf(']', open + 1) - if (close <= open + 1) return null - val zone = url.indexOf('%', open + 1) - val end = if (zone in (open + 1) until close) zone else close - return Ipv6.parse(url.substring(open + 1, end)) + private fun isPrivateIpv6( + url: String, + start: Int, + end: Int, + ): Boolean { + val bytes = ipv6HostOf(url, start, end) ?: return false + return Ipv6.isLoopback(bytes) || Ipv6.isUniqueLocal(bytes) || Ipv6.isLinkLocal(bytes) } - fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") + /** + * Parses the authority of [url] as a bracketed IPv6 literal, dropping any `%zone` suffix. + * Returns null — on a single char comparison — for the overwhelmingly common case of a + * url with a DNS host. + */ + private fun ipv6HostOf( + url: String, + start: Int, + end: Int, + ): ByteArray? { + if (start >= end || url[start] != '[') return null + val close = url.indexOf(']', start + 1) + if (close < 0 || close >= end || close <= start + 1) return null + val zone = url.indexOf('%', start + 1) + val addressEnd = if (zone in (start + 1) until close) zone else close + return Ipv6.parse(url.substring(start + 1, addressEnd)) + } + + /** + * Index of the first char of the authority: past `://`, or 0 for a schemeless host. + * + * The `://` only counts when what precedes it is a real RFC 3986 scheme + * (`ALPHA *( ALPHA / DIGIT / "+" / "-" / "." )`). Otherwise `relay.com/x://127.0.0.1` + * would have its *path* read as the authority and answer true to [isLocalHost]. + */ + private fun hostStart(url: String): Int { + val scheme = url.indexOf("://") + if (scheme <= 0 || !url[0].isLetter()) return 0 + for (i in 1 until scheme) { + val c = url[i] + if (!c.isLetterOrDigit() && c != '+' && c != '-' && c != '.') return 0 + } + return scheme + 3 + } + + /** Index just past the authority — the first `/`, `?` or `#`, or the end of [url]. */ + private fun hostEnd( + url: String, + start: Int, + ): Int { + var i = start + while (i < url.length) { + val c = url[i] + if (c == '/' || c == '?' || c == '#') return i + i++ + } + return i + } + + /** + * [end] trimmed back past a `:port` and any trailing dots, so the host tests see the + * name alone. RFC 1034's fully-qualified form ends in a dot (`abc.onion.`), and missing + * that spelling on [isOnion] would send a `.onion` name to the clearnet DNS resolver. + */ + private fun hostEndWithoutPort( + url: String, + start: Int, + end: Int, + ): Int { + var stop = + if (url[start] == '[') { + // In an IPv6 authority only the `:` after the `]` can start a port. + val close = url.indexOf(']', start + 1) + if (close in start until end) close + 1 else end + } else { + val colon = url.lastIndexOf(':', end - 1) + if (colon >= start) colon else end + } + while (stop > start && url[stop - 1] == '.') stop-- + return stop + } + + // Host names are case-insensitive (RFC 4343), and `fix()` asks these questions *before* + // the RFC 3986 pass folds the case — so a case-sensitive test gave `LOCALHOST:8080` and + // `ABC.ONION:8080` a `wss://` scheme neither host can ever serve. + private fun regionEndsWith( + url: String, + suffix: String, + start: Int, + end: Int, + ): Boolean = end - start >= suffix.length && url.regionMatches(end - suffix.length, suffix, 0, suffix.length, ignoreCase = true) + + private fun regionEquals( + url: String, + name: String, + start: Int, + end: Int, + ): Boolean = end - start == name.length && url.regionMatches(start, name, 0, name.length, ignoreCase = true) fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's' @@ -143,10 +268,12 @@ class RelayUrlNormalizer { * canonical, which is every url with a DNS host. */ private fun canonicalizeIpv6Host(url: String): String { - val open = url.indexOf('[') - if (open < 0) return url + // Anchored to the authority: a `[...]` in a path or query is data, and rewriting it + // would silently corrupt the url. + val open = hostStart(url) + if (open >= url.length || url[open] != '[') return url val close = url.indexOf(']', open + 1) - if (close <= open + 1) return url + if (close <= open + 1 || close >= hostEnd(url, open)) return url val inner = url.substring(open + 1, close) val canonical = Ipv6.canonicalizeOrNull(inner) ?: return url if (canonical == inner) return url diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv4.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv4.kt new file mode 100644 index 0000000000..41bc1c34d1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv4.kt @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +/** + * Pure-Kotlin IPv4 literal parsing and range classification, the companion to [Ipv6]. + * + * Exists because asking "is this host private?" with `url.contains("192.168.")` is wrong in + * both directions: it misses `10.0.0.5` and `172.16.3.4` (so a LAN relay gets `wss://` and is + * dialed through Tor) and it matches `192.168.evil.com`, a perfectly registrable domain (so a + * hostile relay url can exempt itself from Tor). Parsing the host and testing the range is the + * only form of the question that has a right answer. + */ +object Ipv4 { + /** Parses a dotted quad in `[from, to)`, or null when the region is not one. */ + fun parse( + text: String, + from: Int, + to: Int, + ): ByteArray? { + // Cheapest possible rejection of a DNS host: a literal always starts with a digit. + if (from >= to || text[from] !in '0'..'9') return null + val out = ByteArray(4) + return if (parseInto(text, from, to, out, 0)) out else null + } + + /** + * Parses `a.b.c.d` in `[from, to)` into four bytes at [at]. Leading zeros are rejected — + * they invite the octal reading that makes `010.1.1.1` ambiguous across resolvers. + */ + fun parseInto( + text: String, + from: Int, + to: Int, + out: ByteArray, + at: Int, + ): Boolean { + var i = from + for (octet in 0 until 4) { + if (octet > 0) { + if (i >= to || text[i] != '.') return false + i++ + } + var value = 0 + var digits = 0 + while (i < to && text[i] in '0'..'9') { + if (digits == 3) return false + if (digits == 1 && value == 0) return false // leading zero + value = value * 10 + (text[i] - '0') + digits++ + i++ + } + if (digits == 0 || value > 255) return false + out[at + octet] = value.toByte() + } + return i == to + } + + /** `127.0.0.0/8` — the whole loopback block, not just 127.0.0.1. */ + fun isLoopback(bytes: ByteArray): Boolean = octet(bytes, 0) == 127 + + /** RFC 1918: `10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`. */ + fun isPrivate(bytes: ByteArray): Boolean { + val first = octet(bytes, 0) + val second = octet(bytes, 1) + return first == 10 || + (first == 172 && second in 16..31) || + (first == 192 && second == 168) + } + + /** `169.254.0.0/16` — link-local / APIPA, reachable only on the local segment. */ + fun isLinkLocal(bytes: ByteArray): Boolean = octet(bytes, 0) == 169 && octet(bytes, 1) == 254 + + /** `0.0.0.0/8` — "this network"; never a routable relay. */ + fun isUnspecified(bytes: ByteArray): Boolean = octet(bytes, 0) == 0 + + private fun octet( + bytes: ByteArray, + at: Int, + ) = bytes[at].toInt() and 0xFF +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt index 27d0d724ed..ec00f67a46 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt @@ -75,7 +75,7 @@ object Ipv6 { if (i < len && address[i] == '.') { // Trailing dotted quad: occupies the last four bytes, so nothing may follow it. if (fill > 12) return null - if (!parseIpv4Into(address, groupStart, len, out, fill)) return null + if (!Ipv4.parseInto(address, groupStart, len, out, fill)) return null fill += 4 i = len break @@ -178,8 +178,21 @@ object Ipv6 { return format(bytes) + address.substring(zoneAt) } - /** True when [address] is a valid bracket-less literal that names more than one group. */ - fun isLiteral(address: String): Boolean = address.indexOf(':') >= 0 && parse(address) != null + /** + * True when [address] is a valid bracket-less literal. + * + * The two-colon gate is what keeps this off the normalizer's hot path: every schemeless + * `host:port` reaching [com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer] + * has exactly one colon, and a literal needs at least two, so the common case answers + * without entering [parse] and allocating its 16-byte buffer. + */ + fun isLiteral(address: String): Boolean { + val firstColon = address.indexOf(':') + if (firstColon < 0 || address.indexOf(':', firstColon + 1) < 0) return false + // A zone id is part of the literal (`fe80::1%25eth0`), not a reason to reject it. + val zoneAt = address.indexOf('%') + return parse(if (zoneAt < 0) address else address.substring(0, zoneAt)) != null + } /** `::1` — the IPv6 loopback, twin of 127.0.0.1. */ fun isLoopback(bytes: ByteArray): Boolean { @@ -217,38 +230,6 @@ object Ipv6 { } } - /** - * Parses `a.b.c.d` in `[from, to)` into four bytes at [at]. Leading zeros are rejected — - * they invite the octal reading that makes `010.1.1.1` ambiguous across resolvers. - */ - private fun parseIpv4Into( - text: String, - from: Int, - to: Int, - out: ByteArray, - at: Int, - ): Boolean { - var i = from - for (octet in 0 until 4) { - if (octet > 0) { - if (i >= to || text[i] != '.') return false - i++ - } - var value = 0 - var digits = 0 - while (i < to && text[i] in '0'..'9') { - if (digits == 3) return false - if (digits == 1 && value == 0) return false // leading zero - value = value * 10 + (text[i] - '0') - digits++ - i++ - } - if (digits == 0 || value > 255) return false - out[at + octet] = value.toByte() - } - return i == to - } - private fun hexDigit(c: Char): Int = when (c) { in '0'..'9' -> c - '0' diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlAuthorityAnchoringTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlAuthorityAnchoringTest.kt new file mode 100644 index 0000000000..7d91ab2542 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/RelayUrlAuthorityAnchoringTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip01Core.relay + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * [RelayUrlNormalizer.isLocalHost] and [RelayUrlNormalizer.isOnion] decide whether a relay is + * exempt from Tor, so they must read the authority and nothing else. Relay urls arrive from + * other people — NIP-65 lists, relay hints, `r` tags — so a url whose *path* can flip those + * answers is a url that can drop a Tor user's protection. + */ +class RelayUrlAuthorityAnchoringTest { + @Test + fun aPathCannotMakeAForeignHostLookLocal() { + listOf( + "wss://evil.example.com/127.0.0.1", + "wss://evil.example.com/localhost", + "wss://evil.example.com/192.168.1.1", + "wss://evil.example.com/umbrel", + "wss://evil.example.com/x.local/y", + "wss://evil.example.com/[fd00::1]", + "wss://evil.example.com/[::1]", + "wss://evil.example.com/?q=127.0.0.1", + "wss://evil.example.com/?q=[::1]", + ).forEach { + assertFalse(RelayUrlNormalizer.isLocalHost(it), "$it must not read as localhost") + } + } + + @Test + fun aPathCannotMakeAForeignHostLookLikeAnOverlayOrOnion() { + assertFalse(RelayUrlNormalizer.isOverlayNetwork("wss://evil.example.com/[201:d0e:9ba5:8bbc::1]")) + assertFalse(RelayUrlNormalizer.isOnion("wss://evil.example.com/?u=http://nos.lol/.onion/")) + assertFalse(RelayUrlNormalizer.isOnion("wss://evil.example.com/abc.onion")) + } + + @Test + fun realLocalAndOnionHostsStillMatch() { + listOf( + "ws://127.0.0.1:8080/", + "ws://localhost:4869/", + "ws://umbrel:4848/", + "ws://192.168.1.100:8080/", + "ws://myrelay.local:8080/", + "ws://myrelay.local/", + "ws://foo.localhost:8080/", + "ws://[::1]:4869/", + "ws://[fd12:3456::1]:8080/", + "ws://[fe80::1]/", + ).forEach { + assertTrue(RelayUrlNormalizer.isLocalHost(it), "$it must read as localhost") + } + // schemeless, as fix() sees it before choosing ws:// vs wss:// + assertTrue(RelayUrlNormalizer.isLocalHost("127.0.0.1:8080")) + assertTrue(RelayUrlNormalizer.isLocalHost("umbrel:4848")) + } + + /** + * `.onion:8080` never matched the old `.onion/` test, so an onion relay on an explicit port + * was not recognized as onion — it skipped the forced-Tor branch and its hostname went to + * the clearnet DNS resolver. + */ + @Test + fun onionRelaysOnAnExplicitPortAreRecognized() { + assertTrue(RelayUrlNormalizer.isOnion("wss://abc123.onion:8080/")) + assertTrue(RelayUrlNormalizer.isOnion("wss://abc123.onion/")) + assertTrue(RelayUrlNormalizer.isOnion("abc123.onion:8080")) + assertTrue(RelayUrlNormalizer.isOnion("abc123.onion")) + // and it now gets ws:// like any other onion relay + assertEquals("ws://abc123.onion:8080/", "abc123.onion:8080".normalizeRelayUrl().url) + assertFalse(RelayUrlNormalizer.isOnion("wss://notonion.example.com/")) + } + + /** Canonicalization must never rewrite anything outside the authority. */ + @Test + fun canonicalizationLeavesPathsAndQueriesAlone() { + assertEquals( + "wss://evil.example.com/x[0:0:0:0:0:0:0:1]y", + "wss://evil.example.com/x[0:0:0:0:0:0:0:1]y".normalizeRelayUrl().url, + ) + // ...while still folding a real IPv6 authority + assertEquals( + "wss://[::1]/x[0:0:0:0:0:0:0:1]y", + "wss://[0:0:0:0:0:0:0:1]/x[0:0:0:0:0:0:0:1]y".normalizeRelayUrl().url, + ) + } + + /** + * Private IPv4 was substring-matched, which was wrong in both directions. + */ + @Test + fun allPrivateIpv4RangesCountAsLocal() { + listOf( + "ws://127.0.0.1:8080/", + "ws://127.1.2.3:8080/", + "ws://10.0.0.5:4869/", + "ws://172.16.3.4:4869/", + "ws://172.31.255.1/", + "ws://192.168.1.5:4869/", + "ws://169.254.1.1:4869/", + "ws://0.0.0.0:4869/", + ).forEach { + assertTrue(RelayUrlNormalizer.isLocalHost(it), "$it must read as localhost") + } + // a LAN relay therefore gets ws://, not a wss:// that can never hold a certificate + assertEquals("ws://10.0.0.5:4869/", "10.0.0.5:4869".normalizeRelayUrl().url) + } + + @Test + fun publicIpv4AndPrivateLookalikeDomainsAreNotLocal() { + listOf( + "wss://127.0.0.1.evil.com/", + "wss://192.168.evil.com/", + "wss://10.0.0.5.evil.com/", + "wss://8.8.8.8:4869/", + "wss://172.32.0.1/", + "wss://193.168.1.5/", + "wss://relay.damus.io/", + "wss://notlocalhost.example.com/", + "wss://mylocalhost.io/", + ).forEach { + assertFalse(RelayUrlNormalizer.isLocalHost(it), "$it must not read as localhost") + } + } + + /** + * Host names are case-insensitive (RFC 4343), and `fix()` asks these questions before the + * RFC 3986 pass folds the case — so a case-sensitive test handed `LOCALHOST:8080` and + * `ABC.ONION:8080` a `wss://` scheme neither host can serve. + */ + @Test + fun hostTestsAreCaseInsensitive() { + assertTrue(RelayUrlNormalizer.isLocalHost("wss://LocalHost:8080/")) + assertTrue(RelayUrlNormalizer.isLocalHost("LOCALHOST:8080")) + assertTrue(RelayUrlNormalizer.isLocalHost("wss://MyRelay.LOCAL/")) + assertTrue(RelayUrlNormalizer.isOnion("wss://ABC123.ONION/")) + assertTrue(RelayUrlNormalizer.isOnion("ABC.ONION:8080")) + assertEquals("ws://localhost:8080/", "LOCALHOST:8080".normalizeRelayUrl().url) + assertEquals("ws://abc.onion:8080/", "ABC.ONION:8080".normalizeRelayUrl().url) + } + + /** RFC 1034's fully-qualified form ends in a dot; it names the same host. */ + @Test + fun trailingDotFqdnIsTheSameHost() { + assertTrue(RelayUrlNormalizer.isLocalHost("wss://localhost./")) + assertTrue(RelayUrlNormalizer.isLocalHost("wss://myrelay.local./")) + assertTrue(RelayUrlNormalizer.isOnion("wss://abc123.onion./")) + assertTrue(RelayUrlNormalizer.isOnion("wss://abc123.onion.:8080/")) + } + + /** + * A `://` inside a path is not a scheme separator; only a real RFC 3986 scheme starts the + * authority. Otherwise the path gets read as the host. + */ + @Test + fun aColonSlashSlashInThePathIsNotASchemeSeparator() { + assertFalse(RelayUrlNormalizer.isLocalHost("relay.example.com/x://127.0.0.1")) + assertFalse(RelayUrlNormalizer.isOnion("nos.lol/?u=x://abc.onion")) + // a real scheme still starts the authority + assertTrue(RelayUrlNormalizer.isLocalHost("wss://127.0.0.1/x://evil.com")) + } +} diff --git a/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6DifferentialTest.kt b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6DifferentialTest.kt new file mode 100644 index 0000000000..43b9934f21 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/utils/Ipv6DifferentialTest.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.utils + +import okhttp3.HttpUrl.Companion.toHttpUrl +import java.net.InetAddress +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Differential tests for [Ipv6] against the two parsers that actually matter at runtime: the + * JDK's (what `InetAddress` will do with the host) and OkHttp's (what dials the socket). + * + * A hand-written address parser is exactly the kind of code that passes its own examples and + * then disagrees with the real world on the hundredth input, so this pins it against + * references over a deterministic random corpus rather than against more of my own examples. + */ +class Ipv6DifferentialTest { + /** + * Parses through the JDK. IPv4-mapped literals come back as an `Inet4Address` of 4 bytes, + * so they are widened back to the 16-byte mapped form — [Ipv6] keeps them at 16 bytes, + * which is also what OkHttp does. + */ + private fun jdkBytes(literal: String): ByteArray { + val raw = InetAddress.getByName("[$literal]").address + if (raw.size == 16) return raw + return ByteArray(16).also { + it[10] = 0xFF.toByte() + it[11] = 0xFF.toByte() + raw.copyInto(it, 12) + } + } + + private fun randomAddresses(count: Int): List { + val rnd = Random(20260805) + return List(count) { + ByteArray(16) { rnd.nextInt(256).toByte() }.also { bytes -> + // Sprinkle zero runs so every `::` compression path gets exercised. + val runStart = rnd.nextInt(8) * 2 + val runLen = rnd.nextInt(1, 5) * 2 + for (k in runStart until minOf(16, runStart + runLen)) bytes[k] = 0 + } + } + } + + @Test + fun ourTextParsesToTheSameBytesInTheJdk() { + randomAddresses(4000).forEach { bytes -> + val text = Ipv6.format(bytes) + assertTrue(Ipv6.parse(text)!!.contentEquals(bytes), "our own round trip failed for $text") + assertTrue(jdkBytes(text).contentEquals(bytes), "the JDK reads $text as a different address") + } + } + + @Test + fun theJdksTextParsesBackThroughUs() { + randomAddresses(2000).forEach { bytes -> + val jdkText = InetAddress.getByAddress(bytes).hostAddress!! + assertTrue(Ipv6.parse(jdkText)?.contentEquals(bytes) == true, "we cannot read the JDK's own rendering: $jdkText") + } + } + + /** + * The canonical form is the app's relay identity, so it has to equal the host OkHttp shows + * for the same address — otherwise the app keys a relay under a name it does not dial. + */ + @Test + fun ourCanonicalFormMatchesOkHttp() { + randomAddresses(2000).forEach { bytes -> + val text = Ipv6.format(bytes) + assertEquals("http://[$text]/".toHttpUrl().host, text) + } + } + + @Test + fun expandedSpellingsCollapseOntoOkHttpsHost() { + randomAddresses(500).forEach { bytes -> + // The fully expanded, zero-padded, uppercase spelling of the same address. + val expanded = + (0 until 8).joinToString(":") { g -> + val value = ((bytes[g * 2].toInt() and 0xFF) shl 8) or (bytes[g * 2 + 1].toInt() and 0xFF) + value.toString(16).padStart(4, '0').uppercase() + } + assertEquals(Ipv6.format(bytes), Ipv6.canonicalizeOrNull(expanded)) + assertEquals("http://[$expanded]/".toHttpUrl().host, Ipv6.canonicalizeOrNull(expanded)) + } + } +}