feat(aiturk): reach the IDE through an end-to-end encrypted tunnel

A phone on the same Wi-Fi could not reach the computer at all (client
isolation on the router), and every failure was shown as one 'same
Wi-Fi' message. Contract v3: the phone only dials turkservis.online.

Transport: a relay pairing lives on the virtual host
<relayId>.relay.aiturk.invalid. For that host only, the shared OkHttp
client resolves a placeholder address, opens a WebSocket byte stream to
the relay instead of a TCP connection, and runs TLS on top of it pinned
to the IDE's certificate fingerprint. The relay sees ciphertext only.
DNS, sockets, proxy choice and TLS are unchanged for every other host.

Pairing: tunnel first (hello within 10 s), LAN candidates second, one
claim only. The local-network permission is requested only when a LAN
address may be tried; denying it no longer ends the pairing.

Errors: host offline, unknown relay, busy, no internet, silent computer,
certificate mismatch and the LAN failures each get their own text plus
a short diagnostic code built from a fixed vocabulary (never a token,
pairing code or address). After pairing the screen shows the first 8
characters of the fingerprint as a security code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8ppceaR5s6cvMnm7uGRnf
This commit is contained in:
yilsemandClaude Opus 5.5 committed 2026-10-05 04:18:52 +03:00
1 parent 9afac6f508
commit cbe716300c
14 files changed
+2469 -138

No files matched your search

