diff --git a/app/src/main/java/com/gatecontrol/android/ui/MachineBindingText.kt b/app/src/main/java/com/gatecontrol/android/ui/MachineBindingText.kt new file mode 100644 index 00000000..43e9cf37 --- /dev/null +++ b/app/src/main/java/com/gatecontrol/android/ui/MachineBindingText.kt @@ -0,0 +1,16 @@ +package com.gatecontrol.android.ui + +import androidx.annotation.StringRes +import com.gatecontrol.android.R +import com.gatecontrol.android.network.MachineBindingError + +/** User-facing explanation of a machine-binding (Gerätebindung) rejection. */ +@get:StringRes +val MachineBindingError.messageRes: Int + get() = when (this) { + MachineBindingError.MISMATCH -> R.string.binding_mismatch + MachineBindingError.REQUIRED -> R.string.binding_required + MachineBindingError.INVALID -> R.string.binding_invalid + } + +fun MachineBindingError.toUiText(): UiText = UiText.Res(messageRes) diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt index e152caf9..746be1b9 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsScreen.kt @@ -283,6 +283,16 @@ fun SettingsScreen( textAlign = TextAlign.Center, modifier = Modifier.fillMaxWidth().padding(top = 4.dp), ) + if (uiState.deviceIdShort.isNotEmpty()) { + // Matches the server's "Gebunden an Gerät ab12cd34…" for machine binding. + Text( + text = stringResource(R.string.settings_device_id, uiState.deviceIdShort), + style = MaterialTheme.typography.bodySmall.copy(fontFamily = MonoFontFamily), + color = extra.faint, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth(), + ) + } } } diff --git a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt index 27c4420e..a2665773 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/settings/SettingsViewModel.kt @@ -6,6 +6,9 @@ import androidx.lifecycle.viewModelScope import com.gatecontrol.android.R import com.gatecontrol.android.ui.UiText import com.gatecontrol.android.data.LicenseRepository +import com.gatecontrol.android.data.MachineFingerprint +import com.gatecontrol.android.network.MachineBindingError +import com.gatecontrol.android.ui.toUiText import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.data.SplitTunnelJson import com.gatecontrol.android.data.SettingsRepository @@ -68,6 +71,8 @@ data class SettingsUiState( val supportMessage: UiText? = null, /** An admin asked for a support bundle (heartbeat). */ val supportRequested: Boolean = false, + /** First 8 hex chars of the machine fingerprint, as the server shows it. */ + val deviceIdShort: String = "", ) @HiltViewModel @@ -79,6 +84,7 @@ class SettingsViewModel @Inject constructor( private val supportBundleCollector: SupportBundleCollector, private val supportBundleUploader: SupportBundleUploader, private val clientPolicyManager: ClientPolicyManager, + private val machineFingerprint: MachineFingerprint, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -90,6 +96,11 @@ class SettingsViewModel @Inject constructor( } private fun loadInitialState() { + viewModelScope.launch { + val shortId = runCatching { machineFingerprint.shortId() }.getOrDefault("") + _uiState.update { it.copy(deviceIdShort = shortId) } + } + viewModelScope.launch { combine( settingsRepository.getTheme(), @@ -483,7 +494,8 @@ class SettingsViewModel @Inject constructor( } } catch (e: HttpException) { Timber.w("Support bundle upload rejected: HTTP %d", e.code()) - when (e.code()) { + val bindingError = MachineBindingError.from(e) + if (bindingError != null) bindingError.toUiText() else when (e.code()) { 429 -> UiText.Res(R.string.support_rate_limited) 413 -> UiText.Res(R.string.support_too_large) 401, 403 -> UiText.Res(R.string.support_forbidden) @@ -502,6 +514,7 @@ class SettingsViewModel @Inject constructor( private fun supportSettingsSnapshot(state: SettingsUiState): Map = mapOf( "serverUrl" to state.serverUrl, "peerId" to state.peerId, + "deviceId" to state.deviceIdShort, "theme" to state.theme, "locale" to state.locale, "autoConnect" to state.autoConnect, diff --git a/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt index 354521ea..f2d17b74 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/setup/SetupViewModel.kt @@ -5,7 +5,10 @@ import androidx.lifecycle.viewModelScope import com.gatecontrol.android.R import com.gatecontrol.android.ui.UiText import com.gatecontrol.android.common.EnrollmentLink +import com.gatecontrol.android.data.MachineFingerprint import com.gatecontrol.android.data.SetupRepository +import com.gatecontrol.android.network.MachineBindingError +import com.gatecontrol.android.ui.toUiText import com.gatecontrol.android.network.ApiClientProvider import com.gatecontrol.android.service.ClientPolicyManager import com.gatecontrol.android.network.EnrollRequest @@ -60,6 +63,7 @@ class SetupViewModel @Inject constructor( private val apiClientProvider: ApiClientProvider, @ApplicationContext private val context: Context, private val clientPolicyManager: ClientPolicyManager, + private val machineFingerprint: MachineFingerprint, ) : ViewModel() { private val appVersion: String by lazy { @@ -207,10 +211,12 @@ class SetupViewModel @Inject constructor( // Roll back to the previous configuration instead of clearing it setupRepository.save(previousUrl, previousToken, previousPeerId) apiClientProvider.invalidate() + val message = MachineBindingError.from(e)?.toUiText() + ?: UiText.Res(R.string.setup_error, e.localizedMessage ?: "") _uiState.update { it.copy( isLoading = false, - statusMessage = UiText.Res(R.string.setup_error, e.localizedMessage ?: ""), + statusMessage = message, statusType = StatusType.ERROR, ) } @@ -260,6 +266,7 @@ class SetupViewModel @Inject constructor( hostname = android.os.Build.MODEL ?: "android", platform = "android", clientVersion = appVersion, + fingerprint = machineFingerprint.get(), ), ) val token = response.token @@ -345,6 +352,8 @@ class SetupViewModel @Inject constructor( } catch (_: Exception) { "" } + // Binding errors also come from the register call of a token-wizard code. + MachineBindingError.fromResponse(e.code(), body)?.let { return it.toUiText() } ERROR_CODE_RE.find(body)?.groupValues?.get(1).orEmpty() } } else { @@ -355,6 +364,7 @@ class SetupViewModel @Inject constructor( "user_disabled", "user_not_found", "no_valid_scopes" -> R.string.setup_enroll_forbidden "limit_reached" -> R.string.setup_enroll_limit "rate_limited" -> R.string.setup_enroll_rate_limited + "fingerprint_required" -> R.string.binding_required else -> null } return if (res != null) { diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt index 775f5d80..9cd9cfd6 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnScreen.kt @@ -44,6 +44,7 @@ import com.gatecontrol.android.util.openSystemVpnSettings import com.gatecontrol.android.common.Formatters import com.gatecontrol.android.tunnel.TunnelState import com.gatecontrol.android.ui.components.GcBanner +import com.gatecontrol.android.ui.messageRes import com.gatecontrol.android.ui.components.GcCard import com.gatecontrol.android.ui.components.GcIconButton import com.gatecontrol.android.ui.components.GcIcons @@ -257,6 +258,19 @@ fun VpnScreen( } } + // --- Machine binding: the server rejected this device ------------ + val bindingError by viewModel.machineBindingError.collectAsState() + bindingError?.let { err -> + GcBanner(tone = GcTone.Error) { + Text( + text = stringResource(err.messageRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + } + } + // --- Client policy: kill switch / always-on need the system settings com.gatecontrol.android.ui.components.GcPolicySystemVpnCard(clientPolicy) diff --git a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt index 684fbe1d..75da888c 100644 --- a/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt +++ b/app/src/main/java/com/gatecontrol/android/ui/vpn/VpnViewModel.kt @@ -7,6 +7,8 @@ import com.gatecontrol.android.data.LicenseRepository import com.gatecontrol.android.data.SettingsRepository import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.network.ApiClientProvider +import com.gatecontrol.android.network.MachineBindingError +import com.gatecontrol.android.network.MachineBindingMonitor import com.gatecontrol.android.network.PermissionFlags import com.gatecontrol.android.network.TrafficStats import com.gatecontrol.android.network.VpnService @@ -37,8 +39,12 @@ class VpnViewModel @Inject constructor( private val tunnelManager: TunnelManager, private val tunnelConnector: TunnelConnector, private val clientPolicyManager: ClientPolicyManager, + machineBindingMonitor: MachineBindingMonitor, ) : ViewModel() { + /** Last machine-binding rejection of any client API call (null = none). */ + val machineBindingError: StateFlow = machineBindingMonitor.error + /** Client policy from the server (unrestricted until one was fetched). */ val clientPolicy: StateFlow = clientPolicyManager.policy diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 98051866..5997290c 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -333,4 +333,10 @@ Der Server hat das Support-Paket abgelehnt (Token und Gerät prüfen). Der Server unterstützt noch keine Support-Pakete. Erst einen Server einrichten – dann kann ein Support-Paket gesendet werden. + + + Dieser Zugang ist an ein anderes Gerät gebunden. Bitte den Administrator, die Gerätebindung zurückzusetzen. + Der Server verlangt eine Gerätebindung, aber dieses Gerät hat keine Geräte-ID gesendet. Bitte die App aktualisieren oder den Administrator kontaktieren. + Der Server hat die Geräte-ID dieses Geräts abgelehnt. Bitte den Administrator kontaktieren. + Geräte-ID %1$s… diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 21fd42e8..cac137ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -333,4 +333,10 @@ The server rejected the support bundle (check token and device). The server does not support support bundles yet. Set up a server first – then a support bundle can be sent. + + + This access is bound to a different device. Ask your administrator to reset the device binding. + The server requires device binding, but this device sent no device ID. Update the app or contact your administrator. + The server rejected this device\'s ID. Contact your administrator. + Device ID %1$s… diff --git a/app/src/test/java/com/gatecontrol/android/ui/settings/SettingsViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/settings/SettingsViewModelTest.kt index a96dbfef..6de8cbad 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/settings/SettingsViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/settings/SettingsViewModelTest.kt @@ -52,6 +52,9 @@ class SettingsViewModelTest { private lateinit var viewModel: SettingsViewModel private lateinit var supportBundleCollector: SupportBundleCollector private lateinit var supportBundleUploader: SupportBundleUploader + private val machineFingerprint: com.gatecontrol.android.data.MachineFingerprint = mockk { + every { shortId() } returns "ab12cd34" + } @BeforeEach fun setUp() { @@ -87,6 +90,7 @@ class SettingsViewModelTest { setupRepository, settingsRepository, apiClientProvider, licenseRepository, supportBundleCollector, supportBundleUploader, com.gatecontrol.android.service.fakeClientPolicyManager(), + machineFingerprint, ) } @@ -259,6 +263,7 @@ class SettingsViewModelTest { setupRepository, settingsRepository, apiClientProvider, licenseRepository, supportBundleCollector, supportBundleUploader, com.gatecontrol.android.service.fakeClientPolicyManager(policy), + machineFingerprint, ) testDispatcher.scheduler.advanceUntilIdle() @@ -314,11 +319,28 @@ class SettingsViewModelTest { assertFalse(settings.captured.values.any { it == "gc_testtoken" }) assertFalse(settings.captured.keys.any { it.contains("token", ignoreCase = true) }) assertEquals("https://gate.example.com", settings.captured["serverUrl"]) + assertEquals("ab12cd34", settings.captured["deviceId"]) viewModel.consumeSupportMessage() assertNull(viewModel.uiState.value.supportMessage) } + @Test + fun `device id short form is shown`() = runTest { + testDispatcher.scheduler.advanceUntilIdle() + assertEquals("ab12cd34", viewModel.uiState.value.deviceIdShort) + } + + @Test + fun `support bundle - binding mismatch maps to the device binding message`() = runTest { + testDispatcher.scheduler.advanceUntilIdle() + coEvery { supportBundleUploader.upload(any(), any(), any()) } throws HttpException( + Response.error(403, "{\"ok\":false,\"error\":\"Token ist an eine andere Maschine gebunden\"}".toResponseBody(null)), + ) + viewModel.sendSupportBundle("1.5.0") + assertEquals(UiText.Res(R.string.binding_mismatch), awaitSupportResult()) + } + @Test fun `support bundle - 429 maps to rate limited message`() = runTest { testDispatcher.scheduler.advanceUntilIdle() diff --git a/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt index 0e3ad01f..9f9f229a 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/setup/SetupViewModelTest.kt @@ -12,6 +12,7 @@ import com.gatecontrol.android.network.ApiClientProvider import com.gatecontrol.android.network.PingResponse import com.gatecontrol.android.network.RegisterRequest import com.gatecontrol.android.network.RegisterResponse +import okhttp3.ResponseBody.Companion.toResponseBody import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every @@ -42,6 +43,10 @@ class SetupViewModelTest { private lateinit var apiClient: ApiClient private lateinit var context: Context private lateinit var viewModel: SetupViewModel + private val fingerprint = "a".repeat(64) + private val machineFingerprint: com.gatecontrol.android.data.MachineFingerprint = mockk { + every { get() } returns fingerprint + } @BeforeEach fun setUp() { @@ -68,7 +73,7 @@ class SetupViewModelTest { every { context.getString(any(), *anyVararg()) } answers { "res-${firstArg()}" } every { apiClientProvider.invalidate() } returns Unit - viewModel = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager()) + viewModel = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager(), machineFingerprint) } @AfterEach @@ -351,7 +356,7 @@ class SetupViewModelTest { every { setupRepository.isConfigured() } returns true every { setupRepository.hasWireGuardConfig() } returns false - val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager()) + val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager(), machineFingerprint) assertTrue(vm.uiState.value.isSetupComplete) } @@ -361,7 +366,7 @@ class SetupViewModelTest { every { setupRepository.isConfigured() } returns false every { setupRepository.hasWireGuardConfig() } returns true - val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager()) + val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager(), machineFingerprint) assertTrue(vm.uiState.value.isSetupComplete) } @@ -371,7 +376,7 @@ class SetupViewModelTest { every { setupRepository.isConfigured() } returns false every { setupRepository.hasWireGuardConfig() } returns false - val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager()) + val vm = SetupViewModel(setupRepository, apiClientProvider, context, com.gatecontrol.android.service.fakeClientPolicyManager(), machineFingerprint) assertFalse(vm.uiState.value.isSetupComplete) } @@ -448,7 +453,9 @@ class SetupViewModelTest { viewModel.confirmEnrollment() testDispatcher.scheduler.advanceUntilIdle() - coVerify { apiClient.enroll(match { it.code == "AB12-CD34-EF56-7890" && it.platform == "android" }) } + coVerify { + apiClient.enroll(match { it.code == "AB12-CD34-EF56-7890" && it.platform == "android" && it.fingerprint == fingerprint }) + } verify { setupRepository.save("https://gate.example.com", "gc_enrolled", 42) } verify { setupRepository.saveWireGuardConfig(enrollConfig) } verify { setupRepository.saveConfigHash("abc") } @@ -474,6 +481,45 @@ class SetupViewModelTest { assertFalse(state.completedNow) } + private fun httpError(code: Int, body: String) = retrofit2.HttpException( + retrofit2.Response.error( + code, + body.toResponseBody(null), + ), + ) + + @Test + fun `enroll without fingerprint shows the device binding message`() = runTest { + coEvery { apiClient.enroll(any()) } throws httpError(400, "{\"ok\":false,\"error\":\"fingerprint_required\"}") + + viewModel.onEnrollmentLink(link) + viewModel.confirmEnrollment() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals( + com.gatecontrol.android.ui.UiText.Res(com.gatecontrol.android.R.string.binding_required), + viewModel.uiState.value.statusMessage, + ) + } + + @Test + fun `register on a token bound to another device shows the binding mismatch`() = runTest { + coEvery { apiClient.ping() } returns PingResponse(ok = true, version = "1.0", timestamp = "t") + coEvery { apiClient.register(any()) } throws + httpError(403, "{\"ok\":false,\"error\":\"Token is bound to a different machine\"}") + + viewModel.onServerUrlChanged("https://gate.example.com") + viewModel.onApiTokenChanged("gc_bound_token_123456") + viewModel.saveAndRegister() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(StatusType.ERROR, viewModel.uiState.value.statusType) + assertEquals( + com.gatecontrol.android.ui.UiText.Res(com.gatecontrol.android.R.string.binding_mismatch), + viewModel.uiState.value.statusMessage, + ) + } + @Test fun `setup code typed into the token field is redeemed instead of registering`() = runTest { coEvery { apiClient.enroll(any()) } returns EnrollResponse( diff --git a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt index 098c7be1..9a2e15bc 100644 --- a/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt +++ b/app/src/test/java/com/gatecontrol/android/ui/vpn/VpnViewModelTest.kt @@ -84,6 +84,7 @@ class VpnViewModelTest { com.gatecontrol.android.service.fakeClientPolicyManager(), ), clientPolicyManager = com.gatecontrol.android.service.fakeClientPolicyManager(), + machineBindingMonitor = com.gatecontrol.android.network.MachineBindingMonitor(), ) } @@ -262,6 +263,7 @@ class VpnViewModelTest { clientPolicyManager = com.gatecontrol.android.service.fakeClientPolicyManager( com.gatecontrol.android.common.ClientPolicy(autoConnect = com.gatecontrol.android.common.ClientPolicy.AutoConnect.ALWAYS_ON), ), + machineBindingMonitor = com.gatecontrol.android.network.MachineBindingMonitor(), ) vm.disconnect() testDispatcher.scheduler.advanceUntilIdle() diff --git a/core/common/src/main/java/com/gatecontrol/android/common/MachineId.kt b/core/common/src/main/java/com/gatecontrol/android/common/MachineId.kt deleted file mode 100644 index 663e46db..00000000 --- a/core/common/src/main/java/com/gatecontrol/android/common/MachineId.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.gatecontrol.android.common - -import android.content.Context -import android.provider.Settings -import java.security.MessageDigest - -object MachineId { - private var cached: String? = null - - fun getFingerprint(context: Context): String { - cached?.let { return it } - val androidId = Settings.Secure.getString( - context.contentResolver, - Settings.Secure.ANDROID_ID - ) ?: "unknown" - val fingerprint = fingerprintFromString(androidId) - cached = fingerprint - return fingerprint - } - - fun fingerprintFromString(input: String): String { - val digest = MessageDigest.getInstance("SHA-256") - val hashBytes = digest.digest(input.toByteArray()) - return hashBytes.joinToString("") { "%02x".format(it) } - } -} diff --git a/core/common/src/test/java/com/gatecontrol/android/common/MachineIdTest.kt b/core/common/src/test/java/com/gatecontrol/android/common/MachineIdTest.kt deleted file mode 100644 index 5f07321c..00000000 --- a/core/common/src/test/java/com/gatecontrol/android/common/MachineIdTest.kt +++ /dev/null @@ -1,33 +0,0 @@ -package com.gatecontrol.android.common - -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotEquals -import org.junit.jupiter.api.Assertions.assertTrue -import org.junit.jupiter.api.Test - -class MachineIdTest { - - @Test - fun `fingerprint from string produces 64-char hex`() { - val result = MachineId.fingerprintFromString("test-input") - assertTrue( - result.matches(Regex("^[a-f0-9]{64}$")), - "Expected 64-char lowercase hex string but got: $result" - ) - } - - @Test - fun `fingerprint is deterministic`() { - val input = "some-device-id" - val first = MachineId.fingerprintFromString(input) - val second = MachineId.fingerprintFromString(input) - assertEquals(first, second, "Same input should always produce the same fingerprint") - } - - @Test - fun `different inputs produce different fingerprints`() { - val first = MachineId.fingerprintFromString("device-a") - val second = MachineId.fingerprintFromString("device-b") - assertNotEquals(first, second, "Different inputs should produce different fingerprints") - } -} diff --git a/core/data/src/main/java/com/gatecontrol/android/data/EncryptedStorage.kt b/core/data/src/main/java/com/gatecontrol/android/data/EncryptedStorage.kt index d35f2c65..c2176de5 100644 --- a/core/data/src/main/java/com/gatecontrol/android/data/EncryptedStorage.kt +++ b/core/data/src/main/java/com/gatecontrol/android/data/EncryptedStorage.kt @@ -118,9 +118,12 @@ class EncryptedStorage internal constructor( store?.first?.remove(key) } - fun clear() { + /** Remove every value except the [keep] keys (e.g. the device ID). */ + fun clear(keep: Set = emptySet()) { + val kept = keep.mapNotNull { key -> read(key)?.let { key to it } }.toMap() memory.clear() store?.first?.clear() + if (kept.isNotEmpty()) write(kept, sync = true) } private fun write(entries: Map, sync: Boolean) { diff --git a/core/data/src/main/java/com/gatecontrol/android/data/MachineFingerprint.kt b/core/data/src/main/java/com/gatecontrol/android/data/MachineFingerprint.kt new file mode 100644 index 00000000..b705f17f --- /dev/null +++ b/core/data/src/main/java/com/gatecontrol/android/data/MachineFingerprint.kt @@ -0,0 +1,108 @@ +package com.gatecontrol.android.data + +import android.annotation.SuppressLint +import android.content.Context +import android.provider.Settings +import dagger.hilt.android.qualifiers.ApplicationContext +import timber.log.Timber +import java.security.MessageDigest +import java.security.SecureRandom +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Device fingerprint for the server's machine binding (Gerätebindung). + * + * Sent as `X-Machine-Fingerprint` on client API requests and as `fingerprint` + * when a setup code is redeemed. The server stores the first valid value per + * token and rejects requests from any other device afterwards, so the value + * must be stable and unique per device: + * + * - Normally `SHA-256(ANDROID_ID)` as 64 lowercase hex chars. Since Android 8 + * ANDROID_ID is already scoped to the app's signing key, the user and the + * device; it survives reinstalls and changes on a factory reset. The + * derivation is the one the app has sent since the header was introduced, + * so tokens that are already bound keep working after an update. + * - If ANDROID_ID is missing, empty or the known broken emulator/firmware + * value, a random 32-byte ID is generated once and kept in [EncryptedStorage] + * (it survives an app reset in Settings, not a reinstall). + * + * The value is cached in memory and never logged in full. + */ +@Singleton +class MachineFingerprint internal constructor( + private val androidIdProvider: () -> String?, + private val loadFallback: () -> String?, + private val saveFallback: (String) -> Unit, + private val randomBytes: () -> ByteArray, +) { + + @Inject + constructor(@ApplicationContext context: Context, storage: EncryptedStorage) : this( + androidIdProvider = { readAndroidId(context.applicationContext) }, + loadFallback = { storage.getString(FALLBACK_KEY, "").ifEmpty { null } }, + saveFallback = { storage.commitBatch(FALLBACK_KEY to it) }, + randomBytes = { ByteArray(32).also { SecureRandom().nextBytes(it) } }, + ) + + @Volatile + private var cached: String? = null + + /** The 64-char lowercase hex fingerprint. */ + fun get(): String { + cached?.let { return it } + return synchronized(this) { + cached ?: compute().also { + cached = it + Timber.i("Machine fingerprint ready (%s…)", shortForm(it)) + } + } + } + + /** First 8 hex chars — what the server shows as "Gebunden an Gerät ab12cd34…". */ + fun shortId(): String = shortForm(get()) + + private fun compute(): String { + val androidId = try { + androidIdProvider()?.trim() + } catch (e: Exception) { + Timber.w("ANDROID_ID unavailable: %s", e.javaClass.simpleName) + null + } + if (isUsableAndroidId(androidId)) return sha256Hex(androidId!!.toByteArray(Charsets.UTF_8)) + + Timber.w("ANDROID_ID unusable, using the stored random device ID") + val stored = loadFallback()?.takeIf { FALLBACK_RE.matches(it) } + val seed = stored ?: toHex(randomBytes()).also { saveFallback(it) } + return sha256Hex("$FALLBACK_SALT:$seed".toByteArray(Charsets.UTF_8)) + } + + companion object { + /** Storage key of the random fallback ID; kept when the app setup is reset. */ + const val FALLBACK_KEY = "machine_fallback_id" + + /** Value some old devices/emulators return for every installation. */ + internal const val BROKEN_ANDROID_ID = "9774d56d682e549c" + + private const val FALLBACK_SALT = "gatecontrol-android-fallback-v1" + + /** Format the server accepts (`^[a-f0-9]{64}$`). */ + val FORMAT = Regex("^[a-f0-9]{64}$") + + private val FALLBACK_RE = Regex("^[a-f0-9]{64}$") + + fun shortForm(fingerprint: String): String = fingerprint.take(8) + + internal fun isUsableAndroidId(id: String?): Boolean = + !id.isNullOrEmpty() && !id.equals(BROKEN_ANDROID_ID, ignoreCase = true) && id.any { it != '0' } + + internal fun sha256Hex(input: ByteArray): String = + toHex(MessageDigest.getInstance("SHA-256").digest(input)) + + private fun toHex(bytes: ByteArray): String = bytes.joinToString("") { "%02x".format(it) } + + @SuppressLint("HardwareIds") + private fun readAndroidId(context: Context): String? = + Settings.Secure.getString(context.contentResolver, Settings.Secure.ANDROID_ID) + } +} diff --git a/core/data/src/main/java/com/gatecontrol/android/data/SetupRepository.kt b/core/data/src/main/java/com/gatecontrol/android/data/SetupRepository.kt index e5704c07..7f6fdf41 100644 --- a/core/data/src/main/java/com/gatecontrol/android/data/SetupRepository.kt +++ b/core/data/src/main/java/com/gatecontrol/android/data/SetupRepository.kt @@ -55,7 +55,8 @@ class SetupRepository @Inject constructor(private val storage: EncryptedStorage) fun isRegistered(): Boolean = getPeerId() > 0 + /** Reset the setup. The device ID stays so machine binding survives a reset. */ fun clear() { - storage.clear() + storage.clear(keep = setOf(MachineFingerprint.FALLBACK_KEY)) } } diff --git a/core/data/src/test/java/com/gatecontrol/android/data/EncryptedStorageTest.kt b/core/data/src/test/java/com/gatecontrol/android/data/EncryptedStorageTest.kt index 79d0a75a..f403c70e 100644 --- a/core/data/src/test/java/com/gatecontrol/android/data/EncryptedStorageTest.kt +++ b/core/data/src/test/java/com/gatecontrol/android/data/EncryptedStorageTest.kt @@ -100,4 +100,16 @@ class EncryptedStorageTest { assertTrue(backend.data.isEmpty()) assertEquals("", s.getString("a", "")) } + + @Test + fun `clear keeps the requested keys`() { + val backend = MapBackend() + val s = storage(backend) + s.putString("token", "gc_secret") + s.putString("device", "abc") + s.clear(keep = setOf("device", "missing")) + assertEquals("", s.getString("token", "")) + assertEquals("abc", s.getString("device", "")) + assertEquals(1, backend.data.size) + } } diff --git a/core/data/src/test/java/com/gatecontrol/android/data/MachineFingerprintTest.kt b/core/data/src/test/java/com/gatecontrol/android/data/MachineFingerprintTest.kt new file mode 100644 index 00000000..43ffb70f --- /dev/null +++ b/core/data/src/test/java/com/gatecontrol/android/data/MachineFingerprintTest.kt @@ -0,0 +1,106 @@ +package com.gatecontrol.android.data + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class MachineFingerprintTest { + + private var stored: String? = null + private var randomCalls = 0 + private var androidIdCalls = 0 + + private fun provider(androidId: String?, random: ByteArray = ByteArray(32) { 7 }) = MachineFingerprint( + androidIdProvider = { androidIdCalls++; androidId }, + loadFallback = { stored }, + saveFallback = { stored = it }, + randomBytes = { randomCalls++; random }, + ) + + @Test + fun `fingerprint is 64 lowercase hex chars`() { + val fp = provider("a1b2c3d4e5f60718").get() + assertTrue(MachineFingerprint.FORMAT.matches(fp), fp) + } + + @Test + fun `fingerprint is sha256 of ANDROID_ID so existing bindings keep working`() { + // sha256("a1b2c3d4e5f60718") — the value app versions before this change sent. + val expected = MachineFingerprint.sha256Hex("a1b2c3d4e5f60718".toByteArray()) + assertEquals(expected, provider("a1b2c3d4e5f60718").get()) + assertEquals( + "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08", + MachineFingerprint.sha256Hex("test".toByteArray()), + ) + } + + @Test + fun `fingerprint is stable and cached`() { + val p = provider("a1b2c3d4e5f60718") + val first = p.get() + assertEquals(first, p.get()) + assertEquals(1, androidIdCalls) + assertEquals(first, provider("a1b2c3d4e5f60718").get()) + assertEquals(first.take(8), p.shortId()) + } + + @Test + fun `different devices get different fingerprints`() { + assertNotEquals(provider("a1b2c3d4e5f60718").get(), provider("0011223344556677").get()) + } + + @Test + fun `invalid ANDROID_ID falls back to a stored random id`() { + listOf(null, "", " ", "9774d56d682e549c", "9774D56D682E549C", "0000000000000000").forEach { + stored = null + randomCalls = 0 + assertFallback(it) + } + } + + private fun assertFallback(androidId: String?) { + val fp = provider(androidId).get() + assertTrue(MachineFingerprint.FORMAT.matches(fp)) + assertEquals(1, randomCalls) + assertEquals("07".repeat(32), stored) + // Never the hash of the broken value itself (shared by many devices). + assertNotEquals(MachineFingerprint.sha256Hex((androidId ?: "").toByteArray()), fp) + assertNotEquals(MachineFingerprint.sha256Hex("unknown".toByteArray()), fp) + } + + @Test + fun `fallback id is reused across instances`() { + val first = provider(null).get() + val second = provider(null, random = ByteArray(32) { 9 }).get() + assertEquals(first, second) + assertEquals(1, randomCalls) + } + + @Test + fun `devices without ANDROID_ID do not share a fingerprint`() { + val a = provider(null, random = ByteArray(32) { 1 }).get() + stored = null + val b = provider(null, random = ByteArray(32) { 2 }).get() + assertNotEquals(a, b) + } + + @Test + fun `corrupt stored fallback is replaced`() { + stored = "not-hex" + provider(null).get() + assertEquals("07".repeat(32), stored) + } + + @Test + fun `ANDROID_ID failure falls back`() { + val p = MachineFingerprint( + androidIdProvider = { throw SecurityException("nope") }, + loadFallback = { stored }, + saveFallback = { stored = it }, + randomBytes = { ByteArray(32) { 3 } }, + ) + assertTrue(MachineFingerprint.FORMAT.matches(p.get())) + assertEquals("03".repeat(32), stored) + } +} diff --git a/core/data/src/test/java/com/gatecontrol/android/data/SetupRepositoryTest.kt b/core/data/src/test/java/com/gatecontrol/android/data/SetupRepositoryTest.kt index 28e6ad78..e87aa2be 100644 --- a/core/data/src/test/java/com/gatecontrol/android/data/SetupRepositoryTest.kt +++ b/core/data/src/test/java/com/gatecontrol/android/data/SetupRepositoryTest.kt @@ -96,6 +96,6 @@ class SetupRepositoryTest { fun `clear delegates to storage clear`() { repository.clear() - verify { storage.clear() } + verify { storage.clear(keep = setOf(MachineFingerprint.FALLBACK_KEY)) } } } diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt index 29133ec5..56bcc208 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiClientProvider.kt @@ -1,6 +1,7 @@ package com.gatecontrol.android.network import com.gatecontrol.android.common.VpnSubnet +import com.gatecontrol.android.data.MachineFingerprint import android.content.Context import android.content.pm.ApplicationInfo import com.google.gson.Gson @@ -13,6 +14,7 @@ import com.google.gson.stream.JsonToken import com.google.gson.stream.JsonWriter import dagger.hilt.android.qualifiers.ApplicationContext import okhttp3.Dns +import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -26,7 +28,9 @@ import javax.inject.Singleton @Singleton class ApiClientProvider @Inject constructor( private val authInterceptor: AuthInterceptor, - @ApplicationContext private val context: Context + @ApplicationContext private val context: Context, + private val machineFingerprint: MachineFingerprint, + private val machineBindingMonitor: MachineBindingMonitor, ) { private val cache = mutableMapOf() private val lock = Any() @@ -148,6 +152,23 @@ class ApiClientProvider @Inject constructor( } private fun buildClient(baseUrl: String): ApiClient { + val okHttpClient = buildOkHttpClient(baseUrl) + + // Gson that tolerates SQLite boolean fields (0/1 as NUMBER instead of true/false) + // Uses TypeAdapterFactory to cover both Boolean and Boolean? (nullable) fields + val gson = GsonBuilder() + .registerTypeAdapterFactory(LenientBooleanAdapterFactory()) + .create() + + return Retrofit.Builder() + .baseUrl(baseUrl) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create(gson)) + .build() + .create(ApiClient::class.java) + } + + internal fun buildOkHttpClient(baseUrl: String): OkHttpClient { val isDebuggable = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 // Never log bodies: register/enroll responses carry the WireGuard // private key and API tokens. Debug builds log headers with all @@ -157,27 +178,18 @@ class ApiClientProvider @Inject constructor( SENSITIVE_HEADERS.forEach { redactHeader(it) } } - val okHttpClient = OkHttpClient.Builder() + return OkHttpClient.Builder() .dns(vpnSafeDns) .addInterceptor(authInterceptor) + .addInterceptor(machineBindingMonitor) .addInterceptor(logging) + // Network interceptor: runs per hop, so a redirect to another + // host never carries the API token or the device fingerprint. + .addNetworkInterceptor(ServerScopedHeadersInterceptor(baseUrl.toHttpUrl()) { machineFingerprint.get() }) .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) .build() - - // Gson that tolerates SQLite boolean fields (0/1 as NUMBER instead of true/false) - // Uses TypeAdapterFactory to cover both Boolean and Boolean? (nullable) fields - val gson = GsonBuilder() - .registerTypeAdapterFactory(LenientBooleanAdapterFactory()) - .create() - - return Retrofit.Builder() - .baseUrl(baseUrl) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create(gson)) - .build() - .create(ApiClient::class.java) } /** Factory that applies LenientBooleanAdapter to both Boolean and Boolean? fields. */ diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt b/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt index ef61741e..11841466 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/ApiModels.kt @@ -215,6 +215,8 @@ data class EnrollRequest( val hostname: String, val platform: String, val clientVersion: String, + /** Device fingerprint for machine binding (also sent as X-Machine-Fingerprint). */ + val fingerprint: String, ) data class EnrollResponse( diff --git a/core/network/src/main/java/com/gatecontrol/android/network/AuthInterceptor.kt b/core/network/src/main/java/com/gatecontrol/android/network/AuthInterceptor.kt index f8df02d4..3b6ee61b 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/AuthInterceptor.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/AuthInterceptor.kt @@ -7,7 +7,6 @@ class AuthInterceptor( private val tokenProvider: () -> String, private val versionProvider: () -> String, private val platformProvider: () -> String, - private val fingerprintProvider: () -> String ) : Interceptor { override fun intercept(chain: Interceptor.Chain): Response { @@ -23,10 +22,8 @@ class AuthInterceptor( requestBuilder.header("X-Client-Version", sanitizeHeaderValue(versionProvider())) requestBuilder.header("X-Client-Platform", sanitizeHeaderValue(platformProvider())) - val fingerprint = sanitizeHeaderValue(fingerprintProvider()) - if (fingerprint.isNotEmpty()) { - requestBuilder.header("X-Machine-Fingerprint", fingerprint) - } + // ServerScopedHeadersInterceptor adds X-Machine-Fingerprint and strips the + // token from any hop that leaves the configured server (redirects). return chain.proceed(requestBuilder.build()) } diff --git a/core/network/src/main/java/com/gatecontrol/android/network/MachineBinding.kt b/core/network/src/main/java/com/gatecontrol/android/network/MachineBinding.kt new file mode 100644 index 00000000..bc1c493d --- /dev/null +++ b/core/network/src/main/java/com/gatecontrol/android/network/MachineBinding.kt @@ -0,0 +1,115 @@ +package com.gatecontrol.android.network + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import okhttp3.Interceptor +import okhttp3.Response +import retrofit2.HttpException +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** Rejections of the server's machine binding (Gerätebindung). */ +enum class MachineBindingError { + /** The token is bound to another device (403). */ + MISMATCH, + + /** Binding is active but the request carried no fingerprint (403, enroll 400). */ + REQUIRED, + + /** The fingerprint was malformed (400). */ + INVALID; + + companion object { + // The server answers with a translated message (en/de) on the client + // API and with an error code on enroll — match both. + private val MISMATCH_RE = Regex( + "binding_mismatch|bound to a different machine|an eine andere Maschine gebunden", + RegexOption.IGNORE_CASE, + ) + private val REQUIRED_RE = Regex( + "fingerprint_required|machine fingerprint required|Fingerprint erforderlich", + RegexOption.IGNORE_CASE, + ) + private val INVALID_RE = Regex( + "fingerprint_invalid|invalid machine fingerprint|valid machine fingerprint required|Fingerprint-Format", + RegexOption.IGNORE_CASE, + ) + + /** Classify an HTTP error response; null when it is not a binding rejection. */ + fun fromResponse(code: Int, body: String?): MachineBindingError? { + if (code != 400 && code != 403 || body.isNullOrEmpty()) return null + return when { + MISMATCH_RE.containsMatchIn(body) -> MISMATCH + REQUIRED_RE.containsMatchIn(body) -> REQUIRED + INVALID_RE.containsMatchIn(body) -> INVALID + else -> null + } + } + + /** + * Classify a Retrofit [HttpException]. Reads (and so consumes) the + * error body — callers that also need the body use [fromResponse]. + */ + fun from(e: Throwable): MachineBindingError? { + if (e !is HttpException) return null + val body = try { + e.response()?.errorBody()?.string() + } catch (_: Exception) { + null + } + return fromResponse(e.code(), body) + } + } +} + +/** + * Watches client API responses for machine-binding rejections so the UI can + * explain them, whichever request hit them first (most run in the + * background). Cleared again by the next successful request to an endpoint + * that checks the binding — e.g. after the admin reset it. + */ +@Singleton +class MachineBindingMonitor @Inject constructor() : Interceptor { + + private val _error = MutableStateFlow(null) + val error: StateFlow = _error.asStateFlow() + + override fun intercept(chain: Interceptor.Chain): Response { + val response = chain.proceed(chain.request()) + val path = response.request.url.encodedPath + if (!path.contains(CLIENT_API)) return response + val code = response.code + if (code == 400 || code == 403) { + val body = try { + response.peekBody(MAX_PEEK).string() + } catch (_: Exception) { + null + } + MachineBindingError.fromResponse(code, body)?.let { err -> + if (_error.value != err) Timber.w("Machine binding rejected the request: %s", err) + _error.value = err + } + } else if (code in 200..399 && CHECKED_ENDPOINTS.any { path.endsWith(it) }) { + _error.value = null + } + return response + } + + fun clear() { + _error.value = null + } + + private companion object { + const val CLIENT_API = "/api/v1/client/" + const val MAX_PEEK = 4096L + + /** Endpoints that pass the server's binding check when they succeed. */ + val CHECKED_ENDPOINTS = listOf( + "/client/heartbeat", "/client/status", "/client/peer-info", "/client/config", + "/client/config/check", "/client/traffic", "/client/policy", "/client/support-bundle", + "/client/register", "/client/enroll", + ) + } +} diff --git a/core/network/src/main/java/com/gatecontrol/android/network/ServerScopedHeadersInterceptor.kt b/core/network/src/main/java/com/gatecontrol/android/network/ServerScopedHeadersInterceptor.kt new file mode 100644 index 00000000..8ac4306d --- /dev/null +++ b/core/network/src/main/java/com/gatecontrol/android/network/ServerScopedHeadersInterceptor.kt @@ -0,0 +1,49 @@ +package com.gatecontrol.android.network + +import okhttp3.HttpUrl +import okhttp3.Interceptor +import okhttp3.Response + +/** + * Keeps credentials and the device fingerprint on the configured server. + * + * Registered as a *network* interceptor on the client of one GateControl + * server, so it sees every hop including redirects. OkHttp itself only strips + * `Authorization` on a cross-host redirect, not custom headers — so for any + * request that ends up at another scheme/host/port this removes + * `X-API-Token` (added by [AuthInterceptor] or explicitly by a connection + * test) and never adds `X-Machine-Fingerprint`. Requests to the server get + * the fingerprint for its machine binding. + */ +class ServerScopedHeadersInterceptor( + private val serverUrl: HttpUrl, + private val fingerprintProvider: () -> String, +) : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val builder = request.newBuilder().removeHeader(FINGERPRINT_HEADER) + if (isServer(request.url)) { + val fingerprint = try { + fingerprintProvider() + } catch (_: Exception) { + "" + } + if (FINGERPRINT_RE.matches(fingerprint)) builder.header(FINGERPRINT_HEADER, fingerprint) + } else { + builder.removeHeader(TOKEN_HEADER) + } + return chain.proceed(builder.build()) + } + + private fun isServer(url: HttpUrl): Boolean = + url.scheme == serverUrl.scheme && + url.host.equals(serverUrl.host, ignoreCase = true) && + url.port == serverUrl.port + + companion object { + const val FINGERPRINT_HEADER = "X-Machine-Fingerprint" + const val TOKEN_HEADER = "X-API-Token" + private val FINGERPRINT_RE = Regex("^[a-f0-9]{64}$") + } +} diff --git a/core/network/src/main/java/com/gatecontrol/android/network/di/NetworkModule.kt b/core/network/src/main/java/com/gatecontrol/android/network/di/NetworkModule.kt index 4dc60f14..69bf5759 100644 --- a/core/network/src/main/java/com/gatecontrol/android/network/di/NetworkModule.kt +++ b/core/network/src/main/java/com/gatecontrol/android/network/di/NetworkModule.kt @@ -1,10 +1,11 @@ package com.gatecontrol.android.network.di import android.content.Context -import com.gatecontrol.android.common.MachineId +import com.gatecontrol.android.data.MachineFingerprint import com.gatecontrol.android.data.SetupRepository import com.gatecontrol.android.network.ApiClientProvider import com.gatecontrol.android.network.AuthInterceptor +import com.gatecontrol.android.network.MachineBindingMonitor import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -33,13 +34,14 @@ object NetworkModule { } }, platformProvider = { "android" }, - fingerprintProvider = { MachineId.getFingerprint(context) } ) @Provides @Singleton fun provideApiClientProvider( authInterceptor: AuthInterceptor, - @ApplicationContext context: Context - ): ApiClientProvider = ApiClientProvider(authInterceptor, context) + @ApplicationContext context: Context, + machineFingerprint: MachineFingerprint, + machineBindingMonitor: MachineBindingMonitor, + ): ApiClientProvider = ApiClientProvider(authInterceptor, context, machineFingerprint, machineBindingMonitor) } diff --git a/core/network/src/test/java/com/gatecontrol/android/network/AuthInterceptorTest.kt b/core/network/src/test/java/com/gatecontrol/android/network/AuthInterceptorTest.kt index d8042f45..f641fd10 100644 --- a/core/network/src/test/java/com/gatecontrol/android/network/AuthInterceptorTest.kt +++ b/core/network/src/test/java/com/gatecontrol/android/network/AuthInterceptorTest.kt @@ -30,13 +30,11 @@ class AuthInterceptorTest { token: String = "test-token", version: String = "1.0.0", platform: String = "android", - fingerprint: String = "fp-abc123" ): OkHttpClient { val interceptor = AuthInterceptor( tokenProvider = { token }, versionProvider = { version }, platformProvider = { platform }, - fingerprintProvider = { fingerprint } ) return OkHttpClient.Builder() .addInterceptor(interceptor) @@ -44,14 +42,13 @@ class AuthInterceptorTest { } @Test - fun `adds all required headers when token and fingerprint are non-empty`() { + fun `adds all required headers when token is non-empty`() { server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) val httpClient = buildClient( token = "my-secret-token", version = "2.3.1", platform = "android", - fingerprint = "device-fp-xyz" ) val request = Request.Builder() @@ -64,7 +61,6 @@ class AuthInterceptorTest { assertEquals("my-secret-token", recorded.getHeader("X-API-Token")) assertEquals("2.3.1", recorded.getHeader("X-Client-Version")) assertEquals("android", recorded.getHeader("X-Client-Platform")) - assertEquals("device-fp-xyz", recorded.getHeader("X-Machine-Fingerprint")) } @Test @@ -102,21 +98,4 @@ class AuthInterceptorTest { assertEquals("1.0.0", recorded.getHeader("X-Client-Version")) assertEquals("android", recorded.getHeader("X-Client-Platform")) } - - @Test - fun `skips fingerprint header when fingerprint is empty`() { - server.enqueue(MockResponse().setResponseCode(200).setBody("{}")) - - val httpClient = buildClient(fingerprint = "") - - val request = Request.Builder() - .url(server.url("/test")) - .build() - - httpClient.newCall(request).execute().use { } - - val recorded = server.takeRequest() - assertNull(recorded.getHeader("X-Machine-Fingerprint")) - assertEquals("test-token", recorded.getHeader("X-API-Token")) - } } diff --git a/core/network/src/test/java/com/gatecontrol/android/network/MachineFingerprintRequestTest.kt b/core/network/src/test/java/com/gatecontrol/android/network/MachineFingerprintRequestTest.kt new file mode 100644 index 00000000..e3ef86c5 --- /dev/null +++ b/core/network/src/test/java/com/gatecontrol/android/network/MachineFingerprintRequestTest.kt @@ -0,0 +1,186 @@ +package com.gatecontrol.android.network + +import android.content.Context +import android.content.pm.ApplicationInfo +import com.gatecontrol.android.data.MachineFingerprint +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.runTest +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import com.google.gson.JsonParser +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.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +/** The real client wiring of [ApiClientProvider]: fingerprint header, enroll body, binding errors. */ +class MachineFingerprintRequestTest { + + private val fingerprint = "0123456789abcdef".repeat(4) + private lateinit var server: MockWebServer + private lateinit var foreign: MockWebServer + private lateinit var monitor: MachineBindingMonitor + private lateinit var provider: ApiClientProvider + + @BeforeEach + fun setUp() { + server = MockWebServer().apply { start() } + foreign = MockWebServer().apply { start() } + monitor = MachineBindingMonitor() + val context = mockk { + every { applicationInfo } returns mockk(relaxed = true) + } + val machineFingerprint = mockk { every { get() } returns fingerprint } + val auth = AuthInterceptor({ "gc_token" }, { "1.0.0" }, { "android" }) + provider = ApiClientProvider(auth, context, machineFingerprint, monitor) + } + + @AfterEach + fun tearDown() { + server.shutdown() + foreign.shutdown() + } + + private fun ok(body: String = "{\"ok\":true}") = MockResponse().setResponseCode(200).setBody(body) + + @Test + fun `client API calls carry the fingerprint header`() = runTest { + val api = provider.getClient(server.url("/").toString()) + server.enqueue(ok("{\"ok\":true,\"version\":\"1\",\"timestamp\":\"t\"}")) + server.enqueue(ok("{\"ok\":true}")) + server.enqueue(ok("{\"ok\":true}")) + server.enqueue(ok("{\"ok\":true}")) + server.enqueue(ok("{\"ok\":true}")) + server.enqueue(ok("{\"ok\":true,\"available\":false}")) + + api.ping() + runCatching { api.getTraffic(1) } + runCatching { api.sendHeartbeat(HeartbeatRequest(1, true, 0, 0, 0, "pixel")) } + runCatching { api.getClientPolicy() } + runCatching { api.uploadSupportBundle(1, "x".toRequestBody()) } + runCatching { api.checkUpdate("1.0.0") } + + repeat(6) { + val recorded = server.takeRequest() + assertEquals(fingerprint, recorded.getHeader("X-Machine-Fingerprint"), recorded.path) + assertEquals("gc_token", recorded.getHeader("X-API-Token")) + } + } + + @Test + fun `enroll sends the fingerprint in body and header`() = runTest { + server.enqueue(ok("{\"ok\":true,\"token\":\"gc_new\",\"peerId\":3}")) + provider.getClient(server.url("/").toString()).enroll( + EnrollRequest("AB12-CD34-EF56-7890", "pixel", "android", "1.0.0", fingerprint), + ) + val recorded = server.takeRequest() + assertEquals("/api/v1/client/enroll", recorded.path) + assertEquals(fingerprint, JsonParser.parseString(recorded.body.readUtf8()).asJsonObject.get("fingerprint").asString) + assertEquals(fingerprint, recorded.getHeader("X-Machine-Fingerprint")) + } + + @Test + fun `redirect to a foreign host does not carry the fingerprint`() { + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", foreign.url("/elsewhere"))) + foreign.enqueue(ok()) + + val http = provider.buildOkHttpClient(server.url("/").toString()) + http.newCall(Request.Builder().url(server.url("/api/v1/client/ping")).build()).execute().close() + + assertEquals(fingerprint, server.takeRequest().getHeader("X-Machine-Fingerprint")) + assertNull(foreign.takeRequest().getHeader("X-Machine-Fingerprint")) + } + + @Test + fun `request to another host on the same client gets no fingerprint`() { + foreign.enqueue(ok()) + val http = provider.buildOkHttpClient(server.url("/").toString()) + http.newCall( + Request.Builder().url(foreign.url("/x")).header("X-Machine-Fingerprint", fingerprint).build(), + ).execute().close() + assertNull(foreign.takeRequest().getHeader("X-Machine-Fingerprint")) + } + + @Test + fun `api token goes to the server but not across a redirect to a foreign host`() { + server.enqueue(ok()) + server.enqueue(MockResponse().setResponseCode(302).setHeader("Location", foreign.url("/steal"))) + foreign.enqueue(ok()) + + val http = provider.buildOkHttpClient(server.url("/").toString()) + http.newCall(Request.Builder().url(server.url("/api/v1/client/ping")).build()).execute().close() + http.newCall(Request.Builder().url(server.url("/api/v1/client/traffic")).build()).execute().close() + + assertEquals("gc_token", server.takeRequest().getHeader("X-API-Token")) + assertEquals("gc_token", server.takeRequest().getHeader("X-API-Token")) + val leaked = foreign.takeRequest() + assertEquals("/steal", leaked.path) + assertNull(leaked.getHeader("X-API-Token")) + assertNull(leaked.getHeader("X-Machine-Fingerprint")) + } + + @Test + fun `explicit test token is stripped on a foreign host too`() { + server.enqueue(MockResponse().setResponseCode(307).setHeader("Location", foreign.url("/x"))) + foreign.enqueue(ok()) + val http = provider.buildOkHttpClient(server.url("/").toString()) + http.newCall( + Request.Builder().url(server.url("/api/v1/client/ping")).header("X-API-Token", "typed").build(), + ).execute().close() + assertEquals("typed", server.takeRequest().getHeader("X-API-Token")) + assertNull(foreign.takeRequest().getHeader("X-API-Token")) + } + + @Test + fun `same host on a different port counts as foreign`() { + val http = provider.buildOkHttpClient(server.url("/").toString()) + foreign.enqueue(ok()) + // server and foreign are both localhost — only the port differs. + http.newCall(Request.Builder().url(foreign.url("/x")).build()).execute().close() + val recorded = foreign.takeRequest() + assertNull(recorded.getHeader("X-API-Token")) + assertNull(recorded.getHeader("X-Machine-Fingerprint")) + } + + @Test + fun `binding rejection is reported and cleared by the next success`() = runTest { + val api = provider.getClient(server.url("/").toString()) + server.enqueue( + MockResponse().setResponseCode(403) + .setBody("{\"ok\":false,\"error\":\"Token is bound to a different machine\"}"), + ) + val err = runCatching { api.getTraffic(1) }.exceptionOrNull() + assertTrue(err is retrofit2.HttpException) + assertEquals(MachineBindingError.MISMATCH, monitor.error.value) + // The body is still readable for the caller. + assertEquals(MachineBindingError.MISMATCH, MachineBindingError.from(err!!)) + + server.enqueue(ok("{\"ok\":true}")) + runCatching { api.getTraffic(1) } + assertNull(monitor.error.value) + } + + @Test + fun `other 403s are not binding errors`() = runTest { + server.enqueue(MockResponse().setResponseCode(403).setBody("{\"ok\":false,\"error\":\"Forbidden\"}")) + runCatching { provider.getClient(server.url("/").toString()).getTraffic(1) } + assertNull(monitor.error.value) + } + + @Test + fun `classifier matches english, german and enroll codes`() { + assertEquals(MachineBindingError.MISMATCH, MachineBindingError.fromResponse(403, "{\"error\":\"Token ist an eine andere Maschine gebunden\"}")) + assertEquals(MachineBindingError.REQUIRED, MachineBindingError.fromResponse(403, "{\"error\":\"Machine fingerprint required\"}")) + assertEquals(MachineBindingError.REQUIRED, MachineBindingError.fromResponse(403, "{\"error\":\"Maschinen-Fingerprint erforderlich\"}")) + assertEquals(MachineBindingError.REQUIRED, MachineBindingError.fromResponse(400, "{\"ok\":false,\"error\":\"fingerprint_required\"}")) + assertEquals(MachineBindingError.INVALID, MachineBindingError.fromResponse(400, "{\"error\":\"Invalid machine fingerprint format\"}")) + assertEquals(MachineBindingError.INVALID, MachineBindingError.fromResponse(400, "{\"error\":\"Ungültiges Fingerprint-Format\"}")) + assertNull(MachineBindingError.fromResponse(500, "bound to a different machine")) + assertNull(MachineBindingError.fromResponse(403, null)) + } +}