diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0cd718a3..5a1b6ce3 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -201,6 +201,9 @@ dependencies { implementation(libs.media3.ui) implementation(libs.media3.datasource.okhttp) + // AITURK IDE pairing: QR scanner (ZXing, no Play Services) + implementation(libs.zxing.android.embedded) + // Encrypted storage implementation(libs.androidx.security.crypto) implementation(libs.androidx.datastore) @@ -218,6 +221,7 @@ dependencies { testRuntimeOnly(libs.junit.vintage.engine) testRuntimeOnly(libs.junit.platform.launcher) testImplementation(libs.okhttp.mockwebserver) + testImplementation(libs.okhttp.tls) testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockk) testImplementation(libs.turbine) diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 10d382b8..6ed82fa1 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -57,3 +57,8 @@ # Keep screen object class names because their simpleNames are used for bottom nav selection and storage. -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); +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkConnectionRegistrar.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkConnectionRegistrar.kt new file mode 100644 index 00000000..256ea565 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkConnectionRegistrar.kt @@ -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) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrust.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrust.kt new file mode 100644 index 00000000..18c58a48 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrust.kt @@ -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 + + fun save(pins: Map) + } + + private const val PREF_KEY = "aiturk_lan_pins" + + private val pins = ConcurrentHashMap() + + @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 { + 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().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 { + 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) { + 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, +) : X509ExtendedTrustManager() { + override fun checkServerTrusted( + chain: Array, + 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, + 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, + 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, + authType: String, + host: String, + ): List { + 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::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 ?: chain.toList() + } catch (e: java.lang.reflect.InvocationTargetException) { + throw e.targetException as? CertificateException ?: CertificateException(e.targetException) + } + } + + override fun checkClientTrusted( + chain: Array, + authType: String, + ) = delegate.checkClientTrusted(chain, authType) + + override fun checkClientTrusted( + chain: Array, + authType: String, + socket: Socket?, + ) = delegate.checkClientTrusted(chain, authType) + + override fun checkClientTrusted( + chain: Array, + authType: String, + engine: SSLEngine?, + ) = delegate.checkClientTrusted(chain, authType) + + override fun getAcceptedIssuers(): Array = delegate.acceptedIssuers + + private fun requirePinned( + chain: Array, + 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, + authType: String, + ): Unit = throw CertificateException("Not trusted") + + override fun checkServerTrusted( + chain: Array, + authType: String, + ): Unit = throw CertificateException("Not trusted") + + override fun getAcceptedIssuers(): Array = emptyArray() +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClient.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClient.kt new file mode 100644 index 00000000..08beb38a --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClient.kt @@ -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 { + data class Success( + val value: T, + ) : AiturkPairResult + + data class Failure( + val error: AiturkPairError, + ) : AiturkPairResult +} + +/** + * 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 = + 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 = + 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, + ): 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 { + 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 { + 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 { + 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 + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayload.kt b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayload.kt new file mode 100644 index 00000000..ea0fc736 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayload.kt @@ -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=[&u=… 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, + 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 `:`. */ + 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 } + + /** `:`; 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://:` (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> = + 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") +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt index d27b9e03..9647a958 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/DashboardSessionTokenRefresher.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.data.remote +import com.m57.hermescontrol.data.aiturk.AiturkLanTrust import com.m57.hermescontrol.data.local.AuthManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.sync.Mutex @@ -19,6 +20,7 @@ internal object DashboardSessionTokenRefresher { @Synchronized fun refresh(): String? = try { + if (isAiturkPaired()) return null val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null AuthManager.setToken(token) token @@ -29,6 +31,7 @@ internal object DashboardSessionTokenRefresher { suspend fun refreshAsync(): String? = refreshMutex.withLock { try { + if (isAiturkPaired()) return@withLock null val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null AuthManager.setToken(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( baseUrl: String, client: OkHttpClient, diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt index 99a004eb..d8753b99 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/OkHttpProvider.kt @@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy +import com.m57.hermescontrol.data.aiturk.AiturkLanTrust import okhttp3.ConnectionPool import okhttp3.OkHttpClient import java.util.concurrent.TimeUnit @@ -39,6 +40,9 @@ object OkHttpProvider { .readTimeout(30, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .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() } diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrustTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrustTest.kt new file mode 100644 index 00000000..8e426432 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkLanTrustTest.kt @@ -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() + + @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() + 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 = error("storage not ready") + + override fun save(pins: Map) = 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, + authType: String, + ) = Unit + + override fun checkServerTrusted( + chain: Array, + authType: String, + ) { + serverChecks++ + if (reject) throw CertificateException("untrusted") + } + + override fun getAcceptedIssuers(): Array = emptyArray() + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkNoSecretLoggingTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkNoSecretLoggingTest.kt new file mode 100644 index 00000000..c9fd9c0b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkNoSecretLoggingTest.kt @@ -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") + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClientTest.kt new file mode 100644 index 00000000..f5b66838 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingClientTest.kt @@ -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() + + @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 AiturkPairResult.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")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayloadTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayloadTest.kt new file mode 100644 index 00000000..6941e539 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkPairingPayloadTest.kt @@ -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 = 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" + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTestTls.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTestTls.kt new file mode 100644 index 00000000..20b76f35 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTestTls.kt @@ -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() + val claimBodies = CopyOnWriteArrayList() + + @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 = mutableMapOf(), +) : AiturkLanTrust.PinPersistence { + override fun load(): Map = stored.toMap() + + override fun save(pins: Map) { + stored.clear() + stored.putAll(pins) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTokenRefreshSkipTest.kt b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTokenRefreshSkipTest.kt new file mode 100644 index 00000000..c593f1d5 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/aiturk/AiturkTokenRefreshSkipTest.kt @@ -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 /` 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() + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 190d0cc2..ebcef687 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ room = "3.0.2" sqlcipher = "4.19.0" datastore = "1.1.2" latex = "1.5.6" +zxingAndroidEmbedded = "4.3.0" [libraries] 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-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", 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-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" } @@ -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-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) coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }