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:
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user