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
@@ -201,6 +201,9 @@ dependencies {
|
|||||||
implementation(libs.media3.ui)
|
implementation(libs.media3.ui)
|
||||||
implementation(libs.media3.datasource.okhttp)
|
implementation(libs.media3.datasource.okhttp)
|
||||||
|
|
||||||
|
// AITURK IDE pairing: QR scanner (ZXing, no Play Services)
|
||||||
|
implementation(libs.zxing.android.embedded)
|
||||||
|
|
||||||
// Encrypted storage
|
// Encrypted storage
|
||||||
implementation(libs.androidx.security.crypto)
|
implementation(libs.androidx.security.crypto)
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
@@ -218,6 +221,7 @@ dependencies {
|
|||||||
testRuntimeOnly(libs.junit.vintage.engine)
|
testRuntimeOnly(libs.junit.vintage.engine)
|
||||||
testRuntimeOnly(libs.junit.platform.launcher)
|
testRuntimeOnly(libs.junit.platform.launcher)
|
||||||
testImplementation(libs.okhttp.mockwebserver)
|
testImplementation(libs.okhttp.mockwebserver)
|
||||||
|
testImplementation(libs.okhttp.tls)
|
||||||
testImplementation(libs.kotlinx.coroutines.test)
|
testImplementation(libs.kotlinx.coroutines.test)
|
||||||
testImplementation(libs.mockk)
|
testImplementation(libs.mockk)
|
||||||
testImplementation(libs.turbine)
|
testImplementation(libs.turbine)
|
||||||
|
|||||||
Vendored
+5
@@ -57,3 +57,8 @@
|
|||||||
# Keep screen object class names because their simpleNames are used for bottom nav selection and storage.
|
# Keep screen object class names because their simpleNames are used for bottom nav selection and storage.
|
||||||
-keep class * extends androidx.navigation3.runtime.NavKey { *; }
|
-keep class * extends androidx.navigation3.runtime.NavKey { *; }
|
||||||
|
|
||||||
|
# ── AITURK IDE pairing ───────────────────────────────────────────────────
|
||||||
|
# Android looks this hostname-aware overload up by reflection (X509TrustManagerExtensions).
|
||||||
|
-keepclassmembers class com.m57.hermescontrol.data.aiturk.PinAwareTrustManager {
|
||||||
|
public java.util.List checkServerTrusted(java.security.cert.X509Certificate[], java.lang.String, java.lang.String);
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.session.ProfileSwitchCoordinator
|
||||||
|
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stores a freshly claimed AITURK IDE pairing in the app's existing connection
|
||||||
|
* model: a [ConnectionProfile] named after the computer, token auth, WS `?token=`.
|
||||||
|
*/
|
||||||
|
object AiturkConnectionRegistrar {
|
||||||
|
const val FALLBACK_PC_NAME = "AITURK IDE"
|
||||||
|
|
||||||
|
suspend fun register(device: AiturkPairedDevice) {
|
||||||
|
val baseUrl = device.endpoint.baseUrl
|
||||||
|
val name = device.pcName.ifBlank { FALLBACK_PC_NAME }
|
||||||
|
|
||||||
|
// Trust first: every later request to this host:port is checked against the pin.
|
||||||
|
AiturkLanTrust.pin(device.endpoint.hostPort, device.fingerprint)
|
||||||
|
|
||||||
|
withContext(Dispatchers.IO) { HermesWsClient.disconnect() }
|
||||||
|
|
||||||
|
// Re-pairing the same computer reuses its profile instead of piling up duplicates.
|
||||||
|
val profiles = AuthManager.getConnectionProfiles()
|
||||||
|
val existing = profiles.firstOrNull { it.baseUrl == baseUrl }
|
||||||
|
val profile =
|
||||||
|
existing?.copy(name = name)
|
||||||
|
?: ConnectionProfile(name = name, host = "", port = 0, baseUrl = baseUrl)
|
||||||
|
AuthManager.saveConnectionProfiles(
|
||||||
|
if (existing != null) {
|
||||||
|
profiles.map { if (it.id == profile.id) profile else it }
|
||||||
|
} else {
|
||||||
|
profiles + profile
|
||||||
|
},
|
||||||
|
)
|
||||||
|
AuthManager.setSelectedProfileId(profile.id)
|
||||||
|
AuthManager.syncCookieStoreForProfile(profile.id)
|
||||||
|
|
||||||
|
// Same order as the manual token login (AuthLoginViewModel.connect).
|
||||||
|
AuthManager.setBaseUrl(baseUrl)
|
||||||
|
AuthManager.setToken(device.token)
|
||||||
|
AuthManager.setSessionCookie(null)
|
||||||
|
AuthManager.setWsAuthParam("token")
|
||||||
|
// Rebuilds Retrofit, tells chat the server changed, then re-dials the socket.
|
||||||
|
ProfileSwitchCoordinator.switchConnectionProfile(profile.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,345 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import java.net.Socket
|
||||||
|
import java.security.KeyStore
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.SSLContext
|
||||||
|
import javax.net.ssl.SSLEngine
|
||||||
|
import javax.net.ssl.SSLSession
|
||||||
|
import javax.net.ssl.SSLSocket
|
||||||
|
import javax.net.ssl.SSLSocketFactory
|
||||||
|
import javax.net.ssl.TrustManagerFactory
|
||||||
|
import javax.net.ssl.X509ExtendedTrustManager
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TLS trust for AITURK IDE LAN pairing.
|
||||||
|
*
|
||||||
|
* The IDE's LAN proxy presents a self-signed certificate. Its DER SHA-256
|
||||||
|
* fingerprint travels in the QR code (or the gateway rendezvous record) and is
|
||||||
|
* stored here per `host:port`. For a paired `host:port` the certificate is
|
||||||
|
* accepted ONLY when its fingerprint matches (system CAs are not consulted);
|
||||||
|
* for every other address the platform's default trust manager and OkHttp's
|
||||||
|
* default hostname verifier decide exactly as before.
|
||||||
|
*/
|
||||||
|
object AiturkLanTrust {
|
||||||
|
/** Where pins are persisted. Swappable so JVM tests need no Android keystore. */
|
||||||
|
interface PinPersistence {
|
||||||
|
fun load(): Map<String, String>
|
||||||
|
|
||||||
|
fun save(pins: Map<String, String>)
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val PREF_KEY = "aiturk_lan_pins"
|
||||||
|
|
||||||
|
private val pins = ConcurrentHashMap<String, String>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var loaded = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
internal var persistence: PinPersistence = EncryptedPrefsPersistence
|
||||||
|
|
||||||
|
private val platformTrustManager: X509TrustManager by lazy { defaultTrustManager() }
|
||||||
|
private val platformHostnameVerifier: HostnameVerifier by lazy { OkHttpClient.Builder().build().hostnameVerifier }
|
||||||
|
|
||||||
|
val trustManager: X509TrustManager by lazy { PinAwareTrustManager(platformTrustManager, ::pinFor, ::pinsForHost) }
|
||||||
|
val sslSocketFactory: SSLSocketFactory by lazy { socketFactoryFor(trustManager) }
|
||||||
|
val hostnameVerifier: HostnameVerifier by lazy { PinAwareHostnameVerifier(platformHostnameVerifier, ::pinFor) }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single hook for [com.m57.hermescontrol.data.remote.OkHttpProvider.base]:
|
||||||
|
* every REST / WebSocket / media / image client derives from that builder.
|
||||||
|
* If the TLS objects cannot be built the builder is left untouched (platform
|
||||||
|
* defaults), which only means a paired IDE cannot be reached.
|
||||||
|
*/
|
||||||
|
fun applyTo(builder: OkHttpClient.Builder): OkHttpClient.Builder =
|
||||||
|
runCatching {
|
||||||
|
builder
|
||||||
|
.sslSocketFactory(sslSocketFactory, trustManager)
|
||||||
|
.hostnameVerifier(hostnameVerifier)
|
||||||
|
}.getOrDefault(builder)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A client that talks ONLY to a server presenting [fingerprint] — used for
|
||||||
|
* `hello` / `claim` before the pin is stored. Nothing else is trusted on it.
|
||||||
|
*/
|
||||||
|
fun pinnedClient(
|
||||||
|
base: OkHttpClient,
|
||||||
|
fingerprint: String,
|
||||||
|
): OkHttpClient {
|
||||||
|
val lookup: (String) -> String? = { fingerprint }
|
||||||
|
val manager = PinAwareTrustManager(RejectAllTrustManager, lookup) { listOf(fingerprint) }
|
||||||
|
return base
|
||||||
|
.newBuilder()
|
||||||
|
.sslSocketFactory(socketFactoryFor(manager), manager)
|
||||||
|
.hostnameVerifier(PinAwareHostnameVerifier({ _, _ -> false }, lookup))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pin(
|
||||||
|
hostPort: String,
|
||||||
|
fingerprint: String,
|
||||||
|
) {
|
||||||
|
require(AiturkPairing.isValidFingerprint(fingerprint)) { "Invalid certificate fingerprint" }
|
||||||
|
ensureLoaded()
|
||||||
|
// Saving on top of an unread store would drop the pins already in it.
|
||||||
|
check(loaded) { "Secure storage is not ready" }
|
||||||
|
pins[hostPort] = fingerprint
|
||||||
|
persistence.save(pins.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unpin(hostPort: String) {
|
||||||
|
ensureLoaded()
|
||||||
|
if (pins.remove(hostPort) != null) persistence.save(pins.toMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun pinFor(hostPort: String): String? {
|
||||||
|
ensureLoaded()
|
||||||
|
return pins[hostPort]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pins of every paired port on [host] (the Android chain-cleaner path knows no port). */
|
||||||
|
internal fun pinsForHost(host: String): Collection<String> {
|
||||||
|
ensureLoaded()
|
||||||
|
return pins.filterKeys { it.substringBeforeLast(':') == host }.values
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True when [baseUrl] is an HTTPS address of a paired AITURK IDE. */
|
||||||
|
fun isPairedUrl(baseUrl: String?): Boolean {
|
||||||
|
val url = baseUrl?.toHttpUrlOrNull() ?: return false
|
||||||
|
return url.isHttps && pinFor("${url.host}:${url.port}") != null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun resetForTest(newPersistence: PinPersistence) {
|
||||||
|
persistence = newPersistence
|
||||||
|
pins.clear()
|
||||||
|
loaded = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ensureLoaded() {
|
||||||
|
if (loaded) return
|
||||||
|
synchronized(this) {
|
||||||
|
if (loaded) return
|
||||||
|
// Storage not ready yet (or broken): behave as "nothing paired" for
|
||||||
|
// now and look again on the next call. load() never blocks.
|
||||||
|
val stored = runCatching { persistence.load() }.getOrNull() ?: return
|
||||||
|
stored.forEach { (key, value) -> pins.putIfAbsent(key, value) }
|
||||||
|
loaded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** DER SHA-256 of [certificate] compared in constant time with a base64url pin. */
|
||||||
|
internal fun matches(
|
||||||
|
certificate: X509Certificate,
|
||||||
|
fingerprint: String,
|
||||||
|
): Boolean {
|
||||||
|
val expected = AiturkPairing.decodeFingerprint(fingerprint) ?: return false
|
||||||
|
val actual = MessageDigest.getInstance("SHA-256").digest(certificate.encoded)
|
||||||
|
return MessageDigest.isEqual(expected, actual)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun socketFactoryFor(manager: X509TrustManager): SSLSocketFactory =
|
||||||
|
SSLContext
|
||||||
|
.getInstance("TLS")
|
||||||
|
.apply { init(null, arrayOf(manager), null) }
|
||||||
|
.socketFactory
|
||||||
|
|
||||||
|
private fun defaultTrustManager(): X509TrustManager {
|
||||||
|
val factory = TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm())
|
||||||
|
factory.init(null as KeyStore?)
|
||||||
|
return factory.trustManagers.filterIsInstance<X509TrustManager>().first()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Pins live in the app's encrypted preferences next to the connection tokens. */
|
||||||
|
private object EncryptedPrefsPersistence : PinPersistence {
|
||||||
|
private fun prefs(): android.content.SharedPreferences {
|
||||||
|
// Never wait here: this runs inside TLS handshakes. A connection to a
|
||||||
|
// paired IDE cannot start before AuthManager is ready anyway, because
|
||||||
|
// its address and token come from the same store.
|
||||||
|
check(AuthManager.initializationState.value == AuthManager.InitializationState.Ready) {
|
||||||
|
"Secure storage is not ready"
|
||||||
|
}
|
||||||
|
return runBlocking { AuthManager.awaitPrefs() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun load(): Map<String, String> {
|
||||||
|
val prefs = prefs()
|
||||||
|
val raw = prefs.getString(PREF_KEY, null) ?: return emptyMap()
|
||||||
|
return runCatching {
|
||||||
|
Json.parseToJsonElement(raw).jsonObject.mapValues { it.value.jsonPrimitive.content }
|
||||||
|
}.getOrDefault(emptyMap())
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun save(pins: Map<String, String>) {
|
||||||
|
val prefs = prefs()
|
||||||
|
val encoded = JsonObject(pins.mapValues { JsonPrimitive(it.value) }).toString()
|
||||||
|
prefs.edit().putString(PREF_KEY, encoded).apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the leaf certificate against the pin of the peer `host:port` when one
|
||||||
|
* exists; otherwise defers to [delegate] untouched.
|
||||||
|
*/
|
||||||
|
internal class PinAwareTrustManager(
|
||||||
|
private val delegate: X509TrustManager,
|
||||||
|
private val pinFor: (String) -> String?,
|
||||||
|
private val pinsForHost: (String) -> Collection<String>,
|
||||||
|
) : X509ExtendedTrustManager() {
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
socket: Socket?,
|
||||||
|
) {
|
||||||
|
val pin = peerOf(socket)?.let(pinFor)
|
||||||
|
when {
|
||||||
|
pin != null -> requirePinned(chain, pin)
|
||||||
|
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, socket)
|
||||||
|
else -> delegate.checkServerTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
engine: SSLEngine?,
|
||||||
|
) {
|
||||||
|
val pin = engine?.let { peerKey(it.peerHost, it.peerPort) }?.let(pinFor)
|
||||||
|
when {
|
||||||
|
pin != null -> requirePinned(chain, pin)
|
||||||
|
delegate is X509ExtendedTrustManager -> delegate.checkServerTrusted(chain, authType, engine)
|
||||||
|
else -> delegate.checkServerTrusted(chain, authType)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** No peer information: never apply a pin, platform validation only. */
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) = delegate.checkServerTrusted(chain, authType)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Android's hostname-aware entry point (`X509TrustManagerExtensions`, used by
|
||||||
|
* OkHttp's certificate chain cleaner). Looked up reflectively by the platform.
|
||||||
|
* The port is unknown here, so any pin stored for [host] may match. This is
|
||||||
|
* not the handshake's trust decision (that is the socket/engine overload).
|
||||||
|
*/
|
||||||
|
@Suppress("unused")
|
||||||
|
fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
host: String,
|
||||||
|
): List<X509Certificate> {
|
||||||
|
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
|
||||||
|
if (pinsForHost(host).any { AiturkLanTrust.matches(leaf, it) }) return listOf(leaf)
|
||||||
|
val hostAware =
|
||||||
|
runCatching {
|
||||||
|
delegate.javaClass.getMethod(
|
||||||
|
"checkServerTrusted",
|
||||||
|
Array<X509Certificate>::class.java,
|
||||||
|
String::class.java,
|
||||||
|
String::class.java,
|
||||||
|
)
|
||||||
|
}.getOrNull()
|
||||||
|
if (hostAware == null) {
|
||||||
|
delegate.checkServerTrusted(chain, authType)
|
||||||
|
return chain.toList()
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
return hostAware.invoke(delegate, chain, authType, host) as? List<X509Certificate> ?: chain.toList()
|
||||||
|
} catch (e: java.lang.reflect.InvocationTargetException) {
|
||||||
|
throw e.targetException as? CertificateException ?: CertificateException(e.targetException)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) = delegate.checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
socket: Socket?,
|
||||||
|
) = delegate.checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
engine: SSLEngine?,
|
||||||
|
) = delegate.checkClientTrusted(chain, authType)
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = delegate.acceptedIssuers
|
||||||
|
|
||||||
|
private fun requirePinned(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
pin: String,
|
||||||
|
) {
|
||||||
|
val leaf = chain.firstOrNull() ?: throw CertificateException("Empty certificate chain")
|
||||||
|
if (!AiturkLanTrust.matches(leaf, pin)) {
|
||||||
|
throw CertificateException("AITURK IDE certificate fingerprint mismatch")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun peerOf(socket: Socket?): String? {
|
||||||
|
if (socket == null) return null
|
||||||
|
// Prefer the identity the TLS layer was asked to reach (the URL's host
|
||||||
|
// and port); fall back to the connected address.
|
||||||
|
val session = (socket as? SSLSocket)?.let { runCatching { it.handshakeSession }.getOrNull() }
|
||||||
|
return session?.let { peerKey(it.peerHost, it.peerPort) }
|
||||||
|
?: peerKey(socket.inetAddress?.hostAddress, socket.port)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun peerKey(
|
||||||
|
host: String?,
|
||||||
|
port: Int,
|
||||||
|
): String? = if (host.isNullOrBlank() || port <= 0) null else "$host:$port"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hostname check: fingerprint for a paired `host:port`, [delegate] for everything else. */
|
||||||
|
internal class PinAwareHostnameVerifier(
|
||||||
|
private val delegate: HostnameVerifier,
|
||||||
|
private val pinFor: (String) -> String?,
|
||||||
|
) : HostnameVerifier {
|
||||||
|
override fun verify(
|
||||||
|
hostname: String,
|
||||||
|
session: SSLSession,
|
||||||
|
): Boolean {
|
||||||
|
val pin = pinFor("$hostname:${session.peerPort}") ?: return delegate.verify(hostname, session)
|
||||||
|
val leaf =
|
||||||
|
runCatching { session.peerCertificates.firstOrNull() as? X509Certificate }.getOrNull()
|
||||||
|
?: return false
|
||||||
|
return AiturkLanTrust.matches(leaf, pin)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Used by [AiturkLanTrust.pinnedClient]: anything that is not the pinned certificate is refused. */
|
||||||
|
private object RejectAllTrustManager : X509TrustManager {
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
): Unit = throw CertificateException("Not trusted")
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
): Unit = throw CertificateException("Not trusted")
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.remote.await
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.channelFlow
|
||||||
|
import kotlinx.coroutines.flow.firstOrNull
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import kotlinx.coroutines.withTimeoutOrNull
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import okhttp3.HttpUrl
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
|
import okhttp3.Response
|
||||||
|
import okio.Buffer
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/** Error names follow the pairing contract (`wrong_code`, `pairing_closed`, …). */
|
||||||
|
enum class AiturkPairError {
|
||||||
|
/** Gateway `resolve` 404: unknown, expired or exhausted code. */
|
||||||
|
CODE_NOT_FOUND,
|
||||||
|
|
||||||
|
/** Gateway `resolve` 429. */
|
||||||
|
RATE_LIMITED,
|
||||||
|
|
||||||
|
/** Gateway unreachable or answered something unexpected. */
|
||||||
|
GATEWAY_UNAVAILABLE,
|
||||||
|
|
||||||
|
/** No candidate address answered `hello` with the pinned certificate in time. */
|
||||||
|
PC_UNREACHABLE,
|
||||||
|
|
||||||
|
/** `claim` 400 `invalid_request` (or 413). */
|
||||||
|
INVALID_REQUEST,
|
||||||
|
|
||||||
|
/** `claim` 401 `wrong_code`. */
|
||||||
|
WRONG_CODE,
|
||||||
|
|
||||||
|
/** `claim` 410 `pairing_closed` (not open, expired or code already used). */
|
||||||
|
PAIRING_CLOSED,
|
||||||
|
|
||||||
|
/** `claim` 410 `pairing_locked` (5 wrong attempts). */
|
||||||
|
PAIRING_LOCKED,
|
||||||
|
|
||||||
|
/** The computer answered, but not with what the contract promises. */
|
||||||
|
UNEXPECTED_RESPONSE,
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AiturkPairedDevice(
|
||||||
|
val endpoint: AiturkEndpoint,
|
||||||
|
val fingerprint: String,
|
||||||
|
val token: String,
|
||||||
|
val deviceId: String,
|
||||||
|
val pcName: String,
|
||||||
|
val version: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
sealed interface AiturkPairResult<out T> {
|
||||||
|
data class Success<T>(
|
||||||
|
val value: T,
|
||||||
|
) : AiturkPairResult<T>
|
||||||
|
|
||||||
|
data class Failure(
|
||||||
|
val error: AiturkPairError,
|
||||||
|
) : AiturkPairResult<Nothing>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phone side of the AITURK IDE pairing handshake.
|
||||||
|
*
|
||||||
|
* Deliberately does not log: codes, tokens and bodies must never reach logcat.
|
||||||
|
*/
|
||||||
|
class AiturkPairingClient(
|
||||||
|
private val gatewayBaseUrl: HttpUrl = DEFAULT_GATEWAY.toHttpUrl(),
|
||||||
|
private val baseClient: OkHttpClient = defaultBaseClient,
|
||||||
|
private val helloTimeoutMs: Long = HELLO_TIMEOUT_MS,
|
||||||
|
) {
|
||||||
|
/** Code path: `resolve` the rendezvous record for a normalized code. */
|
||||||
|
suspend fun resolve(normalizedCode: String): AiturkPairResult<AiturkPairingOffer> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val body =
|
||||||
|
buildJsonObject { put("lookup", AiturkPairing.lookup(normalizedCode)) }
|
||||||
|
.toString()
|
||||||
|
.toRequestBody(JSON)
|
||||||
|
val request =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
|
.url(gatewayBaseUrl.newBuilder().addPathSegments(RESOLVE_PATH).build())
|
||||||
|
.post(body)
|
||||||
|
.build()
|
||||||
|
val client =
|
||||||
|
baseClient
|
||||||
|
.newBuilder()
|
||||||
|
.callTimeout(GATEWAY_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
try {
|
||||||
|
client.newCall(request).await().use { response ->
|
||||||
|
when (response.code) {
|
||||||
|
200 -> parseOffer(readJson(response))
|
||||||
|
404 -> AiturkPairResult.Failure(AiturkPairError.CODE_NOT_FOUND)
|
||||||
|
429 -> AiturkPairResult.Failure(AiturkPairError.RATE_LIMITED)
|
||||||
|
else -> AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: java.io.IOException) {
|
||||||
|
AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `hello` to every candidate in parallel (pinned to the offer's fingerprint),
|
||||||
|
* then exactly ONE `claim` to the first that answered.
|
||||||
|
*/
|
||||||
|
suspend fun pair(
|
||||||
|
offer: AiturkPairingOffer,
|
||||||
|
normalizedCode: String,
|
||||||
|
deviceName: String,
|
||||||
|
): AiturkPairResult<AiturkPairedDevice> =
|
||||||
|
withContext(Dispatchers.IO) {
|
||||||
|
val pinned = AiturkLanTrust.pinnedClient(baseClient, offer.fingerprint)
|
||||||
|
val reachable =
|
||||||
|
firstReachable(pinned, offer.endpoints)
|
||||||
|
?: return@withContext AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE)
|
||||||
|
if (!reachable.pairingOpen) {
|
||||||
|
// The computer says pairing is not open: a claim could only be
|
||||||
|
// answered with 410 pairing_closed, so do not spend an attempt.
|
||||||
|
return@withContext AiturkPairResult.Failure(AiturkPairError.PAIRING_CLOSED)
|
||||||
|
}
|
||||||
|
claim(pinned, reachable.endpoint, offer.fingerprint, normalizedCode, deviceName)
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class Reachable(
|
||||||
|
val endpoint: AiturkEndpoint,
|
||||||
|
val pairingOpen: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
|
private suspend fun firstReachable(
|
||||||
|
pinned: OkHttpClient,
|
||||||
|
endpoints: List<AiturkEndpoint>,
|
||||||
|
): Reachable? {
|
||||||
|
val helloClient =
|
||||||
|
pinned
|
||||||
|
.newBuilder()
|
||||||
|
.connectTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.readTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.callTimeout(helloTimeoutMs, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
// Small margin over the per-call timeout so a slow candidate cannot hold the result.
|
||||||
|
return withTimeoutOrNull(helloTimeoutMs + HELLO_MARGIN_MS) {
|
||||||
|
channelFlow {
|
||||||
|
endpoints.forEach { endpoint ->
|
||||||
|
launch { hello(helloClient, endpoint)?.let { send(it) } }
|
||||||
|
}
|
||||||
|
}.firstOrNull()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun hello(
|
||||||
|
client: OkHttpClient,
|
||||||
|
endpoint: AiturkEndpoint,
|
||||||
|
): Reachable? =
|
||||||
|
try {
|
||||||
|
val request = Request.Builder().url(lanUrl(endpoint, HELLO_PATH)).get().build()
|
||||||
|
client.newCall(request).await().use { response ->
|
||||||
|
if (response.code != 200) return null
|
||||||
|
val json = readJson(response) ?: return null
|
||||||
|
if (json.string("app") != IDE_APP_ID) return null
|
||||||
|
Reachable(endpoint, (json["pairing"] as? JsonPrimitive)?.booleanOrNull ?: false)
|
||||||
|
}
|
||||||
|
} catch (_: java.io.IOException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun claim(
|
||||||
|
pinned: OkHttpClient,
|
||||||
|
endpoint: AiturkEndpoint,
|
||||||
|
fingerprint: String,
|
||||||
|
normalizedCode: String,
|
||||||
|
deviceName: String,
|
||||||
|
): AiturkPairResult<AiturkPairedDevice> {
|
||||||
|
val body =
|
||||||
|
buildJsonObject {
|
||||||
|
put("code", normalizedCode)
|
||||||
|
put("device_name", sanitizeDeviceName(deviceName))
|
||||||
|
}.toString().toRequestBody(JSON)
|
||||||
|
val request = Request.Builder().url(lanUrl(endpoint, CLAIM_PATH)).post(body).build()
|
||||||
|
// The code is single-use and wrong attempts are counted: never let the
|
||||||
|
// HTTP stack silently replay this POST.
|
||||||
|
val client =
|
||||||
|
pinned
|
||||||
|
.newBuilder()
|
||||||
|
.retryOnConnectionFailure(false)
|
||||||
|
.callTimeout(CLAIM_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||||
|
.build()
|
||||||
|
return try {
|
||||||
|
client.newCall(request).await().use { response ->
|
||||||
|
val json = readJson(response)
|
||||||
|
if (response.code == 200) {
|
||||||
|
parseClaim(json, endpoint, fingerprint)
|
||||||
|
} else {
|
||||||
|
AiturkPairResult.Failure(claimError(response.code, json?.string("error")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_: java.io.IOException) {
|
||||||
|
AiturkPairResult.Failure(AiturkPairError.PC_UNREACHABLE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseClaim(
|
||||||
|
json: JsonObject?,
|
||||||
|
endpoint: AiturkEndpoint,
|
||||||
|
fingerprint: String,
|
||||||
|
): AiturkPairResult<AiturkPairedDevice> {
|
||||||
|
val token = json?.string("token")
|
||||||
|
if (token == null || !TOKEN_PATTERN.matches(token)) {
|
||||||
|
return AiturkPairResult.Failure(AiturkPairError.UNEXPECTED_RESPONSE)
|
||||||
|
}
|
||||||
|
return AiturkPairResult.Success(
|
||||||
|
AiturkPairedDevice(
|
||||||
|
endpoint = endpoint,
|
||||||
|
fingerprint = fingerprint,
|
||||||
|
token = token,
|
||||||
|
deviceId = json.string("device_id").orEmpty(),
|
||||||
|
pcName = json.string("pc_name").orEmpty().trim().take(MAX_NAME_LENGTH),
|
||||||
|
version = json.string("version").orEmpty(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseOffer(json: JsonObject?): AiturkPairResult<AiturkPairingOffer> {
|
||||||
|
val fingerprint = json?.string("fp")
|
||||||
|
val urls = (json?.get("urls") as? JsonArray)?.map { (it as? JsonPrimitive)?.takeIf { p -> p.isString }?.content }
|
||||||
|
if (fingerprint == null || !AiturkPairing.isValidFingerprint(fingerprint) || urls == null) {
|
||||||
|
return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
// The rendezvous record is untrusted input: only private IPv4 targets, 1..4.
|
||||||
|
val endpoints = urls.map { it?.let(AiturkPairing::parseEndpointUrl) }
|
||||||
|
if (endpoints.isEmpty() || endpoints.size > AiturkPairing.MAX_ENDPOINTS || endpoints.any { it == null }) {
|
||||||
|
return AiturkPairResult.Failure(AiturkPairError.GATEWAY_UNAVAILABLE)
|
||||||
|
}
|
||||||
|
return AiturkPairResult.Success(AiturkPairingOffer(endpoints.filterNotNull().distinct(), fingerprint))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun lanUrl(
|
||||||
|
endpoint: AiturkEndpoint,
|
||||||
|
path: String,
|
||||||
|
): HttpUrl =
|
||||||
|
HttpUrl
|
||||||
|
.Builder()
|
||||||
|
.scheme("https")
|
||||||
|
.host(endpoint.host)
|
||||||
|
.port(endpoint.port)
|
||||||
|
.addPathSegments(path)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun readJson(response: Response): JsonObject? =
|
||||||
|
runCatching {
|
||||||
|
val buffer = Buffer()
|
||||||
|
response.body.source().use { source ->
|
||||||
|
while (buffer.size < MAX_BODY_BYTES && source.read(buffer, MAX_BODY_BYTES - buffer.size) != -1L) {
|
||||||
|
// keep reading up to the cap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Json.parseToJsonElement(buffer.readUtf8()) as? JsonObject
|
||||||
|
}.getOrNull()
|
||||||
|
|
||||||
|
private fun JsonObject.string(name: String): String? = (this[name] as? JsonPrimitive)?.takeIf { it.isString }?.content
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DEFAULT_GATEWAY = "https://turkservis.online/"
|
||||||
|
const val HELLO_TIMEOUT_MS = 2_500L
|
||||||
|
const val MAX_NAME_LENGTH = 60
|
||||||
|
private const val RESOLVE_PATH = "v1/aiturk/mobile-pair/resolve"
|
||||||
|
private const val HELLO_PATH = "aiturk/pair/hello"
|
||||||
|
private const val CLAIM_PATH = "aiturk/pair/claim"
|
||||||
|
private const val IDE_APP_ID = "aiturk-ide"
|
||||||
|
private const val HELLO_MARGIN_MS = 500L
|
||||||
|
private const val CLAIM_TIMEOUT_MS = 15_000L
|
||||||
|
private const val GATEWAY_TIMEOUT_MS = 15_000L
|
||||||
|
private const val MAX_BODY_BYTES = 16L * 1024
|
||||||
|
private val JSON = "application/json; charset=utf-8".toMediaType()
|
||||||
|
private val TOKEN_PATTERN = Regex("^amd_[A-Za-z0-9_-]{43}$")
|
||||||
|
|
||||||
|
// A bare client on purpose: no cookie jar, no user-configured custom
|
||||||
|
// headers and no Authorization stamping reach the gateway or `claim`.
|
||||||
|
private val defaultBaseClient: OkHttpClient by lazy {
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.connectTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.writeTimeout(10, TimeUnit.SECONDS)
|
||||||
|
.followRedirects(false)
|
||||||
|
.followSslRedirects(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sanitizeDeviceName(raw: String): String =
|
||||||
|
raw
|
||||||
|
.filterNot { it.isISOControl() }
|
||||||
|
.trim()
|
||||||
|
.take(MAX_NAME_LENGTH)
|
||||||
|
.ifBlank { "Android" }
|
||||||
|
|
||||||
|
internal fun claimError(
|
||||||
|
httpCode: Int,
|
||||||
|
errorName: String?,
|
||||||
|
): AiturkPairError =
|
||||||
|
when (errorName) {
|
||||||
|
"invalid_request" -> AiturkPairError.INVALID_REQUEST
|
||||||
|
"wrong_code" -> AiturkPairError.WRONG_CODE
|
||||||
|
"pairing_closed" -> AiturkPairError.PAIRING_CLOSED
|
||||||
|
"pairing_locked" -> AiturkPairError.PAIRING_LOCKED
|
||||||
|
else ->
|
||||||
|
when (httpCode) {
|
||||||
|
400, 413 -> AiturkPairError.INVALID_REQUEST
|
||||||
|
401 -> AiturkPairError.WRONG_CODE
|
||||||
|
410 -> AiturkPairError.PAIRING_CLOSED
|
||||||
|
else -> AiturkPairError.UNEXPECTED_RESPONSE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import java.net.URI
|
||||||
|
import java.net.URLDecoder
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AITURK IDE pairing contract v1 (phone side), pure JVM so it is unit-testable.
|
||||||
|
*
|
||||||
|
* QR / deep-link payload:
|
||||||
|
* `aiturk://pair?v=1&u=<ip:port>[&u=<ip:port>… max 4]&fp=<43 chars>&c=<8 char code>`
|
||||||
|
*/
|
||||||
|
data class AiturkEndpoint(
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
) {
|
||||||
|
/** Key used by the certificate pin store. */
|
||||||
|
val hostPort: String get() = "$host:$port"
|
||||||
|
|
||||||
|
/** Canonical dashboard base URL (always HTTPS, trailing slash). */
|
||||||
|
val baseUrl: String get() = "https://$host:$port/"
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A pairing offer: where the IDE listens and which certificate it must present. */
|
||||||
|
data class AiturkPairingOffer(
|
||||||
|
val endpoints: List<AiturkEndpoint>,
|
||||||
|
val fingerprint: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
data class AiturkPairingPayload(
|
||||||
|
val offer: AiturkPairingOffer,
|
||||||
|
val code: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class AiturkPayloadError {
|
||||||
|
/** Not an `aiturk://pair` link at all. */
|
||||||
|
NOT_AITURK,
|
||||||
|
|
||||||
|
/** `v` is present but not 1: the app is too old for this QR. */
|
||||||
|
UNSUPPORTED_VERSION,
|
||||||
|
|
||||||
|
/** Missing or malformed field (`v`, `u`, `fp`, `c`). */
|
||||||
|
MALFORMED,
|
||||||
|
|
||||||
|
/** `fp` missing or not a 43-char base64url SHA-256. */
|
||||||
|
BAD_FINGERPRINT,
|
||||||
|
|
||||||
|
/** A `u` entry is not `<private IPv4>:<port>`. */
|
||||||
|
BAD_ADDRESS,
|
||||||
|
|
||||||
|
/** No `u`, or more than [AiturkPairing.MAX_ENDPOINTS]. */
|
||||||
|
BAD_ADDRESS_COUNT,
|
||||||
|
|
||||||
|
/** `c` is not 8 characters of the pairing alphabet. */
|
||||||
|
BAD_CODE,
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface AiturkPayloadResult {
|
||||||
|
data class Ok(
|
||||||
|
val payload: AiturkPairingPayload,
|
||||||
|
) : AiturkPayloadResult
|
||||||
|
|
||||||
|
data class Invalid(
|
||||||
|
val error: AiturkPayloadError,
|
||||||
|
) : AiturkPayloadResult
|
||||||
|
}
|
||||||
|
|
||||||
|
object AiturkPairing {
|
||||||
|
const val SCHEME = "aiturk"
|
||||||
|
const val HOST = "pair"
|
||||||
|
const val VERSION = "1"
|
||||||
|
const val MAX_ENDPOINTS = 4
|
||||||
|
const val CODE_LENGTH = 8
|
||||||
|
const val CODE_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||||
|
const val FINGERPRINT_LENGTH = 43
|
||||||
|
const val LOOKUP_PREFIX = "aiturk-mobile-pair-v1:"
|
||||||
|
|
||||||
|
private val fingerprintPattern = Regex("^[A-Za-z0-9_-]{43}$")
|
||||||
|
private val endpointPattern = Regex("""^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3}):(\d{1,5})$""")
|
||||||
|
|
||||||
|
/** Quick check used by the deep-link entry point. */
|
||||||
|
fun looksLikePairingLink(raw: String?): Boolean =
|
||||||
|
raw != null && raw.trim().startsWith("$SCHEME://$HOST", ignoreCase = true)
|
||||||
|
|
||||||
|
fun parse(raw: String): AiturkPayloadResult {
|
||||||
|
val uri =
|
||||||
|
runCatching { URI(raw.trim()) }.getOrNull()
|
||||||
|
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
|
||||||
|
if (!SCHEME.equals(uri.scheme, ignoreCase = true) || !HOST.equals(uri.host, ignoreCase = true)) {
|
||||||
|
return AiturkPayloadResult.Invalid(AiturkPayloadError.NOT_AITURK)
|
||||||
|
}
|
||||||
|
val params =
|
||||||
|
runCatching { parseQuery(uri.rawQuery.orEmpty()) }.getOrNull()
|
||||||
|
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
|
||||||
|
|
||||||
|
fun single(name: String): String? = params.filter { it.first == name }.map { it.second }.singleOrNull()
|
||||||
|
|
||||||
|
// Version first: a newer QR may carry fields this build cannot judge.
|
||||||
|
val version = single("v") ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.MALFORMED)
|
||||||
|
if (version != VERSION) return AiturkPayloadResult.Invalid(AiturkPayloadError.UNSUPPORTED_VERSION)
|
||||||
|
|
||||||
|
val fingerprint = single("fp")
|
||||||
|
if (fingerprint == null || !isValidFingerprint(fingerprint)) {
|
||||||
|
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_FINGERPRINT)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rawEndpoints = params.filter { it.first == "u" }.map { it.second }
|
||||||
|
if (rawEndpoints.isEmpty() || rawEndpoints.size > MAX_ENDPOINTS) {
|
||||||
|
return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS_COUNT)
|
||||||
|
}
|
||||||
|
val endpoints =
|
||||||
|
rawEndpoints.map {
|
||||||
|
parseEndpoint(it) ?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_ADDRESS)
|
||||||
|
}
|
||||||
|
|
||||||
|
val code =
|
||||||
|
single("c")?.let(::normalizeCode)
|
||||||
|
?: return AiturkPayloadResult.Invalid(AiturkPayloadError.BAD_CODE)
|
||||||
|
|
||||||
|
return AiturkPayloadResult.Ok(
|
||||||
|
AiturkPairingPayload(
|
||||||
|
offer = AiturkPairingOffer(endpoints.distinct(), fingerprint),
|
||||||
|
code = code,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upper-cases (locale-independent), strips whitespace and `-`, and returns the
|
||||||
|
* code only when it is exactly 8 characters of [CODE_ALPHABET]; null otherwise.
|
||||||
|
*/
|
||||||
|
fun normalizeCode(raw: String): String? {
|
||||||
|
val stripped = raw.uppercase(Locale.ROOT).filterNot { it.isWhitespace() || it == '-' }
|
||||||
|
return stripped.takeIf { it.length == CODE_LENGTH && it.all { c -> c in CODE_ALPHABET } }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keeps only alphabet characters (upper-cased), capped at 8 — for the text field. */
|
||||||
|
fun cleanCodeInput(raw: String): String =
|
||||||
|
raw
|
||||||
|
.uppercase(Locale.ROOT)
|
||||||
|
.filter { it in CODE_ALPHABET }
|
||||||
|
.take(CODE_LENGTH)
|
||||||
|
|
||||||
|
/** `hex(SHA-256("aiturk-mobile-pair-v1:" + CODE))` — the raw code never leaves the phone. */
|
||||||
|
fun lookup(normalizedCode: String): String {
|
||||||
|
val digest =
|
||||||
|
MessageDigest
|
||||||
|
.getInstance("SHA-256")
|
||||||
|
.digest((LOOKUP_PREFIX + normalizedCode).toByteArray(Charsets.US_ASCII))
|
||||||
|
return digest.joinToString("") { "%02x".format(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isValidFingerprint(value: String): Boolean = fingerprintPattern.matches(value) && decodeFingerprint(value) != null
|
||||||
|
|
||||||
|
/** The 32 SHA-256 bytes behind a base64url (unpadded) fingerprint, or null. */
|
||||||
|
fun decodeFingerprint(value: String): ByteArray? =
|
||||||
|
runCatching { Base64.getUrlDecoder().decode(value) }
|
||||||
|
.getOrNull()
|
||||||
|
?.takeIf { it.size == 32 }
|
||||||
|
|
||||||
|
/** `<private IPv4>:<port>`; no DNS names, no leading zeros, no public ranges. */
|
||||||
|
fun parseEndpoint(raw: String): AiturkEndpoint? {
|
||||||
|
val match = endpointPattern.matchEntire(raw) ?: return null
|
||||||
|
val parts = match.groupValues.drop(1)
|
||||||
|
if (parts.any { it.length > 1 && it.startsWith('0') }) return null
|
||||||
|
val octets = parts.take(4).map { it.toInt() }
|
||||||
|
if (octets.any { it > 255 }) return null
|
||||||
|
val port = parts[4].toInt()
|
||||||
|
if (port !in 1..65535) return null
|
||||||
|
val (a, b) = octets
|
||||||
|
val isPrivate = a == 10 || (a == 172 && b in 16..31) || (a == 192 && b == 168)
|
||||||
|
if (!isPrivate) return null
|
||||||
|
return AiturkEndpoint(octets.joinToString("."), port)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway `resolve` URLs are `https://<private IPv4>:<port>` (optional trailing slash). */
|
||||||
|
fun parseEndpointUrl(raw: String): AiturkEndpoint? {
|
||||||
|
if (!raw.startsWith("https://")) return null
|
||||||
|
return parseEndpoint(raw.removePrefix("https://").removeSuffix("/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseQuery(rawQuery: String): List<Pair<String, String>> =
|
||||||
|
rawQuery
|
||||||
|
.split('&')
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
.map { part ->
|
||||||
|
val index = part.indexOf('=')
|
||||||
|
require(index > 0) { "Query parameter without a value" }
|
||||||
|
decode(part.substring(0, index)) to decode(part.substring(index + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
// '+' must stay literal: URLDecoder would otherwise turn it into a space.
|
||||||
|
private fun decode(value: String): String = URLDecoder.decode(value.replace("+", "%2B"), "UTF-8")
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.m57.hermescontrol.data.remote
|
package com.m57.hermescontrol.data.remote
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -19,6 +20,7 @@ internal object DashboardSessionTokenRefresher {
|
|||||||
@Synchronized
|
@Synchronized
|
||||||
fun refresh(): String? =
|
fun refresh(): String? =
|
||||||
try {
|
try {
|
||||||
|
if (isAiturkPaired()) return null
|
||||||
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
|
val token = fetchSync(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return null
|
||||||
AuthManager.setToken(token)
|
AuthManager.setToken(token)
|
||||||
token
|
token
|
||||||
@@ -29,6 +31,7 @@ internal object DashboardSessionTokenRefresher {
|
|||||||
suspend fun refreshAsync(): String? =
|
suspend fun refreshAsync(): String? =
|
||||||
refreshMutex.withLock {
|
refreshMutex.withLock {
|
||||||
try {
|
try {
|
||||||
|
if (isAiturkPaired()) return@withLock null
|
||||||
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
|
val token = fetch(AuthManager.baseUrl(), OkHttpProvider.probe) ?: return@withLock null
|
||||||
AuthManager.setToken(token)
|
AuthManager.setToken(token)
|
||||||
token
|
token
|
||||||
@@ -37,6 +40,11 @@ internal object DashboardSessionTokenRefresher {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AITURK: a paired IDE issues a long-lived device token and its LAN proxy
|
||||||
|
// answers 404 on `/` (the dashboard page must never leave the computer), so
|
||||||
|
// there is nothing to refresh and the stored token must stay as it is.
|
||||||
|
private fun isAiturkPaired(): Boolean = AiturkLanTrust.isPairedUrl(AuthManager.baseUrl())
|
||||||
|
|
||||||
internal suspend fun fetch(
|
internal suspend fun fetch(
|
||||||
baseUrl: String,
|
baseUrl: String,
|
||||||
client: OkHttpClient,
|
client: OkHttpClient,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.remote
|
|||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import kotlinx.serialization.json.JsonNamingStrategy
|
import kotlinx.serialization.json.JsonNamingStrategy
|
||||||
|
import com.m57.hermescontrol.data.aiturk.AiturkLanTrust
|
||||||
import okhttp3.ConnectionPool
|
import okhttp3.ConnectionPool
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
@@ -39,6 +40,9 @@ object OkHttpProvider {
|
|||||||
.readTimeout(30, TimeUnit.SECONDS)
|
.readTimeout(30, TimeUnit.SECONDS)
|
||||||
.writeTimeout(15, TimeUnit.SECONDS)
|
.writeTimeout(15, TimeUnit.SECONDS)
|
||||||
.retryOnConnectionFailure(true)
|
.retryOnConnectionFailure(true)
|
||||||
|
// AITURK: a paired IDE's host:port is verified by certificate
|
||||||
|
// fingerprint; every other address keeps the platform TLS checks.
|
||||||
|
.let(AiturkLanTrust::applyTo)
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertThrows
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.IOException
|
||||||
|
import java.net.Socket
|
||||||
|
import java.security.cert.CertificateException
|
||||||
|
import java.security.cert.X509Certificate
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import javax.net.ssl.HostnameVerifier
|
||||||
|
import javax.net.ssl.X509TrustManager
|
||||||
|
|
||||||
|
class AiturkLanTrustTest {
|
||||||
|
private val memory = MemoryPins()
|
||||||
|
private val ides = mutableListOf<FakeIde>()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
AiturkLanTrust.resetForTest(memory)
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
ides.forEach { runCatching { it.stop() } }
|
||||||
|
AiturkLanTrust.resetForTest(MemoryPins())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ide(certificate: okhttp3.tls.HeldCertificate = FakeIde.selfSigned()): FakeIde =
|
||||||
|
FakeIde(certificate).start().also { ides += it }
|
||||||
|
|
||||||
|
/** Built exactly like OkHttpProvider.base wires it: the single `applyTo` hook. */
|
||||||
|
private fun appClient(): OkHttpClient =
|
||||||
|
AiturkLanTrust
|
||||||
|
.applyTo(OkHttpClient.Builder())
|
||||||
|
.connectTimeout(3, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(3, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun get(
|
||||||
|
client: OkHttpClient,
|
||||||
|
server: FakeIde,
|
||||||
|
): Int =
|
||||||
|
client
|
||||||
|
.newCall(Request.Builder().url("https://${server.endpoint.hostPort}/aiturk/pair/hello").build())
|
||||||
|
.execute()
|
||||||
|
.use { it.code }
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pairedHostPortIsTrustedByFingerprintAlone() {
|
||||||
|
val server = ide()
|
||||||
|
AiturkLanTrust.pin(server.endpoint.hostPort, server.fingerprint)
|
||||||
|
|
||||||
|
assertEquals(200, get(appClient(), server))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pairedHostPortWithAnotherCertificateIsRejected() {
|
||||||
|
val server = ide()
|
||||||
|
AiturkLanTrust.pin(server.endpoint.hostPort, FakeIde.fingerprintOf(FakeIde.selfSigned()))
|
||||||
|
|
||||||
|
assertThrows(IOException::class.java) { get(appClient(), server) }
|
||||||
|
assertTrue(server.paths.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pinDoesNotRelaxValidationForAnyOtherAddress() {
|
||||||
|
// Same certificate on two ports of the same host; only one port is paired.
|
||||||
|
val certificate = FakeIde.selfSigned()
|
||||||
|
val paired = ide(certificate)
|
||||||
|
val other = ide(certificate)
|
||||||
|
AiturkLanTrust.pin(paired.endpoint.hostPort, paired.fingerprint)
|
||||||
|
val client = appClient()
|
||||||
|
|
||||||
|
assertEquals(200, get(client, paired))
|
||||||
|
// The unpaired port falls through to the platform trust store, which
|
||||||
|
// does not know this self-signed certificate.
|
||||||
|
assertThrows(IOException::class.java) { get(client, other) }
|
||||||
|
assertTrue(other.paths.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun withoutAnyPinSelfSignedServersAreStillRejected() {
|
||||||
|
val server = ide()
|
||||||
|
|
||||||
|
assertThrows(IOException::class.java) { get(appClient(), server) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unpairedPeersAreHandedToThePlatformTrustManagerUnchanged() {
|
||||||
|
val server = ide()
|
||||||
|
val chain = arrayOf(server.certificate.certificate)
|
||||||
|
val delegate = RecordingTrustManager()
|
||||||
|
val manager = PinAwareTrustManager(delegate, { null }, { emptyList() })
|
||||||
|
|
||||||
|
manager.checkServerTrusted(chain, "ECDHE_ECDSA", null as Socket?)
|
||||||
|
manager.checkServerTrusted(chain, "ECDHE_ECDSA")
|
||||||
|
manager.checkServerTrusted(chain, "ECDHE_ECDSA", "example.com")
|
||||||
|
|
||||||
|
assertEquals(3, delegate.serverChecks)
|
||||||
|
// And a delegate that refuses is not overruled.
|
||||||
|
val strict = PinAwareTrustManager(RecordingTrustManager(reject = true), { null }, { emptyList() })
|
||||||
|
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA", null as Socket?) }
|
||||||
|
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA") }
|
||||||
|
assertThrows(CertificateException::class.java) { strict.checkServerTrusted(chain, "ECDHE_ECDSA", "example.com") }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun hostnameVerificationIsDelegatedForUnpairedHosts() {
|
||||||
|
var delegated = 0
|
||||||
|
val delegate =
|
||||||
|
HostnameVerifier { _, _ ->
|
||||||
|
delegated++
|
||||||
|
false
|
||||||
|
}
|
||||||
|
val verifier = PinAwareHostnameVerifier(delegate) { null }
|
||||||
|
val session = io.mockk.mockk<javax.net.ssl.SSLSession>()
|
||||||
|
io.mockk.every { session.peerPort } returns 443
|
||||||
|
|
||||||
|
assertFalse(verifier.verify("example.com", session))
|
||||||
|
assertEquals(1, delegated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pinsSurviveARestartAndCanBeRemoved() {
|
||||||
|
val fp = "A".repeat(43)
|
||||||
|
AiturkLanTrust.pin("192.168.1.20:47321", fp)
|
||||||
|
|
||||||
|
AiturkLanTrust.resetForTest(memory)
|
||||||
|
|
||||||
|
assertEquals(fp, AiturkLanTrust.pinFor("192.168.1.20:47321"))
|
||||||
|
assertTrue(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47321/"))
|
||||||
|
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47322/"))
|
||||||
|
assertFalse(AiturkLanTrust.isPairedUrl("http://192.168.1.20:47321/"))
|
||||||
|
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.21:47321/"))
|
||||||
|
assertFalse(AiturkLanTrust.isPairedUrl(null))
|
||||||
|
|
||||||
|
AiturkLanTrust.unpin("192.168.1.20:47321")
|
||||||
|
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
|
||||||
|
assertTrue(memory.stored.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun malformedFingerprintCannotBePinned() {
|
||||||
|
assertThrows(IllegalArgumentException::class.java) { AiturkLanTrust.pin("192.168.1.20:47321", "nope") }
|
||||||
|
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unreadableStorageMeansNothingIsPaired() {
|
||||||
|
AiturkLanTrust.resetForTest(
|
||||||
|
object : AiturkLanTrust.PinPersistence {
|
||||||
|
override fun load(): Map<String, String> = error("storage not ready")
|
||||||
|
|
||||||
|
override fun save(pins: Map<String, String>) = Unit
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assertNull(AiturkLanTrust.pinFor("192.168.1.20:47321"))
|
||||||
|
assertFalse(AiturkLanTrust.isPairedUrl("https://192.168.1.20:47321/"))
|
||||||
|
}
|
||||||
|
|
||||||
|
private class RecordingTrustManager(
|
||||||
|
private val reject: Boolean = false,
|
||||||
|
) : X509TrustManager {
|
||||||
|
var serverChecks = 0
|
||||||
|
|
||||||
|
override fun checkClientTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
override fun checkServerTrusted(
|
||||||
|
chain: Array<X509Certificate>,
|
||||||
|
authType: String,
|
||||||
|
) {
|
||||||
|
serverChecks++
|
||||||
|
if (reject) throw CertificateException("untrusted")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pairing code handles a one-time code and a long-lived device token. The
|
||||||
|
* simplest guarantee that neither reaches logcat is that this code does not
|
||||||
|
* log at all — this test keeps it that way.
|
||||||
|
*/
|
||||||
|
class AiturkNoSecretLoggingTest {
|
||||||
|
private val sourceRoot = File("src/main/java/com/m57/hermescontrol")
|
||||||
|
private val forbidden =
|
||||||
|
listOf(
|
||||||
|
Regex("""\bLog\.[a-z]+\("""),
|
||||||
|
Regex("""\bprintln\("""),
|
||||||
|
Regex("""\bprint\("""),
|
||||||
|
Regex("""printStackTrace"""),
|
||||||
|
Regex("""HttpLoggingInterceptor"""),
|
||||||
|
Regex("""\bTimber\b"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pairingSourcesDoNotLog() {
|
||||||
|
val files =
|
||||||
|
listOf("data/aiturk", "ui/aiturkpair")
|
||||||
|
.map { File(sourceRoot, it) }
|
||||||
|
.onEach { assertTrue(it.isDirectory, "missing source directory: ${it.absolutePath}") }
|
||||||
|
.flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.extension == "kt" }.toList() }
|
||||||
|
assertTrue(files.size >= 5, "expected the pairing sources, found ${files.map { it.name }}")
|
||||||
|
|
||||||
|
val offenders =
|
||||||
|
files.flatMap { file ->
|
||||||
|
file.readLines().mapIndexedNotNull { index, line ->
|
||||||
|
if (forbidden.any { it.containsMatchIn(line) }) "${file.name}:${index + 1}: ${line.trim()}" else null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
assertTrue(offenders.isEmpty(), "logging found in pairing code:\n" + offenders.joinToString("\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pairingClientUsesItsOwnBareHttpClient() {
|
||||||
|
// The shared OkHttpProvider clients carry the cookie jar, user-defined
|
||||||
|
// custom headers and (in debug) request logging — none of that may
|
||||||
|
// touch the gateway lookup or the claim that carries the code.
|
||||||
|
val source = File(sourceRoot, "data/aiturk/AiturkPairingClient.kt").readText()
|
||||||
|
|
||||||
|
assertTrue(!source.contains("OkHttpProvider"), "AiturkPairingClient must not use OkHttpProvider")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.SocketPolicy
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.net.ServerSocket
|
||||||
|
|
||||||
|
class AiturkPairingClientTest {
|
||||||
|
private lateinit var gateway: MockWebServer
|
||||||
|
private val ides = mutableListOf<FakeIde>()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
gateway = MockWebServer()
|
||||||
|
gateway.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
gateway.shutdown()
|
||||||
|
ides.forEach { runCatching { it.stop() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun ide(configure: FakeIde.() -> Unit = {}): FakeIde = FakeIde().apply(configure).start().also { ides += it }
|
||||||
|
|
||||||
|
private fun client(helloTimeoutMs: Long = 2_500L) =
|
||||||
|
AiturkPairingClient(gatewayBaseUrl = gateway.url("/"), helloTimeoutMs = helloTimeoutMs)
|
||||||
|
|
||||||
|
private fun <T> AiturkPairResult<T>.error(): AiturkPairError? = (this as? AiturkPairResult.Failure)?.error
|
||||||
|
|
||||||
|
private fun closedPort(): Int = ServerSocket(0).use { it.localPort }
|
||||||
|
|
||||||
|
// ── resolve (gateway) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveSendsOnlyTheLookupHashAndReturnsTheOffer() =
|
||||||
|
runBlocking {
|
||||||
|
val fp = "A".repeat(43)
|
||||||
|
gateway.enqueue(
|
||||||
|
FakeIde.json(200, """{"urls":["https://192.168.1.20:47321","https://10.0.0.5:47322"],"fp":"$fp","expiresIn":280}"""),
|
||||||
|
)
|
||||||
|
|
||||||
|
val result = client().resolve("ABCDEFGH")
|
||||||
|
|
||||||
|
val offer = (result as AiturkPairResult.Success).value
|
||||||
|
assertEquals(listOf(AiturkEndpoint("192.168.1.20", 47321), AiturkEndpoint("10.0.0.5", 47322)), offer.endpoints)
|
||||||
|
assertEquals(fp, offer.fingerprint)
|
||||||
|
val request = gateway.takeRequest()
|
||||||
|
assertEquals("POST", request.method)
|
||||||
|
assertEquals("/v1/aiturk/mobile-pair/resolve", request.path)
|
||||||
|
val body = request.body.readUtf8()
|
||||||
|
assertEquals("""{"lookup":"${AiturkPairingPayloadTest.LOOKUP_ABCDEFGH}"}""", body)
|
||||||
|
assertFalse(body.contains("ABCDEFGH"), "the raw code must never reach the gateway")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveMapsNotFoundAndRateLimit() =
|
||||||
|
runBlocking {
|
||||||
|
gateway.enqueue(MockResponse().setResponseCode(404))
|
||||||
|
gateway.enqueue(MockResponse().setResponseCode(429))
|
||||||
|
gateway.enqueue(MockResponse().setResponseCode(500))
|
||||||
|
|
||||||
|
assertEquals(AiturkPairError.CODE_NOT_FOUND, client().resolve("ABCDEFGH").error())
|
||||||
|
assertEquals(AiturkPairError.RATE_LIMITED, client().resolve("ABCDEFGH").error())
|
||||||
|
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveRejectsOffersThatPointOutsideThePrivateNetwork() =
|
||||||
|
runBlocking {
|
||||||
|
val fp = "A".repeat(43)
|
||||||
|
listOf(
|
||||||
|
"""{"urls":["https://8.8.8.8:47321"],"fp":"$fp"}""",
|
||||||
|
"""{"urls":["http://192.168.1.20:47321"],"fp":"$fp"}""",
|
||||||
|
"""{"urls":["https://evil.example:443"],"fp":"$fp"}""",
|
||||||
|
"""{"urls":[],"fp":"$fp"}""",
|
||||||
|
"""{"urls":["https://192.168.1.1:1","https://192.168.1.2:1","https://192.168.1.3:1","https://192.168.1.4:1","https://192.168.1.5:1"],"fp":"$fp"}""",
|
||||||
|
"""{"urls":["https://192.168.1.20:47321"],"fp":"short"}""",
|
||||||
|
"""{"urls":["https://192.168.1.20:47321"]}""",
|
||||||
|
"""not json""",
|
||||||
|
).forEach { body ->
|
||||||
|
gateway.enqueue(FakeIde.json(200, body))
|
||||||
|
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error(), body)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun resolveReportsAnUnreachableGateway() =
|
||||||
|
runBlocking {
|
||||||
|
gateway.shutdown()
|
||||||
|
|
||||||
|
assertEquals(AiturkPairError.GATEWAY_UNAVAILABLE, client().resolve("ABCDEFGH").error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── hello + claim (computer) ─────────────────────────────────────────
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun firstReachableCandidateIsClaimedExactlyOnce() =
|
||||||
|
runBlocking {
|
||||||
|
val good = ide()
|
||||||
|
val dead = AiturkEndpoint(FakeIde.LOOPBACK, closedPort())
|
||||||
|
val offer = AiturkPairingOffer(listOf(dead, good.endpoint), good.fingerprint)
|
||||||
|
|
||||||
|
val result = client().pair(offer, "ABCDEFGH", "Pixel 9")
|
||||||
|
|
||||||
|
val device = (result as AiturkPairResult.Success).value
|
||||||
|
assertEquals(good.endpoint, device.endpoint)
|
||||||
|
assertEquals(good.fingerprint, device.fingerprint)
|
||||||
|
assertEquals(FakeIde.TOKEN, device.token)
|
||||||
|
assertEquals("MASTE-PC", device.pcName)
|
||||||
|
assertEquals("7c0e8a52-0000-4000-8000-000000000001", device.deviceId)
|
||||||
|
assertEquals(1, good.helloCount)
|
||||||
|
assertEquals(1, good.claimCount)
|
||||||
|
assertEquals("""{"code":"ABCDEFGH","device_name":"Pixel 9"}""", good.claimBodies.single())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyOneClaimIsSentWhenSeveralCandidatesAnswer() =
|
||||||
|
runBlocking {
|
||||||
|
val certificate = FakeIde.selfSigned()
|
||||||
|
val first = FakeIde(certificate).start().also { ides += it }
|
||||||
|
val second = FakeIde(certificate).start().also { ides += it }
|
||||||
|
val offer = AiturkPairingOffer(listOf(first.endpoint, second.endpoint), first.fingerprint)
|
||||||
|
|
||||||
|
val result = client().pair(offer, "ABCDEFGH", "Pixel 9")
|
||||||
|
|
||||||
|
assertTrue(result is AiturkPairResult.Success)
|
||||||
|
assertEquals(1, first.claimCount + second.claimCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun claimErrorsAreMappedByNameAndNeverRetried() =
|
||||||
|
runBlocking {
|
||||||
|
val cases =
|
||||||
|
listOf(
|
||||||
|
Triple(401, "wrong_code", AiturkPairError.WRONG_CODE),
|
||||||
|
Triple(410, "pairing_closed", AiturkPairError.PAIRING_CLOSED),
|
||||||
|
Triple(410, "pairing_locked", AiturkPairError.PAIRING_LOCKED),
|
||||||
|
Triple(400, "invalid_request", AiturkPairError.INVALID_REQUEST),
|
||||||
|
)
|
||||||
|
cases.forEach { (status, name, expected) ->
|
||||||
|
val server = ide { claim = FakeIde.json(status, """{"error":"$name"}""") }
|
||||||
|
|
||||||
|
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
|
||||||
|
|
||||||
|
assertEquals(expected, result.error(), name)
|
||||||
|
assertEquals(1, server.claimCount, "$name must not be retried")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun claimErrorsFallBackToTheStatusCodeWithoutABody() {
|
||||||
|
assertEquals(AiturkPairError.WRONG_CODE, AiturkPairingClient.claimError(401, null))
|
||||||
|
assertEquals(AiturkPairError.PAIRING_CLOSED, AiturkPairingClient.claimError(410, null))
|
||||||
|
assertEquals(AiturkPairError.INVALID_REQUEST, AiturkPairingClient.claimError(413, null))
|
||||||
|
assertEquals(AiturkPairError.INVALID_REQUEST, AiturkPairingClient.claimError(400, "something_new"))
|
||||||
|
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, AiturkPairingClient.claimError(500, null))
|
||||||
|
// The contract name wins over the status.
|
||||||
|
assertEquals(AiturkPairError.PAIRING_LOCKED, AiturkPairingClient.claimError(410, "pairing_locked"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun claimThatEndsWithoutAUsableAnswerFailsAfterASingleAttempt() =
|
||||||
|
runBlocking {
|
||||||
|
val server = ide { claim = MockResponse().setSocketPolicy(SocketPolicy.DISCONNECT_AFTER_REQUEST) }
|
||||||
|
|
||||||
|
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
|
||||||
|
|
||||||
|
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, result.error())
|
||||||
|
assertEquals(1, server.claimCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pairingThatIsNotOpenIsReportedWithoutSpendingAClaim() =
|
||||||
|
runBlocking {
|
||||||
|
val server = ide { hello = FakeIde.json(200, """{"app":"aiturk-ide","version":"1.2.3","pairing":false}""") }
|
||||||
|
|
||||||
|
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
|
||||||
|
|
||||||
|
assertEquals(AiturkPairError.PAIRING_CLOSED, result.error())
|
||||||
|
assertEquals(0, server.claimCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun serverWithAnotherCertificateIsRejectedBeforeAnyRequest() =
|
||||||
|
runBlocking {
|
||||||
|
val impostor = ide()
|
||||||
|
val expected = FakeIde.fingerprintOf(FakeIde.selfSigned())
|
||||||
|
|
||||||
|
val result = client().pair(AiturkPairingOffer(listOf(impostor.endpoint), expected), "ABCDEFGH", "x")
|
||||||
|
|
||||||
|
assertEquals(AiturkPairError.PC_UNREACHABLE, result.error())
|
||||||
|
// The TLS handshake fails, so neither hello nor the code ever reaches it.
|
||||||
|
assertTrue(impostor.paths.isEmpty(), "requests seen: ${impostor.paths}")
|
||||||
|
assertTrue(impostor.claimBodies.isEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun somethingThatIsNotTheIdeIsNotClaimed() =
|
||||||
|
runBlocking {
|
||||||
|
val other = ide { hello = FakeIde.json(200, """{"app":"something-else"}""") }
|
||||||
|
val notFound = ide { hello = FakeIde.json(404, """{"error":"not_found"}""") }
|
||||||
|
val fp = other.fingerprint
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
AiturkPairError.PC_UNREACHABLE,
|
||||||
|
client().pair(AiturkPairingOffer(listOf(other.endpoint), fp), "ABCDEFGH", "x").error(),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
AiturkPairError.PC_UNREACHABLE,
|
||||||
|
client().pair(AiturkPairingOffer(listOf(notFound.endpoint), notFound.fingerprint), "ABCDEFGH", "x").error(),
|
||||||
|
)
|
||||||
|
assertEquals(0, other.claimCount + notFound.claimCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun silentCandidateTimesOut() =
|
||||||
|
runBlocking {
|
||||||
|
val silent = ide { hello = MockResponse().setSocketPolicy(SocketPolicy.NO_RESPONSE) }
|
||||||
|
val startedAt = System.nanoTime()
|
||||||
|
|
||||||
|
val result =
|
||||||
|
client(helloTimeoutMs = 400L)
|
||||||
|
.pair(AiturkPairingOffer(listOf(silent.endpoint), silent.fingerprint), "ABCDEFGH", "x")
|
||||||
|
|
||||||
|
assertEquals(AiturkPairError.PC_UNREACHABLE, result.error())
|
||||||
|
assertTrue((System.nanoTime() - startedAt) / 1_000_000 < 5_000, "hello must give up quickly")
|
||||||
|
assertEquals(0, silent.claimCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun malformedTokenIsNotAccepted() =
|
||||||
|
runBlocking {
|
||||||
|
listOf("amd_short", "xyz_" + "t".repeat(43), "amd_" + "t".repeat(42) + "!").forEach { token ->
|
||||||
|
val server = ide { claim = FakeIde.json(200, FakeIde.claimBody(token = token)) }
|
||||||
|
|
||||||
|
val result = client().pair(AiturkPairingOffer(listOf(server.endpoint), server.fingerprint), "ABCDEFGH", "x")
|
||||||
|
|
||||||
|
assertEquals(AiturkPairError.UNEXPECTED_RESPONSE, result.error(), token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun deviceNameIsTrimmedToTheContractLimit() {
|
||||||
|
assertEquals("Android", AiturkPairingClient.sanitizeDeviceName(" "))
|
||||||
|
assertEquals(60, AiturkPairingClient.sanitizeDeviceName("x".repeat(200)).length)
|
||||||
|
assertEquals("Galaxy S24", AiturkPairingClient.sanitizeDeviceName(" Galaxy\u0000 S24\n"))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertFalse
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.Assertions.assertTrue
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
|
class AiturkPairingPayloadTest {
|
||||||
|
private val fp = "A".repeat(43)
|
||||||
|
|
||||||
|
private fun link(
|
||||||
|
v: String? = "1",
|
||||||
|
us: List<String> = listOf("192.168.1.20:47321"),
|
||||||
|
fingerprint: String? = fp,
|
||||||
|
code: String? = "ABCDEFGH",
|
||||||
|
): String =
|
||||||
|
buildList {
|
||||||
|
v?.let { add("v=$it") }
|
||||||
|
us.forEach { add("u=$it") }
|
||||||
|
fingerprint?.let { add("fp=$it") }
|
||||||
|
code?.let { add("c=$it") }
|
||||||
|
}.joinToString("&", prefix = "aiturk://pair?")
|
||||||
|
|
||||||
|
private fun errorOf(raw: String): AiturkPayloadError? = (AiturkPairing.parse(raw) as? AiturkPayloadResult.Invalid)?.error
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun validPayloadWithSeveralAddressesIsParsedInOrder() {
|
||||||
|
val result =
|
||||||
|
AiturkPairing.parse(link(us = listOf("192.168.1.20:47321", "10.0.0.5:47322", "172.16.4.9:47321")))
|
||||||
|
|
||||||
|
val payload = (result as AiturkPayloadResult.Ok).payload
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
AiturkEndpoint("192.168.1.20", 47321),
|
||||||
|
AiturkEndpoint("10.0.0.5", 47322),
|
||||||
|
AiturkEndpoint("172.16.4.9", 47321),
|
||||||
|
),
|
||||||
|
payload.offer.endpoints,
|
||||||
|
)
|
||||||
|
assertEquals(fp, payload.offer.fingerprint)
|
||||||
|
assertEquals("ABCDEFGH", payload.code)
|
||||||
|
assertEquals("https://192.168.1.20:47321/", payload.offer.endpoints.first().baseUrl)
|
||||||
|
assertEquals("192.168.1.20:47321", payload.offer.endpoints.first().hostPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun versionMismatchAsksForAnAppUpdate() {
|
||||||
|
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf(link(v = "2")))
|
||||||
|
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf(link(v = "01")))
|
||||||
|
// A newer QR is reported as such even when its other fields are unknown to us.
|
||||||
|
assertEquals(AiturkPayloadError.UNSUPPORTED_VERSION, errorOf("aiturk://pair?v=2&x=1"))
|
||||||
|
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link(v = null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun missingOrBrokenFingerprintIsRejected() {
|
||||||
|
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = null)))
|
||||||
|
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42))))
|
||||||
|
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(44))))
|
||||||
|
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "=")))
|
||||||
|
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "*")))
|
||||||
|
// Standard base64 alphabet ('+', '/') is not base64url.
|
||||||
|
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "%2B")))
|
||||||
|
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link(fingerprint = "A".repeat(42) + "/")))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun onlyPrivateIpv4AddressesAreAccepted() {
|
||||||
|
listOf(
|
||||||
|
"8.8.8.8:47321",
|
||||||
|
"172.15.0.1:47321",
|
||||||
|
"172.32.0.1:47321",
|
||||||
|
"192.169.1.1:47321",
|
||||||
|
"127.0.0.1:47321",
|
||||||
|
"169.254.1.1:47321",
|
||||||
|
"100.64.0.1:47321",
|
||||||
|
"example.com:47321",
|
||||||
|
"192.168.1.20",
|
||||||
|
"192.168.1.20:0",
|
||||||
|
"192.168.1.20:65536",
|
||||||
|
"192.168.1.256:47321",
|
||||||
|
"192.168.01.20:47321",
|
||||||
|
"[fe80::1]:47321",
|
||||||
|
"192.168.1.20:47321/path",
|
||||||
|
"user@192.168.1.20:47321",
|
||||||
|
).forEach { address ->
|
||||||
|
assertEquals(AiturkPayloadError.BAD_ADDRESS, errorOf(link(us = listOf(address))), address)
|
||||||
|
}
|
||||||
|
// One bad entry spoils the whole payload.
|
||||||
|
assertEquals(
|
||||||
|
AiturkPayloadError.BAD_ADDRESS,
|
||||||
|
errorOf(link(us = listOf("192.168.1.20:47321", "8.8.8.8:47321"))),
|
||||||
|
)
|
||||||
|
listOf("10.255.255.255:1", "172.16.0.1:65535", "172.31.255.254:47321", "192.168.0.1:47340").forEach {
|
||||||
|
assertTrue(AiturkPairing.parse(link(us = listOf(it))) is AiturkPayloadResult.Ok, it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun addressCountMustBeOneToFour() {
|
||||||
|
assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = emptyList())))
|
||||||
|
val five = (1..5).map { "192.168.1.$it:47321" }
|
||||||
|
assertEquals(AiturkPayloadError.BAD_ADDRESS_COUNT, errorOf(link(us = five)))
|
||||||
|
assertTrue(AiturkPairing.parse(link(us = five.take(4))) is AiturkPayloadResult.Ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun codeOutsideTheAlphabetIsRejected() {
|
||||||
|
// I, O, 0 and 1 are not in the alphabet.
|
||||||
|
listOf("ABCDEFGI", "ABCDEFGO", "ABCDEFG0", "ABCDEFG1", "ABCDEFG", "ABCDEFGHJ", "ABCD_FGH", "").forEach {
|
||||||
|
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link(code = it)), it)
|
||||||
|
}
|
||||||
|
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link(code = null)))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun otherSchemesAndHostsAreNotPairingLinks() {
|
||||||
|
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("https://pair?v=1"))
|
||||||
|
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("aiturk://other?v=1"))
|
||||||
|
assertEquals(AiturkPayloadError.NOT_AITURK, errorOf("just some text"))
|
||||||
|
assertFalse(AiturkPairing.looksLikePairingLink("https://example.com"))
|
||||||
|
assertFalse(AiturkPairing.looksLikePairingLink(null))
|
||||||
|
assertTrue(AiturkPairing.looksLikePairingLink(link()))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun duplicatedScalarFieldsAreRejected() {
|
||||||
|
assertEquals(AiturkPayloadError.BAD_FINGERPRINT, errorOf(link() + "&fp=" + "B".repeat(43)))
|
||||||
|
assertEquals(AiturkPayloadError.BAD_CODE, errorOf(link() + "&c=HJKLMNPQ"))
|
||||||
|
assertEquals(AiturkPayloadError.MALFORMED, errorOf(link() + "&v=1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun codeIsNormalizedFromLowerCaseSpacesAndDashes() {
|
||||||
|
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode("abcdefgh"))
|
||||||
|
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode("ABCD-EFGH"))
|
||||||
|
assertEquals("ABCDEFGH", AiturkPairing.normalizeCode(" abcd efgh \n"))
|
||||||
|
assertEquals("ABCD2345", AiturkPairing.normalizeCode("ab-cd 23-45"))
|
||||||
|
assertNull(AiturkPairing.normalizeCode("ABCDEFG"))
|
||||||
|
assertNull(AiturkPairing.normalizeCode("ABCDEFGHJ"))
|
||||||
|
assertNull(AiturkPairing.normalizeCode("ABCDEFG0"))
|
||||||
|
assertNull(AiturkPairing.normalizeCode("ABCD_EFGH"))
|
||||||
|
assertEquals("ABCDEFGH", (AiturkPairing.parse(link(code = "abcd-efgh")) as AiturkPayloadResult.Ok).payload.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalizationIgnoresTheDeviceLocale() {
|
||||||
|
val previous = Locale.getDefault()
|
||||||
|
try {
|
||||||
|
// Turkish upper-cases 'i' to a dotted capital that is not in the alphabet.
|
||||||
|
Locale.setDefault(Locale.forLanguageTag("tr-TR"))
|
||||||
|
assertEquals("QJKLMNPH", AiturkPairing.normalizeCode("qjklmnph"))
|
||||||
|
assertNull(AiturkPairing.normalizeCode("abcdefgi"))
|
||||||
|
assertEquals("ABCDEFGH", AiturkPairing.cleanCodeInput("abcd-efgh-i"))
|
||||||
|
} finally {
|
||||||
|
Locale.setDefault(previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun textFieldInputKeepsOnlyAlphabetCharactersUpToEight() {
|
||||||
|
assertEquals("ABCDEFGH", AiturkPairing.cleanCodeInput("ab cd-ef01ghjk"))
|
||||||
|
assertEquals("AB", AiturkPairing.cleanCodeInput("a-b"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun lookupIsTheHexSha256OfThePrefixedCode() {
|
||||||
|
val computed =
|
||||||
|
MessageDigest
|
||||||
|
.getInstance("SHA-256")
|
||||||
|
.digest("aiturk-mobile-pair-v1:ABCDEFGH".toByteArray())
|
||||||
|
.joinToString("") { "%02x".format(it) }
|
||||||
|
|
||||||
|
assertEquals(computed, AiturkPairing.lookup("ABCDEFGH"))
|
||||||
|
assertEquals(LOOKUP_ABCDEFGH, AiturkPairing.lookup("ABCDEFGH"))
|
||||||
|
assertEquals(64, AiturkPairing.lookup("ABCDEFGH").length)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gatewayUrlsMustBeHttpsPrivateAddresses() {
|
||||||
|
assertEquals(AiturkEndpoint("192.168.1.20", 47321), AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321"))
|
||||||
|
assertEquals(AiturkEndpoint("192.168.1.20", 47321), AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321/"))
|
||||||
|
assertNull(AiturkPairing.parseEndpointUrl("http://192.168.1.20:47321"))
|
||||||
|
assertNull(AiturkPairing.parseEndpointUrl("https://8.8.8.8:47321"))
|
||||||
|
assertNull(AiturkPairing.parseEndpointUrl("https://192.168.1.20:47321/x"))
|
||||||
|
assertNull(AiturkPairing.parseEndpointUrl("https://evil.example:47321"))
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** hex(sha256("aiturk-mobile-pair-v1:ABCDEFGH")), computed independently (Python hashlib). */
|
||||||
|
const val LOOKUP_ABCDEFGH = "10c03d82b47dff47053d4a829d4615e63da1739e2d7fb418f9d4ee8cc6b4c38a"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
|
import okhttp3.tls.HandshakeCertificates
|
||||||
|
import okhttp3.tls.HeldCertificate
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.security.MessageDigest
|
||||||
|
import java.util.Base64
|
||||||
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
|
|
||||||
|
/** A local stand-in for the IDE's LAN proxy: TLS with a self-signed certificate. */
|
||||||
|
internal class FakeIde(
|
||||||
|
val certificate: HeldCertificate = selfSigned(),
|
||||||
|
) {
|
||||||
|
val server = MockWebServer()
|
||||||
|
val paths = CopyOnWriteArrayList<String>()
|
||||||
|
val claimBodies = CopyOnWriteArrayList<String>()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var hello: MockResponse = json(200, """{"app":"aiturk-ide","version":"1.2.3","pairing":true}""")
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var claim: MockResponse = json(200, claimBody())
|
||||||
|
|
||||||
|
val endpoint: AiturkEndpoint get() = AiturkEndpoint(LOOPBACK, server.port)
|
||||||
|
val fingerprint: String get() = fingerprintOf(certificate)
|
||||||
|
val claimCount: Int get() = paths.count { it == "/aiturk/pair/claim" }
|
||||||
|
val helloCount: Int get() = paths.count { it == "/aiturk/pair/hello" }
|
||||||
|
|
||||||
|
fun start(): FakeIde {
|
||||||
|
val handshake = HandshakeCertificates.Builder().heldCertificate(certificate).build()
|
||||||
|
server.useHttps(handshake.sslSocketFactory(), false)
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val path = request.path.orEmpty()
|
||||||
|
paths += path
|
||||||
|
return when (path) {
|
||||||
|
"/aiturk/pair/hello" -> {
|
||||||
|
hello
|
||||||
|
}
|
||||||
|
|
||||||
|
"/aiturk/pair/claim" -> {
|
||||||
|
claimBodies += request.body.readUtf8()
|
||||||
|
claim
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
json(404, """{"error":"not_found"}""")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
server.start(InetAddress.getByName(LOOPBACK), 0)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop() = server.shutdown()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val LOOPBACK = "127.0.0.1"
|
||||||
|
val TOKEN = "amd_" + "t".repeat(43)
|
||||||
|
|
||||||
|
fun selfSigned(): HeldCertificate =
|
||||||
|
HeldCertificate
|
||||||
|
.Builder()
|
||||||
|
.commonName("aiturk-ide")
|
||||||
|
.addSubjectAlternativeName(LOOPBACK)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
fun fingerprintOf(certificate: HeldCertificate): String =
|
||||||
|
Base64
|
||||||
|
.getUrlEncoder()
|
||||||
|
.withoutPadding()
|
||||||
|
.encodeToString(MessageDigest.getInstance("SHA-256").digest(certificate.certificate.encoded))
|
||||||
|
|
||||||
|
fun json(
|
||||||
|
code: Int,
|
||||||
|
body: String,
|
||||||
|
): MockResponse =
|
||||||
|
MockResponse()
|
||||||
|
.setResponseCode(code)
|
||||||
|
.setHeader("Content-Type", "application/json")
|
||||||
|
.setBody(body)
|
||||||
|
|
||||||
|
fun claimBody(
|
||||||
|
token: String = TOKEN,
|
||||||
|
pcName: String = "MASTE-PC",
|
||||||
|
): String = """{"token":"$token","device_id":"7c0e8a52-0000-4000-8000-000000000001","pc_name":"$pcName","version":"1.2.3"}"""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal class MemoryPins(
|
||||||
|
val stored: MutableMap<String, String> = mutableMapOf(),
|
||||||
|
) : AiturkLanTrust.PinPersistence {
|
||||||
|
override fun load(): Map<String, String> = stored.toMap()
|
||||||
|
|
||||||
|
override fun save(pins: Map<String, String>) {
|
||||||
|
stored.clear()
|
||||||
|
stored.putAll(pins)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package com.m57.hermescontrol.data.aiturk
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
|
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||||
|
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockkObject
|
||||||
|
import io.mockk.unmockkAll
|
||||||
|
import io.mockk.verify
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import okhttp3.Interceptor
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.MockResponse
|
||||||
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.Assertions.assertEquals
|
||||||
|
import org.junit.jupiter.api.Assertions.assertNull
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import org.junit.jupiter.api.Test
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token mode refreshes the dashboard token with `GET <baseUrl>/` before every
|
||||||
|
* WebSocket handshake and on REST 401. An AITURK-paired IDE answers 404 there
|
||||||
|
* and its device token is long-lived, so the refresh must not run — and the
|
||||||
|
* stored `amd_…` token must never be replaced.
|
||||||
|
*/
|
||||||
|
class AiturkTokenRefreshSkipTest {
|
||||||
|
private val pairedUrl = "https://192.168.1.20:47321/"
|
||||||
|
private val calls = AtomicInteger()
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
fun setUp() {
|
||||||
|
AiturkLanTrust.resetForTest(MemoryPins())
|
||||||
|
mockkObject(AuthManager)
|
||||||
|
mockkObject(OkHttpProvider)
|
||||||
|
every { AuthManager.setToken(any()) } returns Unit
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
fun tearDown() {
|
||||||
|
unmockkAll()
|
||||||
|
AiturkLanTrust.resetForTest(MemoryPins())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun countingClient(): OkHttpClient =
|
||||||
|
OkHttpClient
|
||||||
|
.Builder()
|
||||||
|
.addInterceptor(
|
||||||
|
Interceptor {
|
||||||
|
calls.incrementAndGet()
|
||||||
|
throw IOException("no network in this test")
|
||||||
|
},
|
||||||
|
).build()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun pairedConnectionNeverAsksTheDashboardForAToken() =
|
||||||
|
runBlocking {
|
||||||
|
AiturkLanTrust.pin("192.168.1.20:47321", "A".repeat(43))
|
||||||
|
every { AuthManager.baseUrl() } returns pairedUrl
|
||||||
|
every { OkHttpProvider.probe } returns countingClient()
|
||||||
|
|
||||||
|
assertNull(DashboardSessionTokenRefresher.refreshAsync())
|
||||||
|
assertNull(DashboardSessionTokenRefresher.refresh())
|
||||||
|
|
||||||
|
assertEquals(0, calls.get())
|
||||||
|
verify(exactly = 0) { AuthManager.setToken(any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun unpairedConnectionStillRefreshesAsBefore() =
|
||||||
|
runBlocking {
|
||||||
|
every { AuthManager.baseUrl() } returns pairedUrl
|
||||||
|
every { OkHttpProvider.probe } returns countingClient()
|
||||||
|
|
||||||
|
assertNull(DashboardSessionTokenRefresher.refreshAsync())
|
||||||
|
|
||||||
|
assertEquals(1, calls.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun notFoundOnTheRootPathLeavesTheStoredTokenAlone() =
|
||||||
|
runBlocking {
|
||||||
|
// What the IDE proxy answers on `/`, seen by a connection that is not marked as paired.
|
||||||
|
val server = MockWebServer()
|
||||||
|
server.start()
|
||||||
|
try {
|
||||||
|
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_found"}"""))
|
||||||
|
server.enqueue(MockResponse().setResponseCode(404).setBody("""{"error":"not_found"}"""))
|
||||||
|
every { AuthManager.baseUrl() } returns server.url("/").toString()
|
||||||
|
every { OkHttpProvider.probe } returns OkHttpClient()
|
||||||
|
|
||||||
|
assertNull(DashboardSessionTokenRefresher.refreshAsync())
|
||||||
|
assertNull(DashboardSessionTokenRefresher.refresh())
|
||||||
|
|
||||||
|
assertEquals(2, server.requestCount)
|
||||||
|
verify(exactly = 0) { AuthManager.setToken(any()) }
|
||||||
|
} finally {
|
||||||
|
server.shutdown()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ room = "3.0.2"
|
|||||||
sqlcipher = "4.19.0"
|
sqlcipher = "4.19.0"
|
||||||
datastore = "1.1.2"
|
datastore = "1.1.2"
|
||||||
latex = "1.5.6"
|
latex = "1.5.6"
|
||||||
|
zxingAndroidEmbedded = "4.3.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidxCore" }
|
||||||
@@ -69,6 +70,7 @@ androidx-lifecycle-viewmodel-navigation3 = { module = "androidx.lifecycle:lifecy
|
|||||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||||
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||||
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
okhttp-logging = { module = "com.squareup.okhttp3:logging-interceptor", version.ref = "okhttp" }
|
||||||
|
okhttp-tls = { module = "com.squareup.okhttp3:okhttp-tls", version.ref = "okhttp" }
|
||||||
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
|
||||||
retrofit-converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
|
retrofit-converter-kotlinx-serialization = { module = "com.squareup.retrofit2:converter-kotlinx-serialization", version.ref = "retrofit" }
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.11.0" }
|
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version = "1.11.0" }
|
||||||
@@ -87,6 +89,9 @@ latex-base = { module = "io.github.huarangmeng:latex-base", version.ref = "latex
|
|||||||
latex-parser = { module = "io.github.huarangmeng:latex-parser", version.ref = "latex" }
|
latex-parser = { module = "io.github.huarangmeng:latex-parser", version.ref = "latex" }
|
||||||
latex-renderer = { module = "io.github.huarangmeng:latex-renderer", version.ref = "latex" }
|
latex-renderer = { module = "io.github.huarangmeng:latex-renderer", version.ref = "latex" }
|
||||||
|
|
||||||
|
# QR scanning for AITURK IDE pairing (no Play Services)
|
||||||
|
zxing-android-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingAndroidEmbedded" }
|
||||||
|
|
||||||
# Image loading (Coil 3)
|
# Image loading (Coil 3)
|
||||||
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
|
||||||
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
|
coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" }
|
||||||
|
|||||||
Reference in new issue
Block a user