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..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 @@ -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,23 @@ fun RelayUrlEditField( value = url, onValueChange = { url = it + isInvalid = false relaySuggestions.processInput(it) }, + isError = isInvalid, + // 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, + ) + } + } else { + null + }, 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 new file mode 100644 index 0000000000..9a2dbb577b --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/tor/YggdrasilTorRoutingTest.kt @@ -0,0 +1,70 @@ +/* + * 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 + +/** + * 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( + torSettings = + TorRelaySettings( + torType = TorType.INTERNAL, + onionRelaysViaTor = true, + dmRelaysViaTor = true, + newRelaysViaTor = newViaTor, + trustedRelaysViaTor = false, + moneyOperationsViaTor = false, + ), + trustedRelayList = emptySet(), + dmRelayList = emptySet(), + ) + + @Test + fun overlayRelaysAreNeverTorifiedEvenWhenNewRelaysViaTorIsOn() { + val eval = evaluation(newViaTor = true) + 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 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 new file mode 100644 index 0000000000..412210a81b --- /dev/null +++ b/quartz/plans/2026-08-04-yggdrasil-ipv6-relays.md @@ -0,0 +1,154 @@ +# Amethyst over Yggdrasil (IPv6 overlay) + +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` (nodes in `0200::/8`, subnets in `0300::/8`). Consequences: + +- **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. +- `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. + +## What was wrong, and what fixed it + +### 1. One relay, two identities + +`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. + +**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`. + +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. + +### 2. Schemeless entry defaulted to `wss://` + +`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. + +**Fix:** new `RelayUrlNormalizer.isOverlayNetwork()` recognizes `0200::/7` and joins +`isOnion` / `isLocalHost` in choosing `ws://`. Clearnet IPv6 (`2001:db8::1`) still gets +`wss://`. + +`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. + +### 3. Unbracketed literal silently rejected + +`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. + +**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. + +### 4. Tor routing broke mesh relays + +`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. + +**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`. + +## 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 +`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 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 verified here + +- **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 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/…/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..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,6 +44,11 @@ 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) + +/** 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..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,8 @@ 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 import kotlinx.coroutines.CancellationException @@ -38,15 +40,183 @@ 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/") + /** + * 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) + } - fun isOnion(url: String) = url.endsWith(".onion") || url.contains(".onion/") + /** + * 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 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)) + } + + /** + * 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 start = hostStart(url) + val bytes = ipv6HostOf(url, start, hostEnd(url, start)) ?: return false + return Ipv6.isOverlayMesh(bytes) + } + + /** + * 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 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) + } + + /** + * 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' @@ -82,7 +252,33 @@ 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 { + // 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 || 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 + 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 +463,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/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 new file mode 100644 index 0000000000..ec00f67a46 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/Ipv6.kt @@ -0,0 +1,245 @@ +/* + * 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 (!Ipv4.parseInto(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. + * + * 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 { + 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) + } + } + + 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/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/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 new file mode 100644 index 0000000000..160ca7c9e2 --- /dev/null +++ b/quartz/src/jvmAndroidTest/kotlin/com/vitorpamplona/quartz/nip01Core/relay/YggdrasilCompatCharacterizationTest.kt @@ -0,0 +1,156 @@ +/* + * 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.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.assertNotNull +import kotlin.test.assertTrue + +/** + * Relay URLs on an Yggdrasil overlay, end to end through the normalizer. + * + * 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://`**. + * + * 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, 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" + + 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 yggdrasilSubnetAddressesWork() { + assertEquals("ws://[300:1b5d:d0e9:ba58::1]:4848/", "ws://[300:1b5d:d0e9:ba58::1]:4848".normalizeRelayUrl().url) + } + + /** + * 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 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") + } + } + + /** + * 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 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()) + } + + /** + * `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 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) + } +} 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)) + } + } +}