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)
}
}