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

+4
View File
@@ -201,6 +201,9 @@ dependencies {
implementation(libs.media3.ui) implementation(libs.media3.ui)
implementation(libs.media3.datasource.okhttp) implementation(libs.media3.datasource.okhttp)
// AITURK IDE pairing: QR scanner (ZXing, no Play Services)
implementation(libs.zxing.android.embedded)
// Encrypted storage // Encrypted storage
implementation(libs.androidx.security.crypto) implementation(libs.androidx.security.crypto)
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
@@ -218,6 +221,7 @@ dependencies {
testRuntimeOnly(libs.junit.vintage.engine) testRuntimeOnly(libs.junit.vintage.engine)
testRuntimeOnly(libs.junit.platform.launcher) testRuntimeOnly(libs.junit.platform.launcher)
testImplementation(libs.okhttp.mockwebserver) testImplementation(libs.okhttp.mockwebserver)
testImplementation(libs.okhttp.tls)
testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.mockk) testImplementation(libs.mockk)
testImplementation(libs.turbine) testImplementation(libs.turbine)
+5
View File
@@ -57,3 +57,8 @@
# Keep screen object class names because their simpleNames are used for bottom nav selection and storage. # Keep screen object class names because their simpleNames are used for bottom nav selection and storage.
-keep class * extends androidx.navigation3.runtime.NavKey { *; } -keep class * extends androidx.navigation3.runtime.NavKey { *; }
# ── AITURK IDE pairing ───────────────────────────────────────────────────
# Android looks this hostname-aware overload up by reflection (X509TrustManagerExtensions).
-keepclassmembers class com.m57.hermescontrol.data.aiturk.PinAwareTrustManager {
public java.util.List checkServerTrusted(java.security.cert.X509Certificate[], java.lang.String, java.lang.String);
}
@@ -0,0 +1,50 @@
package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
import com.m57.hermescontrol.data.ws.HermesWsClient
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* Stores a freshly claimed AITURK IDE pairing in the app's existing connection
* model: a [ConnectionProfile] named after the computer, token auth, WS `?token=`.
*/
object AiturkConnectionRegistrar {
const val FALLBACK_PC_NAME = "AITURK IDE"
suspend fun register(device: AiturkPairedDevice) {
val baseUrl = device.endpoint.baseUrl
val name = device.pcName.ifBlank { FALLBACK_PC_NAME }
// Trust first: every later request to this host:port is checked against the pin.
AiturkLanTrust.pin(device.endpoint.hostPort, device.fingerprint)
withContext(Dispatchers.IO) { HermesWsClient.disconnect() }
// Re-pairing the same computer reuses its profile instead of piling up duplicates.
val profiles = AuthManager.getConnectionProfiles()
val existing = profiles.firstOrNull { it.baseUrl == baseUrl }
val profile =
existing?.copy(name = name)
?: ConnectionProfile(name = name, host = "", port = 0, baseUrl = baseUrl)
AuthManager.saveConnectionProfiles(
if (existing != null) {
profiles.map { if (it.id == profile.id) profile else it }
} else {
profiles + profile
},
)
AuthManager.setSelectedProfileId(profile.id)
AuthManager.syncCookieStoreForProfile(profile.id)
// Same order as the manual token login (AuthLoginViewModel.connect).
AuthManager.setBaseUrl(baseUrl)
AuthManager.setToken(device.token)
AuthManager.setSessionCookie(null)
AuthManager.setWsAuthParam("token")
// Rebuilds Retrofit, tells chat the server changed, then re-dials the socket.
ProfileSwitchCoordinator.switchConnectionProfile(profile.id)
}
}
@@ -0,0 +1,345 @@
package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.runBlocking
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import java.net.Socket
import java.security.KeyStore
import java.security.MessageDigest
import java.security.cert.CertificateException
import java.security.cert.X509Certificate
import java.util.concurrent.ConcurrentHashMap
import javax.net.ssl.HostnameVerifier
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLEngine
import javax.net.ssl.SSLSession
import javax.net.ssl.SSLSocket
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509ExtendedTrustManager
import javax.net.ssl.X509TrustManager
/**
* TLS trust for AITURK IDE LAN pairing.
*
* The IDE's LAN proxy presents a self-signed certificate. Its DER SHA-256
* fingerprint travels in the QR code (or the gateway rendezvous record) and is
* stored here per `host:port`. For a paired `host:port` the certificate is
* accepted ONLY when its fingerprint matches (system CAs are not consulted);
* for every other address the platform's default trust manager and OkHttp's
* default hostname verifier decide exactly as before.
*/
object AiturkLanTrust {
/** Where pins are persisted. Swappable so JVM tests need no Android keystore. */
interface PinPersistence {
fun load(): Map<String, String>
fun save(pins: Map<String, String>)
}
private const val PREF_KEY = "aiturk_lan_pins"
private val pins = ConcurrentHashMap<String, String>()
@Volatile
private var loaded = false
@Volatile
internal var persistence: PinPersistence = EncryptedPrefsPersistence
private val platformTrustManager: X509TrustManager by lazy { defaultTrustManager() }
private val platformHostnameVerifier: HostnameVerifier by lazy { OkHttpClient.Builder().build().hostnameVerifier }
val trustManager: X509TrustManager by lazy { PinAwareTrustManager(platformTrustManager, ::pinFor, ::pinsForHost) }
val sslSocketFactory: SSLSocketFactory by lazy { socketFactoryFor(trustManager) }
val hostnameVerifier: HostnameVerifier by lazy { PinAwareHostnameVerifier(platformHostnameVerifier, ::pinFor) }
/**
* The single hook for [com.m57.hermescontrol.data.remote.OkHttpProvider.base]:
* every REST / WebSocket / media / image client derives from that builder.
* If the TLS objects cannot be built the builder is left untouched (platform
* defaults), which only means a paired IDE cannot be reached.
*/
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder =
runCatching {
builder
.sslSocketFactory(sslSocketFactory, trustManager)
.hostnameVerifier(hostnameVerifier)
}.getOrDefault(builder)
/**
* A client that talks ONLY to a server presenting [fingerprint] — used for
* `hello` / `claim` before the pin is stored. Nothing else is trusted on it.
*/
fun pinnedClient(
base: OkHttpClient,
fingerprint: String,
): OkHttpClient {
val lookup: (String) -> String? = { fingerprint }
val manager = PinAwareTrustManager(RejectAllTrustManager, lookup) { listOf(fingerprint) }
return base
.newBuilder()
.sslSocketFactory(socketFactoryFor(manager), manager)
.hostnameVerifier(PinAwareHostnameVerifier({ _, _ -> false }, lookup))
.build()
}
fun pin(
hostPort: String,
fingerprint: String,
) {
require(AiturkPairing.isValidFingerprint(fingerprint)) { "Invalid certificate fingerprint" }
ensureLoaded()
// Saving on top of an unread store would drop the pins already in it.
check(loaded) { "Secure storage is not ready" }
pins[hostPort] = fingerprint
persistence.save(pins.toMap())
}
fun unpin(hostPort: String) {
ensureLoaded()
if (pins.remove(hostPort) != null) persistence.save(pins.toMap())
}
fun pinFor(hostPort: String): String? {
ensureLoaded()
return pins[hostPort]
}
/** Pins of every paired port on [host] (the Android chain-cleaner path knows no port). */
internal fun pinsForHost(host: String): Collection<String> {
ensureLoaded()
return pins.filterKeys { it.substringBeforeLast(':') == host }.values
}
/** True when [baseUrl] is an HTTPS address of a paired AITURK IDE. */
fun isPairedUrl(baseUrl: String?): Boolean {
val url = baseUrl?.toHttpUrlOrNull() ?: return false
return url.isHttps && pinFor("${url.host}:${url.port}") != null
}
internal fun resetForTest(newPersistence: PinPersistence) {
persistence = newPersistence
pins.clear()
loaded = false
}
private fun ensureLoaded() {
if (loaded) return
synchronized(this) {
if (loaded) return
// Storage not ready yet (or broken): behave as "nothing paired" for
// now and look again on the next call. load() never blocks.
val stored = runCatching { persistence.load() }.getOrNull() ?: return
stored.forEach { (key, value) -> pins.putIfAbsent(key, value) }
loaded = true
}
}
/** DER SHA-256 of [certificate] compared in constant time with a base64url pin. */
internal fun matches(
certificate: X509Certificate,
fingerprint: String,
): Boolean {
val expected = AiturkPairing.decodeFingerprint(fingerprint) ?: return false
val actual = MessageDigest.getInstance("SHA-256").digest(certificate.encoded)
return MessageDigest.isEqual(expected, actual)
}
private fun socketFactoryFor(manager: X509TrustManager): SSLSocketFactory =
SSLContext
.getInstance("TLS")
.apply { init(null, arrayOf(manager), null) }
.socketFactory
private fun defaultTrustManager(): X509TrustManager {
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
factory.init(null as KeyStore?)
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
}
/** Pins live in the app's encrypted preferences next to the connection tokens. */
private object EncryptedPrefsPersistence : PinPersistence {
private fun prefs(): android.content.SharedPreferences {
// Never wait here: this runs inside TLS handshakes. A connection to a
// paired IDE cannot start before AuthManager is ready anyway, because
// its address and token come from the same store.
check(AuthManager.initializationState.value == AuthManager.InitializationState.Ready) {
"Secure storage is not ready"
}
return runBlocking { AuthManager.awaitPrefs() }
}
override fun load(): Map<String, String> {
val prefs = prefs()
val raw = prefs.getString(PREF_KEY, null) ?: return emptyMap()
return runCatching {
Json.parseToJsonElement(raw).jsonObject.mapValues { it.value.jsonPrimitive.content }
}.getOrDefault(emptyMap())
}
override fun save(pins: Map<String, String>) {
val prefs = prefs()
val encoded = JsonObject(pins.mapValues { JsonPrimitive(it.value) }).toString()
prefs.edit().putString(PREF_KEY, encoded).apply()
}
}
}
/**
* Checks the leaf certificate against the pin of the peer `host:port` when one
* exists; otherwise defers to [delegate] untouched.
*/
internal class PinAwareTrustManager(
private val delegate: X509TrustManager,
private val pinFor: (String) -> String?,
private val pinsForHost: (String) -> Collection<String>,
) : X509ExtendedTrustManager() {
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket?,
) {
val pin = peerOf(socket)?.let(pinFor)
when {
pin != null -> requirePinned(chain, pin)
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, socket)
else -> delegate.checkServerTrusted(chain, authType)
}
}
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine?,
) {
val pin = engine?.let { peerKey(it.peerHost, it.peerPort) }?.let(pinFor)
when {
pin != null -> requirePinned(chain, pin)
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, engine)
else -> delegate.checkServerTrusted(chain, authType)
}
}
/** No peer information: never apply a pin, platform validation only. */
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
) = delegate.checkServerTrusted(chain, authType)
/**
* Android's hostname-aware entry point (`X509TrustManagerExtensions`, used by
* OkHttp's certificate chain cleaner). Looked up reflectively by the platform.
* The port is unknown here, so any pin stored for [host] may match. This is
* not the handshake's trust decision (that is the socket/engine overload).
*/
@Suppress("unused")
fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
host: String,
): List<X509Certificate> {
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
if (pinsForHost(host).any { AiturkLanTrust.matches(leaf, it) }) return listOf(leaf)
val hostAware =
runCatching {
delegate.javaClass.getMethod(
"checkServerTrusted",
Array<X509Certificate>::class.java,
String::class.java,
String::class.java,
)
}.getOrNull()
if (hostAware == null) {
delegate.checkServerTrusted(chain, authType)
return chain.toList()
}
try {
@Suppress("UNCHECKED_CAST")
return hostAware.invoke(delegate, chain, authType, host) as? List<X509Certificate> ?: chain.toList()
} catch (e: java.lang.reflect.InvocationTargetException) {
throw e.targetException as? CertificateException ?: CertificateException(e.targetException)
}
}
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
) = delegate.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
socket: Socket?,
) = delegate.checkClientTrusted(chain, authType)
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
engine: SSLEngine?,
) = delegate.checkClientTrusted(chain, authType)
override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers
private fun requirePinned(
chain: Array<X509Certificate>,
pin: String,
) {
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
if (!AiturkLanTrust.matches(leaf, pin)) {
throw CertificateException("AITURK IDE certificate fingerprint mismatch")
}
}
private fun peerOf(socket: Socket?): String? {
if (socket == null) return null
// Prefer the identity the TLS layer was asked to reach (the URL's host
// and port); fall back to the connected address.
val session = (socket as? SSLSocket)?.let { runCatching { it.handshakeSession }.getOrNull() }
return session?.let { peerKey(it.peerHost, it.peerPort) }
?: peerKey(socket.inetAddress?.hostAddress, socket.port)
}
private fun peerKey(
host: String?,
port: Int,
): String? = if (host.isNullOrBlank() || port <= 0) null else "$host:$port"
}
/** Hostname check: fingerprint for a paired `host:port`, [delegate] for everything else. */
internal class PinAwareHostnameVerifier(
private val delegate: HostnameVerifier,
private val pinFor: (String) -> String?,
) : HostnameVerifier {
override fun verify(
hostname: String,
session: SSLSession,
): Boolean {
val pin = pinFor("$hostname:${session.peerPort}") ?: return delegate.verify(hostname, session)
val leaf =
runCatching { session.peerCertificates.firstOrNull() as? X509Certificate }.getOrNull()
?: return false
return AiturkLanTrust.matches(leaf, pin)
}
}
/** Used by [AiturkLanTrust.pinnedClient]: anything that is not the pinned certificate is refused. */
private object RejectAllTrustManager : X509TrustManager {
override fun checkClientTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("Not trusted")
override fun checkServerTrusted(
chain: Array<X509Certificate>,
authType: String,
): Unit = throw CertificateException("Not trusted")
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
}
@@ -0,0 +1,330 @@
package com.m57.hermescontrol.data.aiturk
import com.m57.hermescontrol.data.remote.await
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import okio.Buffer
import java.util.concurrent.TimeUnit
/** Error names follow the pairing contract (`wrong_code`, `pairing_closed`, …). */
enum class AiturkPairError {
/** Gateway `resolve` 404: unknown, expired or exhausted code. */
CODE_NOT_FOUND,
/** Gateway `resolve` 429. */
RATE_LIMITED,
/** Gateway unreachable or answered something unexpected. */
GATEWAY_UNAVAILABLE,
/** No candidate address answered `hello` with the pinned certificate in time. */
PC_UNREACHABLE,
/** `claim` 400 `invalid_request` (or 413). */
INVALID_REQUEST,
/** `claim` 401 `wrong_code`. */
WRONG_CODE,
/** `claim` 410 `pairing_closed` (not open, expired or code already used). */
PAIRING_CLOSED,
/** `claim` 410 `pairing_locked` (5 wrong attempts). */
PAIRING_LOCKED,
/** The computer answered, but not with what the contract promises. */
UNEXPECTED_RESPONSE,
}
data class AiturkPairedDevice(
val endpoint: AiturkEndpoint,
val fingerprint: String,
val token: String,
val deviceId: String,
val pcName: String,
val version: String,
)
sealed interface AiturkPairResult<out T> {
data class Success<T>(
val value: T,
) : AiturkPairResult<T>
data class Failure(
val error: AiturkPairError,
) : AiturkPairResult<Nothing>
}
/**
* Phone side of the AITURK IDE pairing handshake.
*
* Deliberately does not log: codes, tokens and bodies must never reach logcat.
*/
class AiturkPairingClient(
private val gatewayBaseUrl: HttpUrl = DEFAULT_GATEWAY.toHttpUrl(),
private val baseClient: OkHttpClient = defaultBaseClient,
private val helloTimeoutMs: Long = HELLO_TIMEOUT_MS,
) {
/** Code path: `resolve` the rendezvous record for a normalized code. */
suspend fun resolve(normalizedCode: String): AiturkPairResult<AiturkPairingOffer> =
withContext(Dispatchers.IO) {
val body =
buildJsonObject { put("lookup", AiturkPairing.lookup(normalizedCode)) }
.toString()
.toRequestBody(JSON)
val request =
Request
.Builder()
.url(gatewayBaseUrl.newBuilder().addPathSegments(RESOLVE_PATH).build())
.post(body)
.build()
val client =
baseClient
.newBuilder()
.callTimeout(GATEWAY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
try {
client.newCall(request).await().use { response ->
when (response.code) {
200 -> parseOffer(readJson(response))
404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND)
429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED)
else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
}
}
} catch (_: java.io.IOException) {
AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
}
}
/**
* `hello` to every candidate in parallel (pinned to the offer's fingerprint),
* then exactly ONE `claim` to the first that answered.
*/
suspend fun pair(
offer: AiturkPairingOffer,
normalizedCode: String,
deviceName: String,
): AiturkPairResult<AiturkPairedDevice> =
withContext(Dispatchers.IO) {
val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint)
val reachable =
firstReachable(pinned, offer.endpoints)
?: return@withContext AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE)
if (!reachable.pairingOpen) {
// The computer says pairing is not open: a claim could only be
// answered with 410 pairing_closed, so do not spend an attempt.
return@withContext AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED)
}
claim(pinned, reachable.endpoint, offer.fingerprint, normalizedCode, deviceName)
}
private data class Reachable(
val endpoint: AiturkEndpoint,
val pairingOpen: Boolean,
)
private suspend fun firstReachable(
pinned: OkHttpClient,
endpoints: List<AiturkEndpoint>,
): Reachable? {
val helloClient =
pinned
.newBuilder()
.connectTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
.readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
.callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
.build()
// Small margin over the per-call timeout so a slow candidate cannot hold the result.
return withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) {
channelFlow {
endpoints.forEach { endpoint ->
launch { hello(helloClient, endpoint)?.let { send(it) } }
}
}.firstOrNull()
}
}
private suspend fun hello(
client: OkHttpClient,
endpoint: AiturkEndpoint,
): Reachable? =
try {
val request = Request.Builder().url(lanUrl(endpoint, HELLO_PATH)).get().build()
client.newCall(request).await().use { response ->
if (response.code != 200) return null
val json = readJson(response) ?: return null
if (json.string("app") != IDE_APP_ID) return null
Reachable(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false)
}
} catch (_: java.io.IOException) {
null
}
private suspend fun claim(
pinned: OkHttpClient,
endpoint: AiturkEndpoint,
fingerprint: String,
normalizedCode: String,
deviceName: String,
): AiturkPairResult<AiturkPairedDevice> {
val body =
buildJsonObject {
put("code", normalizedCode)
put("device_name", sanitizeDeviceName(deviceName))
}.toString().toRequestBody(JSON)
val request = Request.Builder().url(lanUrl(endpoint, CLAIM_PATH)).post(body).build()
// The code is single-use and wrong attempts are counted: never let the
// HTTP stack silently replay this POST.
val client =
pinned
.newBuilder()
.retryOnConnectionFailure(false)
.callTimeout(CLAIM_TIMEOUT_MS, TimeUnit.MILLISECONDS)
.build()
return try {
client.newCall(request).await().use { response ->
val json = readJson(response)
if (response.code == 200) {
parseClaim(json, endpoint, fingerprint)
} else {
AiturkPairResult.Failure(claimError(response.code, json?.string("error")))
}
}
} catch (_: java.io.IOException) {
AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE)
}
}
private fun parseClaim(
json: JsonObject?,
endpoint: AiturkEndpoint,
fingerprint: String,
): AiturkPairResult<AiturkPairedDevice> {
val token = json?.string("token")
if (token == null || !TOKEN_PATTERN.matches(token)) {
return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE)
}
return AiturkPairResult.Success(
AiturkPairedDevice(
endpoint = endpoint,
fingerprint = fingerprint,
token = token,
deviceId = json.string("device_id").orEmpty(),
pcName = json.string("pc_name").orEmpty().trim().take(MAX_NAME_LENGTH),
version = json.string("version").orEmpty(),
),
)
}
private fun parseOffer(json: JsonObject?): AiturkPairResult<AiturkPairingOffer> {
val fingerprint = json?.string("fp")
val urls = (json?.get("urls") as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
if (fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint) || urls == null) {
return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
}
// The rendezvous record is untrusted input: only private IPv4 targets, 1..4.
val endpoints = urls.map { it?.let(AiturkPairing::parseEndpointUrl) }
if (endpoints.isEmpty() || endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) {
return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
}
return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint))
}
private fun lanUrl(
endpoint: AiturkEndpoint,
path: String,
): HttpUrl =
HttpUrl
.Builder()
.scheme("https")
.host(endpoint.host)
.port(endpoint.port)
.addPathSegments(path)
.build()
private fun readJson(response: Response): JsonObject? =
runCatching {
val buffer = Buffer()
response.body.source().use { source ->
while (buffer.size < MAX_BODY_BYTES && source.read(buffer, MAX_BODY_BYTES - buffer.size) != -1L) {
// keep reading up to the cap
}
}
Json.parseToJsonElement(buffer.readUtf8()) as? JsonObject
}.getOrNull()
private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content
companion object {
const val DEFAULT_GATEWAY = "https://turkservis.online/"
const val HELLO_TIMEOUT_MS = 2_500L
const val MAX_NAME_LENGTH = 60
private const val RESOLVE_PATH = "v1/aiturk/mobile-pair/resolve"
private const val HELLO_PATH = "aiturk/pair/hello"
private const val CLAIM_PATH = "aiturk/pair/claim"
private const val IDE_APP_ID = "aiturk-ide"
private const val HELLO_MARGIN_MS = 500L
private const val CLAIM_TIMEOUT_MS = 15_000L
private const val GATEWAY_TIMEOUT_MS = 15_000L
private const val MAX_BODY_BYTES = 16L * 1024
private val JSON = "application/json; charset=utf-8".toMediaType()
private val TOKEN_PATTERN = Regex("^amd_[A-Za-z0-9_-]{43}$")
// A bare client on purpose: no cookie jar, no user-configured custom
// headers and no Authorization stamping reach the gateway or `claim`.
private val defaultBaseClient: OkHttpClient by lazy {
OkHttpClient
.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.followRedirects(false)
.followSslRedirects(false)
.build()
}
fun sanitizeDeviceName(raw: String): String =
raw
.filterNot { it.isISOControl() }
.trim()
.take(MAX_NAME_LENGTH)
.ifBlank { "Android" }
internal fun claimError(
httpCode: Int,
errorName: String?,
): AiturkPairError =
when (errorName) {
"invalid_request" -> AiturkPairError.INVALID_REQUEST
"wrong_code" -> AiturkPairError.WRONG_CODE
"pairing_closed" -> AiturkPairError.PAIRING_CLOSED
"pairing_locked" -> AiturkPairError.PAIRING_LOCKED
else ->
when (httpCode) {
400, 413 -> AiturkPairError.INVALID_REQUEST
401 -> AiturkPairError.WRONG_CODE
410 -> AiturkPairError.PAIRING_CLOSED
else -> AiturkPairError.UNEXPECTED_RESPONSE
}
}
}
}
@@ -0,0 +1,196 @@
package com.m57.hermescontrol.data.aiturk
import java.net.URI
import java.net.URLDecoder
import java.security.MessageDigest
import java.util.Base64
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=<ip:port>[&u=<ip:port>… max 4]&fp=<43 chars>&c=<8 char code>`
*/
data class AiturkEndpoint(
val host: String,
val port: Int,
) {
/** Key used by the certificate pin store. */
val hostPort: String get() = "$host:$port"
/** Canonical dashboard base URL (always HTTPS, trailing slash). */
val baseUrl: String get() = "https://$host:$port/"
}
/** A pairing offer: where the IDE listens and which certificate it must present. */
data class AiturkPairingOffer(
val endpoints: List<AiturkEndpoint>,
val fingerprint: String,
)
data class AiturkPairingPayload(
val offer: AiturkPairingOffer,
val code: String,
)
enum class AiturkPayloadError {
/** Not an `aiturk://pair` link at all. */
NOT_AITURK,
/** `v` is present but not 1: the app is too old for this QR. */
UNSUPPORTED_VERSION,
/** Missing or malformed field (`v`, `u`, `fp`, `c`). */
MALFORMED,
/** `fp` missing or not a 43-char base64url SHA-256. */
BAD_FINGERPRINT,
/** A `u` entry is not `<private IPv4>:<port>`. */
BAD_ADDRESS,
/** No `u`, or more than [AiturkPairing.MAX_ENDPOINTS]. */
BAD_ADDRESS_COUNT,
/** `c` is not 8 characters of the pairing alphabet. */
BAD_CODE,
}
sealed interface AiturkPayloadResult {
data class Ok(
val payload: AiturkPairingPayload,
) : AiturkPayloadResult
data class Invalid(
val error: AiturkPayloadError,
) : AiturkPayloadResult
}
object AiturkPairing {
const val SCHEME = "aiturk"
const val HOST = "pair"
const val VERSION = "1"
const val MAX_ENDPOINTS = 4
const val CODE_LENGTH = 8
const val CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
const val FINGERPRINT_LENGTH = 43
const val LOOKUP_PREFIX = "aiturk-mobile-pair-v1:"
private val fingerprintPattern = Regex("^[A-Za-z0-9_-]{43}$")
private val endpointPattern = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):(\d{1,5})$""")
/** Quick check used by the deep-link entry point. */
fun looksLikePairingLink(raw: String?): Boolean =
raw != null && raw.trim().startsWith("$SCHEME://$HOST", ignoreCase = true)
fun parse(raw: String): AiturkPayloadResult {
val uri =
runCatching { URI(raw.trim()) }.getOrNull()
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
if (!SCHEME.equals(uri.scheme, ignoreCase = true) || !HOST.equals(uri.host, ignoreCase = true)) {
return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
}
val params =
runCatching { parseQuery(uri.rawQuery.orEmpty()) }.getOrNull()
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
fun single(name: String): String? = params.filter { it.first == name }.map { it.second }.singleOrNull()
// Version first: a newer QR may carry fields this build cannot judge.
val version = single("v") ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
if (version != VERSION) return AiturkPayloadResult.Invalid(AiturkPayloadError.UNSUPPORTED_VERSION)
val fingerprint = single("fp")
if (fingerprint == null || !isValidFingerprint(fingerprint)) {
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
}
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
if (rawEndpoints.isEmpty() || rawEndpoints.size > MAX_ENDPOINTS) {
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
}
val endpoints =
rawEndpoints.map {
parseEndpoint(it) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS)
}
val code =
single("c")?.let(::normalizeCode)
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_CODE)
return AiturkPayloadResult.Ok(
AiturkPairingPayload(
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint),
code = code,
),
)
}
/**
* Upper-cases (locale-independent), strips whitespace and `-`, and returns the
* code only when it is exactly 8 characters of [CODE_ALPHABET]; null otherwise.
*/
fun normalizeCode(raw: String): String? {
val stripped = raw.uppercase(Locale.ROOT).filterNot { it.isWhitespace() || it == '-' }
return stripped.takeIf { it.length == CODE_LENGTH && it.all { c -> c in CODE_ALPHABET } }
}
/** Keeps only alphabet characters (upper-cased), capped at 8 — for the text field. */
fun cleanCodeInput(raw: String): String =
raw
.uppercase(Locale.ROOT)
.filter { it in CODE_ALPHABET }
.take(CODE_LENGTH)
/** `hex(SHA-256("aiturk-mobile-pair-v1:" + CODE))` — the raw code never leaves the phone. */
fun lookup(normalizedCode: String): String {
val digest =
MessageDigest
.getInstance("SHA-256")
.digest((LOOKUP_PREFIX + normalizedCode).toByteArray(Charsets.US_ASCII))
return digest.joinToString("") { "%02x".format(it) }
}
fun isValidFingerprint(value: String): Boolean = fingerprintPattern.matches(value) && decodeFingerprint(value) != null
/** The 32 SHA-256 bytes behind a base64url (unpadded) fingerprint, or null. */
fun decodeFingerprint(value: String): ByteArray? =
runCatching { Base64.getUrlDecoder().decode(value) }
.getOrNull()
?.takeIf { it.size == 32 }
/** `<private IPv4>:<port>`; no DNS names, no leading zeros, no public ranges. */
fun parseEndpoint(raw: String): AiturkEndpoint? {
val match = endpointPattern.matchEntire(raw) ?: return null
val parts = match.groupValues.drop(1)
if (parts.any { it.length > 1 && it.startsWith('0') }) return null
val octets = parts.take(4).map { it.toInt() }
if (octets.any { it > 255 }) return null
val port = parts[4].toInt()
if (port !in 1..65535) return null
val (a, b) = octets
val isPrivate = a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168)
if (!isPrivate) return null
return AiturkEndpoint(octets.joinToString("."), port)
}
/** Gateway `resolve` URLs are `https://<private IPv4>:<port>` (optional trailing slash). */
fun parseEndpointUrl(raw: String): AiturkEndpoint? {
if (!raw.startsWith("https://")) return null
return parseEndpoint(raw.removePrefix("https://").removeSuffix("/"))
}
private fun parseQuery(rawQuery: String): List<Pair<String, String>> =
rawQuery
.split('&')
.filter { it.isNotEmpty() }
.map { part ->
val index = part.indexOf('=')
require(index > 0) { "Query parameter without a value" }
decode(part.substring(0, index)) to decode(part.substring(index + 1))
}
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
}
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.data.remote package com.m57.hermescontrol.data.remote
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
import com.m57.hermescontrol.data.local.AuthManager import com.m57.hermescontrol.data.local.AuthManager
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -19,6 +20,7 @@ internal object DashboardSessionTokenRefresher {
@Synchronized @Synchronized
fun refresh(): String? = fun refresh(): String? =
try { try {
if (isAiturkPaired()) return null
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
AuthManager.setToken(token) AuthManager.setToken(token)
token token
@@ -29,6 +31,7 @@ internal object DashboardSessionTokenRefresher {
suspend fun refreshAsync(): String? = suspend fun refreshAsync(): String? =
refreshMutex.withLock { refreshMutex.withLock {
try { try {
if (isAiturkPaired()) return@withLock null
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
AuthManager.setToken(token) AuthManager.setToken(token)
token token
@@ -37,6 +40,11 @@ internal object DashboardSessionTokenRefresher {
} }
} }
// AITURK: a paired IDE issues a long-lived device token and its LAN proxy
// answers 404 on `/` (the dashboard page must never leave the computer), so
// there is nothing to refresh and the stored token must stay as it is.
private fun isAiturkPaired(): Boolean = AiturkLanTrust.isPairedUrl(AuthManager.baseUrl())
internal suspend fun fetch( internal suspend fun fetch(
baseUrl: String, baseUrl: String,
client: OkHttpClient, client: OkHttpClient,
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy import kotlinx.serialization.json.JsonNamingStrategy
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
import okhttp3.ConnectionPool import okhttp3.ConnectionPool
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -39,6 +40,9 @@ object OkHttpProvider {
.readTimeout(30, TimeUnit.SECONDS) .readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS)
.retryOnConnectionFailure(true) .retryOnConnectionFailure(true)
// AITURK: a paired IDE's host:port is verified by certificate
// fingerprint; every other address keeps the platform TLS checks.
.let(AiturkLanTrust::applyTo)
.build() .build()
} }
@@ -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()
}
}
}
+5
View File
@@ -31,6 +31,7 @@ room = "3.0.2"
sqlcipher = "4.19.0" sqlcipher = "4.19.0"
datastore = "1.1.2" datastore = "1.1.2"
latex = "1.5.6" latex = "1.5.6"
zxingAndroidEmbedded = "4.3.0"
[libraries] [libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
@@ -69,6 +70,7 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" } okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" } okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" } okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" } retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
retrofit-converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" } retrofit-converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.11.0" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.11.0" }
@@ -87,6 +89,9 @@ latex-base = { module = "io.github.huarangmeng:latex-base", version.ref = "latex
latex-parser = { module = "io.github.huarangmeng:latex-parser", version.ref = "latex" } latex-parser = { module = "io.github.huarangmeng:latex-parser", version.ref = "latex" }
latex-renderer = { module = "io.github.huarangmeng:latex-renderer", version.ref = "latex" } latex-renderer = { module = "io.github.huarangmeng:latex-renderer", version.ref = "latex" }
# QR scanning for AITURK IDE pairing (no Play Services)
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
# Image loading (Coil 3) # Image loading (Coil 3)
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }