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:
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user