mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #3859 from vitorpamplona/claude/yggdrasil-ipv6-compat-03mctx
Support IPv6 overlay relays (Yggdrasil) with RFC 5952 canonicalization
This commit is contained in:
+4
-2
@@ -67,7 +67,9 @@ class RoleBasedHttpClientBuilder(
|
|||||||
normalizedUrl: String,
|
normalizedUrl: String,
|
||||||
final: Boolean,
|
final: Boolean,
|
||||||
): 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
|
false
|
||||||
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
|
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
|
||||||
true
|
true
|
||||||
@@ -113,7 +115,7 @@ class RoleBasedHttpClientBuilder(
|
|||||||
isOnionRelaysActive: Boolean,
|
isOnionRelaysActive: Boolean,
|
||||||
final: Boolean,
|
final: Boolean,
|
||||||
): Boolean =
|
): Boolean =
|
||||||
if (RelayUrlNormalizer.isLocalHost(normalizedUrl)) {
|
if (RelayUrlNormalizer.isLocalHost(normalizedUrl) || RelayUrlNormalizer.isOverlayNetwork(normalizedUrl)) {
|
||||||
false
|
false
|
||||||
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
|
} else if (RelayUrlNormalizer.isOnion(normalizedUrl)) {
|
||||||
isOnionRelaysActive
|
isOnionRelaysActive
|
||||||
|
|||||||
+22
@@ -170,6 +170,7 @@ fun RelayUrlEditField(
|
|||||||
nav: INav,
|
nav: INav,
|
||||||
) {
|
) {
|
||||||
var url by remember { mutableStateOf("") }
|
var url by remember { mutableStateOf("") }
|
||||||
|
var isInvalid by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
fun submitRelay() {
|
fun submitRelay() {
|
||||||
if (url.isNotBlank()) {
|
if (url.isNotBlank()) {
|
||||||
@@ -177,7 +178,13 @@ fun RelayUrlEditField(
|
|||||||
if (relay != null) {
|
if (relay != null) {
|
||||||
onNewRelay(relay)
|
onNewRelay(relay)
|
||||||
url = ""
|
url = ""
|
||||||
|
isInvalid = false
|
||||||
relaySuggestions.reset()
|
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,
|
value = url,
|
||||||
onValueChange = {
|
onValueChange = {
|
||||||
url = it
|
url = it
|
||||||
|
isInvalid = false
|
||||||
relaySuggestions.processInput(it)
|
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 = {
|
placeholder = {
|
||||||
Text(
|
Text(
|
||||||
text = "server.com",
|
text = "server.com",
|
||||||
|
|||||||
@@ -214,6 +214,7 @@
|
|||||||
<string name="connection_success_rate_description">Percentage of successful connections to the relay</string>
|
<string name="connection_success_rate_description">Percentage of successful connections to the relay</string>
|
||||||
<string name="search_and_add_a_user">Search and add user</string>
|
<string name="search_and_add_a_user">Search and add user</string>
|
||||||
<string name="add_a_relay">Add a Relay</string>
|
<string name="add_a_relay">Add a Relay</string>
|
||||||
|
<string name="relay_url_not_valid">Not a valid relay address. Use a host name, or an IP address in brackets (for example [201:d0e:9ba5:8bbc::1]:8080).</string>
|
||||||
<string name="my_name">My @tag name</string>
|
<string name="my_name">My @tag name</string>
|
||||||
<string name="display_name">Display Name</string>
|
<string name="display_name">Display Name</string>
|
||||||
<string name="my_display_name">My display name</string>
|
<string name="my_display_name">My display name</string>
|
||||||
|
|||||||
+6
@@ -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.NormalizedRelayUrl
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isLocalHost
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOnion
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.isOverlayNetwork
|
||||||
|
|
||||||
class TorRelayEvaluation(
|
class TorRelayEvaluation(
|
||||||
val torSettings: TorRelaySettings,
|
val torSettings: TorRelaySettings,
|
||||||
@@ -36,6 +37,11 @@ class TorRelayEvaluation(
|
|||||||
} else {
|
} else {
|
||||||
if (relay.isLocalHost()) {
|
if (relay.isLocalHost()) {
|
||||||
false
|
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()) {
|
} else if (relay.isOnion()) {
|
||||||
// .onion is only reachable over Tor regardless of any other classification.
|
// .onion is only reachable over Tor regardless of any other classification.
|
||||||
torSettings.onionRelaysViaTor
|
torSettings.onionRelaysViaTor
|
||||||
|
|||||||
+70
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
+6
-1
@@ -44,6 +44,11 @@ fun NormalizedRelayUrl.toHttp() =
|
|||||||
"https://$url"
|
"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)
|
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)
|
||||||
|
|||||||
+222
-12
@@ -21,6 +21,8 @@
|
|||||||
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
|
package com.vitorpamplona.quartz.nip01Core.relay.normalizer
|
||||||
|
|
||||||
import androidx.collection.LruCache
|
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.Log
|
||||||
import com.vitorpamplona.quartz.utils.Rfc3986
|
import com.vitorpamplona.quartz.utils.Rfc3986
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -38,15 +40,183 @@ val normalizedUrls = LruCache<String, NormalizationResult>(5000)
|
|||||||
|
|
||||||
class RelayUrlNormalizer {
|
class RelayUrlNormalizer {
|
||||||
companion object {
|
companion object {
|
||||||
fun isLocalHost(url: String) =
|
/**
|
||||||
url.contains("127.0.0.1") ||
|
* Every host test below is anchored to the **authority** (`host[:port]`), never to the
|
||||||
url.contains("localhost") ||
|
* whole url. A plain `contains` reads the path and query too, so
|
||||||
url.contains("//umbrel:") ||
|
* `wss://evil.example.com/127.0.0.1` used to answer true here — and since [isLocalHost]
|
||||||
url.contains("192.168.") ||
|
* is what exempts a relay from Tor, any relay list could hand the app a url that quietly
|
||||||
url.contains(".local:") ||
|
* dropped its own Tor routing. Relay urls arrive from other people (NIP-65 lists, relay
|
||||||
url.contains(".local/")
|
* 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'
|
fun isRelaySchemePrefix(url: String) = url.length > 6 && url[0] == 'w' && url[1] == 's'
|
||||||
|
|
||||||
@@ -82,7 +252,33 @@ class RelayUrlNormalizer {
|
|||||||
return false
|
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'
|
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
|
// protocol-relative urls (`//host/`) are just missing the scheme
|
||||||
val bare = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed
|
val protocolRelative = if (trimmed.startsWith("//")) trimmed.drop(2) else trimmed
|
||||||
if (bare.length < 4) return null
|
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)) {
|
if (!isBareHostAndPath(bare)) {
|
||||||
Log.d("RelayUrlNormalizer") { "Rejected $url" }
|
Log.d("RelayUrlNormalizer") { "Rejected $url" }
|
||||||
return null
|
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"
|
"ws://$bare"
|
||||||
} else {
|
} else {
|
||||||
"wss://$bare"
|
"wss://$bare"
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
+186
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+156
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -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<ByteArray> {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user