@@ -0,0 +1,152 @@
package com.m57.hermescontrol.ui.aiturkpair
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.aiturk.AiturkPairError
import com.m57.hermescontrol.data.aiturk.AiturkPairedDevice
import com.m57.hermescontrol.data.aiturk.AiturkPairingClient
import com.m57.hermescontrol.data.aiturk.AiturkPayloadError
import com.m57.hermescontrol.data.aiturk.AiturkTunnelConnector
import com.m57.hermescontrol.data.aiturk.AiturkTunnelException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.HttpUrl.Companion.toHttpUrl
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.assertTrue
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
import java.util.concurrent.atomic.AtomicInteger
/**
* Contract v3: the local-network permission is asked for only when a LAN
* address may be tried. The coroutine dispatcher is never advanced, so no
* network call runs: these tests look at the decision made synchronously.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class AiturkPairViewModelTest {
private val fp = "A".repeat(43)
private val relayId = "3f2b8c1e-5d4a-4b7c-9e1f-0a1b2c3d4e5f"
private val tunnelOpens = AtomicInteger()
@BeforeEach
fun setUp() {
Dispatchers.setMain(StandardTestDispatcher())
}
@AfterEach
fun tearDown() {
Dispatchers.resetMain()
}
private fun viewModel(): AiturkPairViewModel {
val tunnel =
AiturkTunnelConnector { _, _ ->
tunnelOpens.incrementAndGet()
throw AiturkTunnelException(AiturkTunnelException.NETWORK)
}
return AiturkPairViewModel(
client = AiturkPairingClient(gatewayBaseUrl = "http://127.0.0.1:9/".toHttpUrl(), tunnel = { tunnel }),
register = { _: AiturkPairedDevice -> },
deviceName = "Test phone",
)
}
@Test
fun relayOnlyQrNeverAsksForTheLocalNetworkPermission() {
val vm = viewModel()
vm.onQrScanned("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId", lanPermissionGranted = false)
val state = vm.uiState.value
assertFalse(state.lanPermissionNeeded, "a relay-only pairing must not ask for ACCESS_LOCAL_NETWORK")
assertEquals(AiturkPairStep.CONNECTING, state.step)
assertNull(state.errorRes)
}
@Test
fun relayOnlyDeepLinkNeverAsksEither() {
val vm = viewModel()
vm.onDeepLink("aiturk://pair?v=1&fp=$fp&c=ABCDEFGH&r=$relayId")
assertEquals("turkservis.online", vm.uiState.value.confirmAddress)
vm.confirmDeepLink(lanPermissionGranted = false)
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.CONNECTING, vm.uiState.value.step)
}
@Test
fun qrWithALanAddressStillAsksBeforeConnecting() {
val vm = viewModel()
vm.onQrScanned("aiturk://pair?v=1&u=192.168.1.20:47321&fp=$fp&c=ABCDEFGH&r=$relayId", lanPermissionGranted = false)
assertTrue(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.IDLE, vm.uiState.value.step)
// Denying it no longer ends the pairing: the tunnel is still tried.
vm.onLanPermissionResult(granted = false)
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.CONNECTING, vm.uiState.value.step)
assertNull(vm.uiState.value.errorRes)
}
@Test
fun typedCodeResolvesFirstWithoutAskingForThePermission() {
val vm = viewModel()
vm.onCodeChange("ABCDEFGH")
vm.submitCode(lanPermissionGranted = false)
// Whether a LAN address exists is only known after `resolve`.
assertFalse(vm.uiState.value.lanPermissionNeeded)
assertEquals(AiturkPairStep.RESOLVING, vm.uiState.value.step)
}
@Test
fun everyFailureHasItsOwnText() {
val texts = AiturkPairError.entries.associateWith(AiturkPairViewModel::pairErrorRes)
assertEquals(R.string.aiturk_pair_error_host_offline, texts[AiturkPairError.HOST_OFFLINE])
assertEquals(R.string.aiturk_pair_error_relay_unknown, texts[AiturkPairError.RELAY_UNKNOWN])
assertEquals(R.string.aiturk_pair_error_no_internet, texts[AiturkPairError.NO_INTERNET])
assertEquals(R.string.aiturk_pair_error_relay_busy, texts[AiturkPairError.RELAY_BUSY])
assertEquals(R.string.aiturk_pair_error_host_timeout, texts[AiturkPairError.HOST_TIMEOUT])
assertEquals(R.string.aiturk_pair_error_tls_pin, texts[AiturkPairError.TLS_PIN])
assertEquals(R.string.aiturk_pair_error_pc_unreachable, texts[AiturkPairError.PC_UNREACHABLE])
assertEquals(R.string.aiturk_pair_error_lan_permission, texts[AiturkPairError.LAN_PERMISSION])
assertEquals(R.string.aiturk_pair_error_wrong_code, texts[AiturkPairError.WRONG_CODE])
assertEquals(R.string.aiturk_pair_error_pairing_closed, texts[AiturkPairError.PAIRING_CLOSED])
assertEquals(R.string.aiturk_pair_error_pairing_locked, texts[AiturkPairError.PAIRING_LOCKED])
// The named failures never collapse into one message.
val distinct =
listOf(
AiturkPairError.HOST_OFFLINE,
AiturkPairError.RELAY_UNKNOWN,
AiturkPairError.NO_INTERNET,
AiturkPairError.RELAY_BUSY,
AiturkPairError.HOST_TIMEOUT,
AiturkPairError.TLS_PIN,
AiturkPairError.PC_UNREACHABLE,
AiturkPairError.LAN_PERMISSION,
AiturkPairError.WRONG_CODE,
AiturkPairError.PAIRING_CLOSED,
AiturkPairError.PAIRING_LOCKED,
AiturkPairError.CODE_NOT_FOUND,
AiturkPairError.RATE_LIMITED,
).map { texts.getValue(it) }
assertEquals(distinct.size, distinct.toSet().size)
assertEquals(R.string.aiturk_pair_error_qr_invalid, AiturkPairViewModel.payloadErrorRes(AiturkPayloadError.BAD_RELAY))
}
@Test
fun securityCodeIsTheFirstEightCharactersOfTheFingerprint() {
assertEquals("AbCdEfGh", AiturkPairViewModel.securityCode("AbCdEfGh" + "x".repeat(35)))
assertEquals(0, tunnelOpens.get())
}
}