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

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

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

No files matched your search

@@ -0,0 +1,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
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,
@@ -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()
}