diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayload.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayload.kt index ea0fc736..81c30feb 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayload.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayload.kt @@ -10,7 +10,11 @@ import java.util.Locale * AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable. * * QR / deep-link payload: - * `aiturk://pair?v=1&u=[&u=… max 4]&fp=<43 chars>&c=<8 char code>` + * `aiturk://pair?v=1&fp=<43 chars>&c=<8 char code>[&r=][&u=… max 4]` + * + * Contract v3 (tunnel): `r` is a lowercase UUIDv4 relay id and is what the IDE + * sends by default; `u` (0..4) is only present when the user allowed direct + * same-Wi-Fi connections. A payload with neither `u` nor `r` is rejected. */ data class AiturkEndpoint( val host: String, @@ -23,10 +27,14 @@ data class AiturkEndpoint( val baseUrl: String get() = "https://$host:$port/" } -/** A pairing offer: where the IDE listens and which certificate it must present. */ +/** + * A pairing offer: where the IDE listens and which certificate it must present + * (on the LAN and inside the tunnel alike). [relay] is the validated relay id or null. + */ data class AiturkPairingOffer( val endpoints: List, val fingerprint: String, + val relay: String? = null, ) data class AiturkPairingPayload( @@ -50,9 +58,12 @@ enum class AiturkPayloadError { /** A `u` entry is not `:`. */ BAD_ADDRESS, - /** No `u`, or more than [AiturkPairing.MAX_ENDPOINTS]. */ + /** More than [AiturkPairing.MAX_ENDPOINTS] `u`, or neither `u` nor `r`. */ BAD_ADDRESS_COUNT, + /** `r` is present but is not a lowercase UUIDv4 relay id. */ + BAD_RELAY, + /** `c` is not 8 characters of the pairing alphabet. */ BAD_CODE, } @@ -106,8 +117,18 @@ object AiturkPairing { return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT) } + // Relay first: a present-but-wrong `r` rejects the whole payload, it is + // never silently dropped in favour of the LAN candidates. + val rawRelays = params.filter { it.first == "r" }.map { it.second } + val relay = + when (rawRelays.size) { + 0 -> null + 1 -> AiturkRelay.parse(rawRelays.single()) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY) + else -> return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_RELAY) + } + val rawEndpoints = params.filter { it.first == "u" }.map { it.second } - if (rawEndpoints.isEmpty() || rawEndpoints.size > MAX_ENDPOINTS) { + if (rawEndpoints.size > MAX_ENDPOINTS || (rawEndpoints.isEmpty() && relay == null)) { return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT) } val endpoints = @@ -121,7 +142,7 @@ object AiturkPairing { return AiturkPayloadResult.Ok( AiturkPairingPayload( - offer = AiturkPairingOffer(endpoints.distinct(), fingerprint), + offer = AiturkPairingOffer(endpoints.distinct(), fingerprint, relay), code = code, ), ) @@ -194,3 +215,42 @@ object AiturkPairing { // '+' must stay literal: URLDecoder would otherwise turn it into a space. private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8") } + +/** + * The turkservis.online tunnel (contract v3). `r` / `relay` is a bare relay id + * (lowercase UUIDv4). A relay pairing is addressed by the VIRTUAL host + * `.relay.aiturk.invalid` — a name that never resolves on the + * internet; [AiturkTunnel] turns connections to it into an end-to-end + * encrypted stream through the relay. + */ +object AiturkRelay { + const val HOST_SUFFIX = ".relay.aiturk.invalid" + const val PORT = 443 + + private const val UUID4 = "[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" + private val relayId = Regex(UUID4) + + /** + * Untrusted input (QR `r`, gateway `relay`): returns the value only when it + * is EXACTLY a lowercase UUIDv4 — no URL, host, path, upper case, braces or + * surrounding whitespace. + */ + fun parse(raw: String): String? = raw.takeIf { relayId.matches(it) } + + fun virtualHost(relayId: String): String = relayId + HOST_SUFFIX + + /** Key used by the certificate pin store for a relay pairing. */ + fun hostPort(relayId: String): String = "${virtualHost(relayId)}:$PORT" + + /** The saved connection base: no path prefix, trailing slash. */ + fun baseUrl(relayId: String): String = "https://${virtualHost(relayId)}/" + + /** The relay id behind a virtual host name, or null for any other host. */ + fun relayIdOfHost(host: String?): String? = + host + ?.takeIf { it.endsWith(HOST_SUFFIX) } + ?.removeSuffix(HOST_SUFFIX) + ?.let(::parse) + + fun isVirtualHost(host: String?): Boolean = relayIdOfHost(host) != null +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayloadTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayloadTest.kt index 6941e539..5bf63244 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayloadTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayloadTest.kt @@ -16,14 +16,111 @@ class AiturkPairingPayloadTest { us: List = listOf("192.168.1.20:47321"), fingerprint: String? = fp, code: String? = "ABCDEFGH", + relays: List = emptyList(), ): String = buildList { v?.let { add("v=$it") } us.forEach { add("u=$it") } fingerprint?.let { add("fp=$it") } code?.let { add("c=$it") } + relays.forEach { add("r=$it") } }.joinToString("&", prefix = "aiturk://pair?") + private val relayId = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f" + + // ── Contract v3: relay id ──────────────────────────────────────────── + + @Test + fun relayOnlyPayloadIsTheDefaultAndNeedsNoAddress() { + // Exactly what the IDE prints by default: aiturk://pair?v=1&fp=…&c=…&r= + val result = AiturkPairing.parse("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId") + + val offer = (result as AiturkPayloadResult.Ok).payload.offer + assertEquals(relayId, offer.relay) + assertTrue(offer.endpoints.isEmpty()) + assertEquals(fp, offer.fingerprint) + assertEquals("https://$relayId.relay.aiturk.invalid/", AiturkRelay.baseUrl(relayId)) + assertEquals("$relayId.relay.aiturk.invalid:443", AiturkRelay.hostPort(relayId)) + assertEquals(relayId, AiturkRelay.relayIdOfHost("$relayId.relay.aiturk.invalid")) + } + + @Test + fun relayAndLanAddressesCanComeTogether() { + val result = AiturkPairing.parse(link(us = listOf("192.168.1.20:47321", "10.0.0.5:47322"), relays = listOf(relayId))) + + val offer = (result as AiturkPayloadResult.Ok).payload.offer + assertEquals(relayId, offer.relay) + assertEquals(2, offer.endpoints.size) + // Without `r` the offer simply has no relay. + assertNull((AiturkPairing.parse(link()) as AiturkPayloadResult.Ok).payload.offer.relay) + } + + @Test + fun anythingButALowercaseUuid4RelayIdRejectsThePayload() { + val rejected = + listOf( + // 1. the cancelled v2 form (a URL) + "https%3A%2F%2Fturkservis.online%2Fv1%2Faiturk%2Fmobile-relay%2Fr%2F$relayId", + // 2. upper case + relayId.uppercase(Locale.ROOT), + // 3. not version 4 + "3f2b8c1e-5d4a-1b7c-9e1f-0a1b2c3d4e5f", + // 4. wrong variant nibble + "3f2b8c1e-5d4a-4b7c-ce1f-0a1b2c3d4e5f", + // 5. a host name smuggled in + "$relayId.evil.example", + // 6. extra path + "$relayId%2F..%2Fhost", + // 7. surrounding whitespace + "%20$relayId", + // 8. braces / no dashes + "%7B$relayId%7D", + relayId.replace("-", ""), + // 9. too short / empty-ish + relayId.dropLast(1), + "x", + ) + rejected.forEach { value -> + // The LAN address is valid: a bad `r` must still reject the whole payload. + assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(relays = listOf(value))), value) + assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(us = emptyList(), relays = listOf(value))), value) + assertNull(AiturkRelay.parse(java.net.URLDecoder.decode(value, "UTF-8")), value) + } + // 10. `r` given twice + assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link(relays = listOf(relayId, relayId)))) + // An empty `r=` is a parameter without a value. + assertEquals(AiturkPayloadError.MALFORMED, errorOf(link() + "&r")) + assertEquals(AiturkPayloadError.BAD_RELAY, errorOf(link() + "&r=")) + } + + @Test + fun payloadWithNeitherAddressNorRelayIsRejected() { + assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = emptyList()))) + val five = (1..5).map { "192.168.1.$it:47321" } + assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = five, relays = listOf(relayId)))) + // A relay does not excuse a bad LAN address. + assertEquals(AiturkPayloadError.BAD_ADDRESS, errorOf(link(us = listOf("8.8.8.8:443"), relays = listOf(relayId)))) + // The fingerprint stays mandatory: it is what makes the tunnel end-to-end. + assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf("aiturk://pair?v=1&c=ABCDEFGH&r=$relayId")) + } + + @Test + fun onlyExactVirtualHostsAreRelayHosts() { + assertTrue(AiturkRelay.isVirtualHost("$relayId.relay.aiturk.invalid")) + listOf( + null, + "", + "relay.aiturk.invalid", + ".relay.aiturk.invalid", + "abc.relay.aiturk.invalid", + "${relayId.uppercase(Locale.ROOT)}.relay.aiturk.invalid", + "$relayId.relay.aiturk.invalid.evil.example", + "x.$relayId.relay.aiturk.invalid", + "turkservis.online", + "192.168.1.20", + ).forEach { assertFalse(AiturkRelay.isVirtualHost(it), it.toString()) } + } + private fun errorOf(raw: String): AiturkPayloadError? = (AiturkPairing.parse(raw) as? AiturkPayloadResult.Invalid)?.error @Test