feat(aiturk): pair with AITURK IDE over LAN with a pinned certificate

Adds the phone side of the pairing contract v1: payload parsing, gateway resolve, parallel hello and a single claim, per host:port certificate pinning hooked into OkHttpProvider.base, and no dashboard token refresh for a paired IDE. Adds zxing-android-embedded and okhttp-tls (tests).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-04 06:06:31 +03:00
1 parent 8e48a0fa1e
commit c2b08ff73e
15 files changed
+1849

No files matched your search

@@ -0,0 +1,190 @@
package com.m57.hermescontrol.data.aiturk
import okhttp3.OkHttpClient
import okhttp3.Request
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertThrows
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.IOException
import java.net.Socket
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.concurrent.TimeUnit
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.X509TrustManager
class AiturkLanTrustTest {
private val memory = MemoryPins()
private val ides = mutableListOf<FakeIde>()
@BeforeEach
fun setUp() {
AiturkLanTrust.resetForTest(memory)
}
@AfterEach
fun tearDown() {
ides.forEach { runCatching { it.stop() } }
AiturkLanTrust.resetForTest(MemoryPins())
}
private fun ide(certificate: okhttp3.tls.HeldCertificate = FakeIde.selfSigned()): FakeIde =
FakeIde(certificate).start().also { ides += it }
/** Built exactly like OkHttpProvider.base wires it: the single `applyTo` hook. */
private fun appClient(): OkHttpClient =
AiturkLanTrust
.applyTo(OkHttpClient.Builder())
.connectTimeout(3, TimeUnit.SECONDS)
.readTimeout(3, TimeUnit.SECONDS)
.build()
private fun get(
client: OkHttpClient,
server: FakeIde,
): Int =
client
.newCall(Request.Builder().url("https://${server.endpoint.hostPort}/aiturk/pair/hello").build())
.execute()
.use { it.code }
@Test
fun pairedHostPortIsTrustedByFingerprintAlone() {
val server = ide()
AiturkLanTrust.pin(server.endpoint.hostPort, server.fingerprint)
assertEquals(200, get(appClient(), server))
}
@Test
fun pairedHostPortWithAnotherCertificateIsRejected() {
val server = ide()
AiturkLanTrust.pin(server.endpoint.hostPort, FakeIde.fingerprintOf(FakeIde.selfSigned()))
assertThrows(IOException::class.java) { get(appClient(), server) }
assertTrue(server.paths.isEmpty())
}
@Test
fun pinDoesNotRelaxValidationForAnyOtherAddress() {
// Same certificate on two ports of the same host; only one port is paired.
val certificate = FakeIde.selfSigned()
val paired = ide(certificate)
val other = ide(certificate)
AiturkLanTrust.pin(paired.endpoint.hostPort, paired.fingerprint)
val client = appClient()
assertEquals(200, get(client, paired))
// The unpaired port falls through to the platform trust store, which
// does not know this self-signed certificate.
assertThrows(IOException::class.java) { get(client, other) }
assertTrue(other.paths.isEmpty())
}
@Test
fun withoutAnyPinSelfSignedServersAreStillRejected() {
val server = ide()
assertThrows(IOException::class.java) { get(appClient(), server) }
}
@Test
fun unpairedPeersAreHandedToThePlatformTrustManagerUnchanged() {
val server = ide()
val chain = arrayOf(server.certificate.certificate)
val delegate = RecordingTrustManager()
val manager = PinAwareTrustManager(delegate, { null }, { emptyList() })
manager.checkServerTrusted(chain, "ECDHE_ECDSA", null as Socket?)
manager.checkServerTrusted(chain, "ECDHE_ECDSA")
manager.checkServerTrusted(chain, "ECDHE_ECDSA", "example.com")
assertEquals(3, delegate.serverChecks)
// And a delegate that refuses is not overruled.
val strict = PinAwareTrustManager(RecordingTrustManager(reject = true), { null }, { emptyList() })
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA", null as Socket?) }
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA") }
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA", "example.com") }
}
@Test
fun hostnameVerificationIsDelegatedForUnpairedHosts() {
var delegated = 0
val delegate =
HostnameVerifier { _, _ ->
delegated++
false
}
val verifier = PinAwareHostnameVerifier(delegate) { null }
val session = io.mockk.mockk<javax.net.ssl.SSLSession>()
io.mockk.every { session.peerPort } returns 443
assertFalse(verifier.verify("example.com", session))
assertEquals(1, delegated)
}
@Test
fun pinsSurviveARestartAndCanBeRemoved() {
val fp = "A".repeat(43)
AiturkLanTrust.pin("192.168.1.20:47321", fp)
AiturkLanTrust.resetForTest(memory)
assertEquals(fp, AiturkLanTrust.pinFor("192.168.1.20:47321"))
assertTrue(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47321/"))
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47322/"))
assertFalse(AiturkLanTrust.isPairedUrl("http://192.168.1.20:47321/"))
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.21:47321/"))
assertFalse(AiturkLanTrust.isPairedUrl(null))
AiturkLanTrust.unpin("192.168.1.20:47321")
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
assertTrue(memory.stored.isEmpty())
}
@Test
fun malformedFingerprintCannotBePinned() {
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("192.168.1.20:47321", "nope") }
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
}
@Test
fun unreadableStorageMeansNothingIsPaired() {
AiturkLanTrust.resetForTest(
object : AiturkLanTrust.PinPersistence {
override fun load(): Map<String, String> = error("storage not ready")
override fun save(pins: Map<String, String>) = Unit
},
)
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47321/"))
}
private class RecordingTrustManager(
private val reject: Boolean = false,
) : X509TrustManager {
var serverChecks = 0
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) = Unit
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) {
serverChecks++
if (reject) throw CertificateException("untrusted")
}
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
}
@@ -0,0 +1,52 @@
package com.m57.hermescontrol.data.aiturk
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.io.File
/**
* Pairing code handles a one-time code and a long-lived device token. The
* simplest guarantee that neither reaches logcat is that this code does not
* log at all — this test keeps it that way.
*/
class AiturkNoSecretLoggingTest {
private val sourceRoot = File("src/main/java/com/m57/hermescontrol")
private val forbidden =
listOf(
Regex("""\bLog\.[a-z]+\("""),
Regex("""\bprintln\("""),
Regex("""\bprint\("""),
Regex("""printStackTrace"""),
Regex("""HttpLoggingInterceptor"""),
Regex("""\bTimber\b"""),
)
@Test
fun pairingSourcesDoNotLog() {
val files =
listOf("data/aiturk", "ui/aiturkpair")
.map { File(sourceRoot, it) }
.onEach { assertTrue(it.isDirectory, "missing source directory: ${it.absolutePath}") }
.flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() }
assertTrue(files.size >= 5, "expected the pairing sources, found ${files.map { it.name }}")
val offenders =
files.flatMap { file ->
file.readLines().mapIndexedNotNull { index, line ->
if (forbidden.any { it.containsMatchIn(line) }) "${file.name}:${index + 1}: ${line.trim()}" else null
}
}
assertTrue(offenders.isEmpty(), "logging found in pairing code:\n" + offenders.joinToString("\n"))
}
@Test
fun pairingClientUsesItsOwnBareHttpClient() {
// The shared OkHttpProvider clients carry the cookie jar, user-defined
// custom headers and (in debug) request logging — none of that may
// touch the gateway lookup or the claim that carries the code.
val source = File(sourceRoot, "data/aiturk/AiturkPairingClient.kt").readText()
assertTrue(!source.contains("OkHttpProvider"), "AiturkPairingClient must not use OkHttpProvider")
}
}
@@ -0,0 +1,256 @@
package com.m57.hermescontrol.data.aiturk
import kotlinx.coroutines.runBlocking
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.SocketPolicy
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.net.ServerSocket
class AiturkPairingClientTest {
private lateinit var gateway: MockWebServer
private val ides = mutableListOf<FakeIde>()
@BeforeEach
fun setUp() {
gateway = MockWebServer()
gateway.start()
}
@AfterEach
fun tearDown() {
gateway.shutdown()
ides.forEach { runCatching { it.stop() } }
}
private fun ide(configure: FakeIde.() -> Unit = {}): FakeIde = FakeIde().apply(configure).start().also { ides += it }
private fun client(helloTimeoutMs: Long = 2_500L) =
AiturkPairingClient(gatewayBaseUrl = gateway.url("/"), helloTimeoutMs = helloTimeoutMs)
private fun <T> AiturkPairResult<T>.error(): AiturkPairError? = (this as? AiturkPairResult.Failure)?.error
private fun closedPort(): Int = ServerSocket(0).use { it.localPort }
// ── resolve (gateway) ────────────────────────────────────────────────
@Test
fun resolveSendsOnlyTheLookupHashAndReturnsTheOffer() =
runBlocking {
val fp = "A".repeat(43)
gateway.enqueue(
FakeIde.json(200, """{"urls":["https://192.168.1.20:47321","https://10.0.0.5:47322"],"fp":"$fp","expiresIn":280}"""),
)
val result = client().resolve("ABCDEFGH")
val offer = (result as AiturkPairResult.Success).value
assertEquals(listOf(AiturkEndpoint("192.168.1.20", 47321), AiturkEndpoint("10.0.0.5", 47322)), offer.endpoints)
assertEquals(fp, offer.fingerprint)
val request = gateway.takeRequest()
assertEquals("POST", request.method)
assertEquals("/v1/aiturk/mobile-pair/resolve", request.path)
val body = request.body.readUtf8()
assertEquals("""{"lookup":"${AiturkPairingPayloadTest.LOOKUP_ABCDEFGH}"}""", body)
assertFalse(body.contains("ABCDEFGH"), "the raw code must never reach the gateway")
}
@Test
fun resolveMapsNotFoundAndRateLimit() =
runBlocking {
gateway.enqueue(MockResponse().setResponseCode(404))
gateway.enqueue(MockResponse().setResponseCode(429))
gateway.enqueue(MockResponse().setResponseCode(500))
assertEquals(AiturkPairError.CODE_NOT_FOUND, client().resolve("ABCDEFGH").error())
assertEquals(AiturkPairError.RATE_LIMITED, client().resolve("ABCDEFGH").error())
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error())
}
@Test
fun resolveRejectsOffersThatPointOutsideThePrivateNetwork() =
runBlocking {
val fp = "A".repeat(43)
listOf(
"""{"urls":["https://8.8.8.8:47321"],"fp":"$fp"}""",
"""{"urls":["http://192.168.1.20:47321"],"fp":"$fp"}""",
"""{"urls":["https://evil.example:443"],"fp":"$fp"}""",
"""{"urls":[],"fp":"$fp"}""",
"""{"urls":["https://192.168.1.1:1","https://192.168.1.2:1","https://192.168.1.3:1","https://192.168.1.4:1","https://192.168.1.5:1"],"fp":"$fp"}""",
"""{"urls":["https://192.168.1.20:47321"],"fp":"short"}""",
"""{"urls":["https://192.168.1.20:47321"]}""",
"""not json""",
).forEach { body ->
gateway.enqueue(FakeIde.json(200, body))
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error(), body)
}
}
@Test
fun resolveReportsAnUnreachableGateway() =
runBlocking {
gateway.shutdown()
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error())
}
// ── hello + claim (computer) ─────────────────────────────────────────
@Test
fun firstReachableCandidateIsClaimedExactlyOnce() =
runBlocking {
val good = ide()
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
val offer = AiturkPairingOffer(listOf(dead, good.endpoint), good.fingerprint)
val result = client().pair(offer, "ABCDEFGH", "Pixel 9")
val device = (result as AiturkPairResult.Success).value
assertEquals(good.endpoint, device.endpoint)
assertEquals(good.fingerprint, device.fingerprint)
assertEquals(FakeIde.TOKEN, device.token)
assertEquals("MASTE-PC", device.pcName)
assertEquals("7c0e8a52-0000-4000-8000-000000000001", device.deviceId)
assertEquals(1, good.helloCount)
assertEquals(1, good.claimCount)
assertEquals("""{"code":"ABCDEFGH","device_name":"Pixel 9"}""", good.claimBodies.single())
}
@Test
fun onlyOneClaimIsSentWhenSeveralCandidatesAnswer() =
runBlocking {
val certificate = FakeIde.selfSigned()
val first = FakeIde(certificate).start().also { ides += it }
val second = FakeIde(certificate).start().also { ides += it }
val offer = AiturkPairingOffer(listOf(first.endpoint, second.endpoint), first.fingerprint)
val result = client().pair(offer, "ABCDEFGH", "Pixel 9")
assertTrue(result is AiturkPairResult.Success)
assertEquals(1, first.claimCount + second.claimCount)
}
@Test
fun claimErrorsAreMappedByNameAndNeverRetried() =
runBlocking {
val cases =
listOf(
Triple(401, "wrong_code", AiturkPairError.WRONG_CODE),
Triple(410, "pairing_closed", AiturkPairError.PAIRING_CLOSED),
Triple(410, "pairing_locked", AiturkPairError.PAIRING_LOCKED),
Triple(400, "invalid_request", AiturkPairError.INVALID_REQUEST),
)
cases.forEach { (status, name, expected) ->
val server = ide { claim = FakeIde.json(status, """{"error":"$name"}""") }
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
assertEquals(expected, result.error(), name)
assertEquals(1, server.claimCount, "$name must not be retried")
}
}
@Test
fun claimErrorsFallBackToTheStatusCodeWithoutABody() {
assertEquals(AiturkPairError.WRONG_CODE, AiturkPairingClient.claimError(401, null))
assertEquals(AiturkPairError.PAIRING_CLOSED, AiturkPairingClient.claimError(410, null))
assertEquals(AiturkPairError.INVALID_REQUEST, AiturkPairingClient.claimError(413, null))
assertEquals(AiturkPairError.INVALID_REQUEST, AiturkPairingClient.claimError(400, "something_new"))
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, AiturkPairingClient.claimError(500, null))
// The contract name wins over the status.
assertEquals(AiturkPairError.PAIRING_LOCKED, AiturkPairingClient.claimError(410, "pairing_locked"))
}
@Test
fun claimThatEndsWithoutAUsableAnswerFailsAfterASingleAttempt() =
runBlocking {
val server = ide { claim = MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) }
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, result.error())
assertEquals(1, server.claimCount)
}
@Test
fun pairingThatIsNotOpenIsReportedWithoutSpendingAClaim() =
runBlocking {
val server = ide { hello = FakeIde.json(200, """{"app":"aiturk-ide","version":"1.2.3","pairing":false}""") }
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
assertEquals(AiturkPairError.PAIRING_CLOSED, result.error())
assertEquals(0, server.claimCount)
}
@Test
fun serverWithAnotherCertificateIsRejectedBeforeAnyRequest() =
runBlocking {
val impostor = ide()
val expected = FakeIde.fingerprintOf(FakeIde.selfSigned())
val result = client().pair(AiturkPairingOffer(listOf(impostor.endpoint), expected), "ABCDEFGH", "x")
assertEquals(AiturkPairError.PC_UNREACHABLE, result.error())
// The TLS handshake fails, so neither hello nor the code ever reaches it.
assertTrue(impostor.paths.isEmpty(), "requests seen: ${impostor.paths}")
assertTrue(impostor.claimBodies.isEmpty())
}
@Test
fun somethingThatIsNotTheIdeIsNotClaimed() =
runBlocking {
val other = ide { hello = FakeIde.json(200, """{"app":"something-else"}""") }
val notFound = ide { hello = FakeIde.json(404, """{"error":"not_found"}""") }
val fp = other.fingerprint
assertEquals(
AiturkPairError.PC_UNREACHABLE,
client().pair(AiturkPairingOffer(listOf(other.endpoint), fp), "ABCDEFGH", "x").error(),
)
assertEquals(
AiturkPairError.PC_UNREACHABLE,
client().pair(AiturkPairingOffer(listOf(notFound.endpoint), notFound.fingerprint), "ABCDEFGH", "x").error(),
)
assertEquals(0, other.claimCount + notFound.claimCount)
}
@Test
fun silentCandidateTimesOut() =
runBlocking {
val silent = ide { hello = MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE) }
val startedAt = System.nanoTime()
val result =
client(helloTimeoutMs = 400L)
.pair(AiturkPairingOffer(listOf(silent.endpoint), silent.fingerprint), "ABCDEFGH", "x")
assertEquals(AiturkPairError.PC_UNREACHABLE, result.error())
assertTrue((System.nanoTime() - startedAt) / 1_000_000 < 5_000, "hello must give up quickly")
assertEquals(0, silent.claimCount)
}
@Test
fun malformedTokenIsNotAccepted() =
runBlocking {
listOf("amd_short", "xyz_" + "t".repeat(43), "amd_" + "t".repeat(42) + "!").forEach { token ->
val server = ide { claim = FakeIde.json(200, FakeIde.claimBody(token = token)) }
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, result.error(), token)
}
}
@Test
fun deviceNameIsTrimmedToTheContractLimit() {
assertEquals("Android", AiturkPairingClient.sanitizeDeviceName(" "))
assertEquals(60, AiturkPairingClient.sanitizeDeviceName("x".repeat(200)).length)
assertEquals("Galaxy S24", AiturkPairingClient.sanitizeDeviceName(" Galaxy\u0000 S24\n"))
}
}
@@ -0,0 +1,196 @@
package com.m57.hermescontrol.data.aiturk
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import java.security.MessageDigest
import java.util.Locale
class AiturkPairingPayloadTest {
private val fp = "A".repeat(43)
private fun link(
v: String? = "1",
us: List<String> = listOf("192.168.1.20:47321"),
fingerprint: String? = fp,
code: String? = "ABCDEFGH",
): String =
buildList {
v?.let { add("v=$it") }
us.forEach { add("u=$it") }
fingerprint?.let { add("fp=$it") }
code?.let { add("c=$it") }
}.joinToString("&", prefix = "aiturk://pair?")
private fun errorOf(raw: String): AiturkPayloadError? = (AiturkPairing.parse(raw) as? AiturkPayloadResult.Invalid)?.error
@Test
fun validPayloadWithSeveralAddressesIsParsedInOrder() {
val result =
AiturkPairing.parse(link(us = listOf("192.168.1.20:47321", "10.0.0.5:47322", "172.16.4.9:47321")))
val payload = (result as AiturkPayloadResult.Ok).payload
assertEquals(
listOf(
AiturkEndpoint("192.168.1.20", 47321),
AiturkEndpoint("10.0.0.5", 47322),
AiturkEndpoint("172.16.4.9", 47321),
),
payload.offer.endpoints,
)
assertEquals(fp, payload.offer.fingerprint)
assertEquals("ABCDEFGH", payload.code)
assertEquals("https://192.168.1.20:47321/", payload.offer.endpoints.first().baseUrl)
assertEquals("192.168.1.20:47321", payload.offer.endpoints.first().hostPort)
}
@Test
fun versionMismatchAsksForAnAppUpdate() {
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf(link(v = "2")))
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf(link(v = "01")))
// A newer QR is reported as such even when its other fields are unknown to us.
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf("aiturk://pair?v=2&x=1"))
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link(v = null)))
}
@Test
fun missingOrBrokenFingerprintIsRejected() {
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = null)))
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42))))
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(44))))
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "=")))
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "*")))
// Standard base64 alphabet ('+', '/') is not base64url.
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "%2B")))
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "/")))
}
@Test
fun onlyPrivateIpv4AddressesAreAccepted() {
listOf(
"8.8.8.8:47321",
"172.15.0.1:47321",
"172.32.0.1:47321",
"192.169.1.1:47321",
"127.0.0.1:47321",
"169.254.1.1:47321",
"100.64.0.1:47321",
"example.com:47321",
"192.168.1.20",
"192.168.1.20:0",
"192.168.1.20:65536",
"192.168.1.256:47321",
"192.168.01.20:47321",
"[fe80::1]:47321",
"192.168.1.20:47321/path",
"user@192.168.1.20:47321",
).forEach { address ->
assertEquals(AiturkPayloadError.BAD_ADDRESS, errorOf(link(us = listOf(address))), address)
}
// One bad entry spoils the whole payload.
assertEquals(
AiturkPayloadError.BAD_ADDRESS,
errorOf(link(us = listOf("192.168.1.20:47321", "8.8.8.8:47321"))),
)
listOf("10.255.255.255:1", "172.16.0.1:65535", "172.31.255.254:47321", "192.168.0.1:47340").forEach {
assertTrue(AiturkPairing.parse(link(us = listOf(it))) is AiturkPayloadResult.Ok, it)
}
}
@Test
fun addressCountMustBeOneToFour() {
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)))
assertTrue(AiturkPairing.parse(link(us = five.take(4))) is AiturkPayloadResult.Ok)
}
@Test
fun codeOutsideTheAlphabetIsRejected() {
// I, O, 0 and 1 are not in the alphabet.
listOf("ABCDEFGI", "ABCDEFGO", "ABCDEFG0", "ABCDEFG1", "ABCDEFG", "ABCDEFGHJ", "ABCD_FGH", "").forEach {
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link(code = it)), it)
}
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link(code = null)))
}
@Test
fun otherSchemesAndHostsAreNotPairingLinks() {
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("https://pair?v=1"))
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("aiturk://other?v=1"))
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("just some text"))
assertFalse(AiturkPairing.looksLikePairingLink("https://example.com"))
assertFalse(AiturkPairing.looksLikePairingLink(null))
assertTrue(AiturkPairing.looksLikePairingLink(link()))
}
@Test
fun duplicatedScalarFieldsAreRejected() {
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link() + "&fp=" + "B".repeat(43)))
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link() + "&c=HJKLMNPQ"))
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link() + "&v=1"))
}
@Test
fun codeIsNormalizedFromLowerCaseSpacesAndDashes() {
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode("abcdefgh"))
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode("ABCD-EFGH"))
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode(" abcd efgh \n"))
assertEquals("ABCD2345", AiturkPairing.normalizeCode("ab-cd 23-45"))
assertNull(AiturkPairing.normalizeCode("ABCDEFG"))
assertNull(AiturkPairing.normalizeCode("ABCDEFGHJ"))
assertNull(AiturkPairing.normalizeCode("ABCDEFG0"))
assertNull(AiturkPairing.normalizeCode("ABCD_EFGH"))
assertEquals("ABCDEFGH", (AiturkPairing.parse(link(code = "abcd-efgh")) as AiturkPayloadResult.Ok).payload.code)
}
@Test
fun normalizationIgnoresTheDeviceLocale() {
val previous = Locale.getDefault()
try {
// Turkish upper-cases 'i' to a dotted capital that is not in the alphabet.
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
assertEquals("QJKLMNPH", AiturkPairing.normalizeCode("qjklmnph"))
assertNull(AiturkPairing.normalizeCode("abcdefgi"))
assertEquals("ABCDEFGH", AiturkPairing.cleanCodeInput("abcd-efgh-i"))
} finally {
Locale.setDefault(previous)
}
}
@Test
fun textFieldInputKeepsOnlyAlphabetCharactersUpToEight() {
assertEquals("ABCDEFGH", AiturkPairing.cleanCodeInput("ab cd-ef01ghjk"))
assertEquals("AB", AiturkPairing.cleanCodeInput("a-b"))
}
@Test
fun lookupIsTheHexSha256OfThePrefixedCode() {
val computed =
MessageDigest
.getInstance("SHA-256")
.digest("aiturk-mobile-pair-v1:ABCDEFGH".toByteArray())
.joinToString("") { "%02x".format(it) }
assertEquals(computed, AiturkPairing.lookup("ABCDEFGH"))
assertEquals(LOOKUP_ABCDEFGH, AiturkPairing.lookup("ABCDEFGH"))
assertEquals(64, AiturkPairing.lookup("ABCDEFGH").length)
}
@Test
fun gatewayUrlsMustBeHttpsPrivateAddresses() {
assertEquals(AiturkEndpoint("192.168.1.20", 47321), AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321"))
assertEquals(AiturkEndpoint("192.168.1.20", 47321), AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321/"))
assertNull(AiturkPairing.parseEndpointUrl("http://192.168.1.20:47321"))
assertNull(AiturkPairing.parseEndpointUrl("https://8.8.8.8:47321"))
assertNull(AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321/x"))
assertNull(AiturkPairing.parseEndpointUrl("https://evil.example:47321"))
}
companion object {
/** hex(sha256("aiturk-mobile-pair-v1:ABCDEFGH")), computed independently (Python hashlib). */
const val LOOKUP_ABCDEFGH = "10c03d82b47dff47053d4a829d4615e63da1739e2d7fb418f9d4ee8cc6b4c38a"
}
}
@@ -0,0 +1,105 @@
package com.m57.hermescontrol.data.aiturk
import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import okhttp3.mockwebserver.RecordedRequest
import okhttp3.tls.HandshakeCertificates
import okhttp3.tls.HeldCertificate
import java.net.InetAddress
import java.security.MessageDigest
import java.util.Base64
import java.util.concurrent.CopyOnWriteArrayList
/** A local stand-in for the IDE's LAN proxy: TLS with a self-signed certificate. */
internal class FakeIde(
val certificate: HeldCertificate = selfSigned(),
) {
val server = MockWebServer()
val paths = CopyOnWriteArrayList<String>()
val claimBodies = CopyOnWriteArrayList<String>()
@Volatile
var hello: MockResponse = json(200, """{"app":"aiturk-ide","version":"1.2.3","pairing":true}""")
@Volatile
var claim: MockResponse = json(200, claimBody())
val endpoint: AiturkEndpoint get() = AiturkEndpoint(LOOPBACK, server.port)
val fingerprint: String get() = fingerprintOf(certificate)
val claimCount: Int get() = paths.count { it == "/aiturk/pair/claim" }
val helloCount: Int get() = paths.count { it == "/aiturk/pair/hello" }
fun start(): FakeIde {
val handshake = HandshakeCertificates.Builder().heldCertificate(certificate).build()
server.useHttps(handshake.sslSocketFactory(), false)
server.dispatcher =
object : Dispatcher() {
override fun dispatch(request: RecordedRequest): MockResponse {
val path = request.path.orEmpty()
paths += path
return when (path) {
"/aiturk/pair/hello" -> {
hello
}
"/aiturk/pair/claim" -> {
claimBodies += request.body.readUtf8()
claim
}
else -> {
json(404, """{"error":"not_found"}""")
}
}
}
}
server.start(InetAddress.getByName(LOOPBACK), 0)
return this
}
fun stop() = server.shutdown()
companion object {
const val LOOPBACK = "127.0.0.1"
val TOKEN = "amd_" + "t".repeat(43)
fun selfSigned(): HeldCertificate =
HeldCertificate
.Builder()
.commonName("aiturk-ide")
.addSubjectAlternativeName(LOOPBACK)
.build()
fun fingerprintOf(certificate: HeldCertificate): String =
Base64
.getUrlEncoder()
.withoutPadding()
.encodeToString(MessageDigest.getInstance("SHA-256").digest(certificate.certificate.encoded))
fun json(
code: Int,
body: String,
): MockResponse =
MockResponse()
.setResponseCode(code)
.setHeader("Content-Type", "application/json")
.setBody(body)
fun claimBody(
token: String = TOKEN,
pcName: String = "MASTE-PC",
): String = """{"token":"$token","device_id":"7c0e8a52-0000-4000-8000-000000000001","pc_name":"$pcName","version":"1.2.3"}"""
}
}
internal class MemoryPins(
val stored: MutableMap<String, String> = mutableMapOf(),
) : AiturkLanTrust.PinPersistence {
override fun load(): Map<String, String> = stored.toMap()
override fun save(pins: Map<String, String>) {
stored.clear()
stored.putAll(pins)
}
}
@@ -0,0 +1,103 @@
package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
import com.m57.hermescontrol.data.remote.OkHttpProvider
import io.mockk.every
import io.mockk.mockkObject
import io.mockk.unmockkAll
import io.mockk.verify
import kotlinx.coroutines.runBlocking
import okhttp3.Interceptor
import okhttp3.OkHttpClient
import okhttp3.mockwebserver.MockResponse
import okhttp3.mockwebserver.MockWebServer
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.io.IOException
import java.util.concurrent.atomic.AtomicInteger
/**
* Token mode refreshes the dashboard token with `GET <baseUrl>/` before every
* WebSocket handshake and on REST 401. An AITURK-paired IDE answers 404 there
* and its device token is long-lived, so the refresh must not run — and the
* stored `amd_…` token must never be replaced.
*/
class AiturkTokenRefreshSkipTest {
private val pairedUrl = "https://192.168.1.20:47321/"
private val calls = AtomicInteger()
@BeforeEach
fun setUp() {
AiturkLanTrust.resetForTest(MemoryPins())
mockkObject(AuthManager)
mockkObject(OkHttpProvider)
every { AuthManager.setToken(any()) } returns Unit
}
@AfterEach
fun tearDown() {
unmockkAll()
AiturkLanTrust.resetForTest(MemoryPins())
}
private fun countingClient(): OkHttpClient =
OkHttpClient
.Builder()
.addInterceptor(
Interceptor {
calls.incrementAndGet()
throw IOException("no network in this test")
},
).build()
@Test
fun pairedConnectionNeverAsksTheDashboardForAToken() =
runBlocking {
AiturkLanTrust.pin("192.168.1.20:47321", "A".repeat(43))
every { AuthManager.baseUrl() } returns pairedUrl
every { OkHttpProvider.probe } returns countingClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertNull(DashboardSessionTokenRefresher.refresh())
assertEquals(0, calls.get())
verify(exactly = 0) { AuthManager.setToken(any()) }
}
@Test
fun unpairedConnectionStillRefreshesAsBefore() =
runBlocking {
every { AuthManager.baseUrl() } returns pairedUrl
every { OkHttpProvider.probe } returns countingClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertEquals(1, calls.get())
}
@Test
fun notFoundOnTheRootPathLeavesTheStoredTokenAlone() =
runBlocking {
// What the IDE proxy answers on `/`, seen by a connection that is not marked as paired.
val server = MockWebServer()
server.start()
try {
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_found"}"""))
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_found"}"""))
every { AuthManager.baseUrl() } returns server.url("/").toString()
every { OkHttpProvider.probe } returns OkHttpClient()
assertNull(DashboardSessionTokenRefresher.refreshAsync())
assertNull(DashboardSessionTokenRefresher.refresh())
assertEquals(2, server.requestCount)
verify(exactly = 0) { AuthManager.setToken(any()) }
} finally {
server.shutdown()
}
}
}