feat(aiturk): pair with AITURK IDE over LAN with a pinned certificate
Adds the phone side of the pairing contract v1: payload parsing, gateway resolve, parallel hello and a single claim, per host:port certificate pinning hooked into OkHttpProvider.base, and no dashboard token refresh for a paired IDE. Adds zxing-android-embedded and okhttp-tls (tests). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
1 parent
8e48a0fa1e
commit
c2b08ff73e
15 files changed
+1849
No files matched your search
@@ -0,0 +1,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()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user