feat(aiturk): accept a relay id in pairing payloads
Contract v3 (tunnel): the QR / deep link may carry r=<lowercase UUIDv4> and no LAN address at all (the IDE's default). u is now 0..4, but a payload with neither u nor r is rejected, and a present-but-invalid r rejects the whole payload instead of falling back to the LAN addresses. AiturkRelay maps a relay id to the virtual host <relayId>.relay.aiturk.invalid that addresses a tunnel pairing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
1 parent
6f4309c746
commit
9afac6f508
2 files changed
+162
-5
No files matched your search
@@ -10,7 +10,11 @@ import java.util.Locale
|
|||||||
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
|
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
|
||||||
*
|
*
|
||||||
* QR / deep-link payload:
|
* QR / deep-link payload:
|
||||||
* `aiturk://pair?v=1&u=<ip:port>[&u=<ip:port>… max 4]&fp=<43 chars>&c=<8 char code>`
|
* `aiturk://pair?v=1&fp=<43 chars>&c=<8 char code>[&r=<relayId>][&u=<ip:port>… 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(
|
data class AiturkEndpoint(
|
||||||
val host: String,
|
val host: String,
|
||||||
@@ -23,10 +27,14 @@ data class AiturkEndpoint(
|
|||||||
val baseUrl: String get() = "https://$host:$port/"
|
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(
|
data class AiturkPairingOffer(
|
||||||
val endpoints: List<AiturkEndpoint>,
|
val endpoints: List<AiturkEndpoint>,
|
||||||
val fingerprint: String,
|
val fingerprint: String,
|
||||||
|
val relay: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class AiturkPairingPayload(
|
data class AiturkPairingPayload(
|
||||||
@@ -50,9 +58,12 @@ enum class AiturkPayloadError {
|
|||||||
/** A `u` entry is not `<private IPv4>:<port>`. */
|
/** A `u` entry is not `<private IPv4>:<port>`. */
|
||||||
BAD_ADDRESS,
|
BAD_ADDRESS,
|
||||||
|
|
||||||
/** No `u`, or more than [AiturkPairing.MAX_ENDPOINTS]. */
|
/** More than [AiturkPairing.MAX_ENDPOINTS] `u`, or neither `u` nor `r`. */
|
||||||
BAD_ADDRESS_COUNT,
|
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. */
|
/** `c` is not 8 characters of the pairing alphabet. */
|
||||||
BAD_CODE,
|
BAD_CODE,
|
||||||
}
|
}
|
||||||
@@ -106,8 +117,18 @@ object AiturkPairing {
|
|||||||
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
|
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 }
|
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)
|
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
|
||||||
}
|
}
|
||||||
val endpoints =
|
val endpoints =
|
||||||
@@ -121,7 +142,7 @@ object AiturkPairing {
|
|||||||
|
|
||||||
return AiturkPayloadResult.Ok(
|
return AiturkPayloadResult.Ok(
|
||||||
AiturkPairingPayload(
|
AiturkPairingPayload(
|
||||||
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint),
|
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint, relay),
|
||||||
code = code,
|
code = code,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
@@ -194,3 +215,42 @@ object AiturkPairing {
|
|||||||
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
|
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
|
||||||
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
|
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
|
||||||
|
* `<relayId>.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
|
||||||
|
}
|
||||||
@@ -16,14 +16,111 @@ class AiturkPairingPayloadTest {
|
|||||||
us: List<String> = listOf("192.168.1.20:47321"),
|
us: List<String> = listOf("192.168.1.20:47321"),
|
||||||
fingerprint: String? = fp,
|
fingerprint: String? = fp,
|
||||||
code: String? = "ABCDEFGH",
|
code: String? = "ABCDEFGH",
|
||||||
|
relays: List<String> = emptyList(),
|
||||||
): String =
|
): String =
|
||||||
buildList {
|
buildList {
|
||||||
v?.let { add("v=$it") }
|
v?.let { add("v=$it") }
|
||||||
us.forEach { add("u=$it") }
|
us.forEach { add("u=$it") }
|
||||||
fingerprint?.let { add("fp=$it") }
|
fingerprint?.let { add("fp=$it") }
|
||||||
code?.let { add("c=$it") }
|
code?.let { add("c=$it") }
|
||||||
|
relays.forEach { add("r=$it") }
|
||||||
}.joinToString("&", prefix = "aiturk://pair?")
|
}.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=<relayId>
|
||||||
|
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
|
private fun errorOf(raw: String): AiturkPayloadError? = (AiturkPairing.parse(raw) as? AiturkPayloadResult.Invalid)?.error
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in new issue
Block a user