From 7c90da222cc5035a1ce1cde6f397e07e5bb57187 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Mon, 24 Aug 2026 14:38:49 +0700 Subject: [PATCH] fix(security): credential wipe on endpoint change, PairingProbeState gating, and parser hardening --- .../core/pairing/HermesPairingParser.kt | 47 ++++- .../mobile/core/pairing/PairingModels.kt | 29 ++- .../mobile/feature/hosts/HostsScreen.kt | 5 + .../mobile/feature/hosts/HostsViewModel.kt | 5 + .../feature/hosts/PairingPreviewDialog.kt | 94 +++++++-- .../core/network/JsonRpcGatewayClientTest.kt | 7 +- .../core/pairing/HermesPairingParserTest.kt | 199 +++++++++--------- .../mobile/feature/hosts/HostsPairingTest.kt | 41 +++- 8 files changed, 305 insertions(+), 122 deletions(-) diff --git a/app/src/main/java/app/hermes/mobile/core/pairing/HermesPairingParser.kt b/app/src/main/java/app/hermes/mobile/core/pairing/HermesPairingParser.kt index 924295e..2285552 100644 --- a/app/src/main/java/app/hermes/mobile/core/pairing/HermesPairingParser.kt +++ b/app/src/main/java/app/hermes/mobile/core/pairing/HermesPairingParser.kt @@ -11,6 +11,10 @@ object HermesPairingParser { fun parse(rawUri: String): PairingValidationResult { try { + if (rawUri.toByteArray(Charsets.UTF_8).size > 4096) { + return PairingValidationResult.InvalidPayload("URI exceeds maximum length of 4096 bytes") + } + val uri = URI(rawUri) if (uri.scheme != "hermes" || uri.host != "pair") { return PairingValidationResult.InvalidPayload("Invalid scheme or host") @@ -29,6 +33,10 @@ object HermesPairingParser { return PairingValidationResult.InvalidPayload("Malformed Base64") } + if (decodedBytes.size > 2048) { + return PairingValidationResult.InvalidPayload("Decoded payload exceeds 2048 bytes") + } + val jsonString = String(decodedBytes, Charsets.UTF_8) val payload = try { json.decodeFromString(jsonString) @@ -43,13 +51,32 @@ object HermesPairingParser { return PairingValidationResult.InvalidPayload("Invalid type") } try { - UUID.fromString(payload.hostId) + val uuid = UUID.fromString(payload.hostId) + if (uuid.toString() != payload.hostId.lowercase()) { + return PairingValidationResult.InvalidPayload("host_id must be a valid UUID string") + } } catch (e: IllegalArgumentException) { return PairingValidationResult.InvalidPayload("Invalid host_id") } + + if (payload.name.isBlank()) { + return PairingValidationResult.InvalidPayload("Name is empty") + } + val trimmedName = payload.name.trim() + if (trimmedName.length > 128) { + return PairingValidationResult.InvalidPayload("Name exceeds 128 characters") + } + if (payload.name.any { it in '\u0000'..'\u001F' || it in '\u007F'..'\u009F' }) { + return PairingValidationResult.InvalidPayload("Name contains control characters") + } + if (payload.host.isBlank()) { return PairingValidationResult.InvalidPayload("Host is empty") } + if (payload.host.any { it.isWhitespace() || it == '/' || it == '\\' || it == '?' || it == '#' || it == '@' || it == ':' || it in '\u0000'..'\u001F' || it in '\u007F'..'\u009F' }) { + return PairingValidationResult.InvalidPayload("Host contains invalid characters") + } + if (payload.port !in 1..65535) { return PairingValidationResult.InvalidPayload("Invalid port") } @@ -57,9 +84,25 @@ object HermesPairingParser { return PairingValidationResult.InvalidScheme("Scheme must be http or https") } - if (payload.expiresAt <= System.currentTimeMillis() / 1000) { + if (payload.nonce.isBlank()) { + return PairingValidationResult.InvalidPayload("Nonce is empty") + } + val nonceBytes = try { + Base64.getUrlDecoder().decode(payload.nonce) + } catch (e: IllegalArgumentException) { + return PairingValidationResult.InvalidPayload("Nonce must be valid Base64URL") + } + if (nonceBytes.size < 16 || nonceBytes.size > 64) { + return PairingValidationResult.InvalidPayload("Nonce must decode to between 16 and 64 bytes") + } + + val now = System.currentTimeMillis() / 1000 + if (payload.expiresAt < now - 30) { return PairingValidationResult.Expired(payload.expiresAt) } + if (payload.expiresAt > now + 600) { + return PairingValidationResult.InvalidPayload("Expiry exceeds maximum TTL of 600 seconds") + } return PairingValidationResult.Success(payload) } catch (e: Exception) { diff --git a/app/src/main/java/app/hermes/mobile/core/pairing/PairingModels.kt b/app/src/main/java/app/hermes/mobile/core/pairing/PairingModels.kt index 3225e3c..1c5360c 100644 --- a/app/src/main/java/app/hermes/mobile/core/pairing/PairingModels.kt +++ b/app/src/main/java/app/hermes/mobile/core/pairing/PairingModels.kt @@ -14,7 +14,34 @@ data class HermesPairingPayload( val scheme: String = "http", @SerialName("expires_at") val expiresAt: Long, val nonce: String -) +) { + val canonicalEndpoint: CanonicalEndpoint + get() = CanonicalEndpoint( + scheme = scheme.lowercase(), + host = host.lowercase(), + port = if (port == 0) { + if (scheme.lowercase() == "https") 443 else 80 + } else port + ) +} + +data class CanonicalEndpoint(val scheme: String, val host: String, val port: Int) { + companion object { + fun fromBaseUrl(baseUrl: String): CanonicalEndpoint { + return try { + val uri = java.net.URI(baseUrl) + val scheme = uri.scheme?.lowercase() ?: "http" + val host = uri.host?.lowercase() ?: "" + val port = if (uri.port == -1 || uri.port == 0) { + if (scheme == "https") 443 else 80 + } else uri.port + CanonicalEndpoint(scheme, host, port) + } catch (e: Exception) { + CanonicalEndpoint("http", "", 80) + } + } + } +} sealed interface PairingValidationResult { data class Success(val payload: HermesPairingPayload) : PairingValidationResult diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt index 8e90e10..ce0d392 100644 --- a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt +++ b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsScreen.kt @@ -200,10 +200,15 @@ fun HostsScreen( uiState.scannedPayload?.let { payload -> val existingHost = hosts.find { it.id.value == payload.hostId } + val isEndpointChanged = existingHost != null && + app.hermes.mobile.core.pairing.CanonicalEndpoint.fromBaseUrl(existingHost.baseUrl) != payload.canonicalEndpoint + PairingPreviewDialog( payload = payload, isExistingHost = existingHost != null, existingHostName = existingHost?.displayName, + isEndpointChanged = isEndpointChanged, + oldEndpointUrl = existingHost?.baseUrl, onConfirm = { allowCleartext -> viewModel.confirmPairing(payload, allowCleartext) }, diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt index 57d63b5..1fad66e 100644 --- a/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt +++ b/app/src/main/java/app/hermes/mobile/feature/hosts/HostsViewModel.kt @@ -151,6 +151,11 @@ class HostsViewModel( viewModelScope.launch { val existingHost = connectionManager.hostDao.getHost(payload.hostId) val hostToConnect = if (existingHost != null) { + val oldCanonical = app.hermes.mobile.core.pairing.CanonicalEndpoint.fromBaseUrl(existingHost.baseUrl) + if (oldCanonical != payload.canonicalEndpoint) { + connectionManager.disconnectHost(HermesHostId(payload.hostId)) + tokenVault.clearTokens(payload.hostId) + } val updatedHost = HermesHost( id = HermesHostId(payload.hostId), displayName = payload.name, diff --git a/app/src/main/java/app/hermes/mobile/feature/hosts/PairingPreviewDialog.kt b/app/src/main/java/app/hermes/mobile/feature/hosts/PairingPreviewDialog.kt index 73f9f26..8e97f8d 100644 --- a/app/src/main/java/app/hermes/mobile/feature/hosts/PairingPreviewDialog.kt +++ b/app/src/main/java/app/hermes/mobile/feature/hosts/PairingPreviewDialog.kt @@ -7,9 +7,18 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import app.hermes.mobile.core.pairing.HermesPairingPayload import app.hermes.mobile.core.network.HermesRestClient +import app.hermes.mobile.core.model.HermesServerStatus import kotlinx.coroutines.launch import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.net.URI + +sealed interface PairingProbeState { + data object Idle : PairingProbeState + data object Probing : PairingProbeState + data class Success(val status: HermesServerStatus) : PairingProbeState + data class Failed(val message: String) : PairingProbeState +} @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -17,15 +26,18 @@ fun PairingPreviewDialog( payload: HermesPairingPayload, isExistingHost: Boolean, existingHostName: String?, + isEndpointChanged: Boolean, + oldEndpointUrl: String?, onConfirm: (Boolean) -> Unit, onCancel: () -> Unit ) { - var probeStatus by remember { mutableStateOf("Probing...") } + var probeState by remember { mutableStateOf(PairingProbeState.Probing) } var allowCleartext by remember { mutableStateOf(payload.scheme == "http") } val coroutineScope = rememberCoroutineScope() val restClient = remember { HermesRestClient() } - LaunchedEffect(payload) { + fun doProbe() { + probeState = PairingProbeState.Probing coroutineScope.launch { try { val url = "${payload.scheme}://${payload.host}:${payload.port}" @@ -35,17 +47,23 @@ fun PairingPreviewDialog( if (result.isSuccess) { val status = result.getOrNull() if (status != null) { - probeStatus = "Hermes v${status.version} | Auth: ${if (status.authRequired) "Required" else "None"}" + probeState = PairingProbeState.Success(status) + } else { + probeState = PairingProbeState.Failed("Empty status") } } else { - probeStatus = "Probe failed: ${result.exceptionOrNull()?.message}" + probeState = PairingProbeState.Failed(result.exceptionOrNull()?.message ?: "Unknown error") } } catch (e: Exception) { - probeStatus = "Probe failed: ${e.message}" + probeState = PairingProbeState.Failed(e.message ?: "Unknown error") } } } + LaunchedEffect(payload) { + doProbe() + } + AlertDialog( onDismissRequest = onCancel, title = { Text("Pairing Preview") }, @@ -54,20 +72,61 @@ fun PairingPreviewDialog( Text("Host: ${payload.name}", style = MaterialTheme.typography.bodyLarge) Text("Address: ${payload.scheme}://${payload.host}:${payload.port}", style = MaterialTheme.typography.bodyMedium) Spacer(modifier = Modifier.height(8.dp)) - - Text("Status: $probeStatus", style = MaterialTheme.typography.bodySmall) - + + when (val state = probeState) { + is PairingProbeState.Probing -> { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + CircularProgressIndicator(modifier = Modifier.size(16.dp), strokeWidth = 2.dp) + Spacer(modifier = Modifier.width(8.dp)) + Text("Probing status...", style = MaterialTheme.typography.bodySmall) + } + } + is PairingProbeState.Failed -> { + Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.small) { + Column(modifier = Modifier.padding(8.dp)) { + Text("Probe failed: ${state.message}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onErrorContainer) + Spacer(modifier = Modifier.height(4.dp)) + Button(onClick = { doProbe() }, modifier = Modifier.height(32.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 0.dp)) { + Text("Retry", style = MaterialTheme.typography.labelSmall) + } + } + } + } + is PairingProbeState.Success -> { + Surface(color = androidx.compose.ui.graphics.Color(0xFFD1FAE5), shape = MaterialTheme.shapes.small) { + Text( + "Hermes v${state.status.version} | Auth: ${if (state.status.authRequired) "Required" else "None"}", + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.bodySmall, + color = androidx.compose.ui.graphics.Color(0xFF065F46) + ) + } + } + PairingProbeState.Idle -> {} + } + if (isExistingHost) { Spacer(modifier = Modifier.height(8.dp)) - Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small) { - Text( - "Will update endpoint for existing host '${existingHostName ?: payload.name}'", - modifier = Modifier.padding(8.dp), - style = MaterialTheme.typography.labelMedium - ) + if (isEndpointChanged) { + Surface(color = MaterialTheme.colorScheme.errorContainer, shape = MaterialTheme.shapes.small) { + Text( + "Endpoint for this host has changed from $oldEndpointUrl to ${payload.scheme}://${payload.host}:${payload.port}. For security, saved login credentials will be cleared. Fresh login will be required.", + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } else { + Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small) { + Text( + "Will update endpoint for existing host '${existingHostName ?: payload.name}'", + modifier = Modifier.padding(8.dp), + style = MaterialTheme.typography.labelMedium + ) + } } } - + if (payload.scheme == "http") { Spacer(modifier = Modifier.height(8.dp)) Row( @@ -88,7 +147,10 @@ fun PairingPreviewDialog( } }, confirmButton = { - TextButton(onClick = { onConfirm(allowCleartext) }) { + Button( + onClick = { onConfirm(allowCleartext) }, + enabled = probeState is PairingProbeState.Success + ) { Text(if (isExistingHost) "Update Host" else "Add Host") } }, diff --git a/app/src/test/java/app/hermes/mobile/core/network/JsonRpcGatewayClientTest.kt b/app/src/test/java/app/hermes/mobile/core/network/JsonRpcGatewayClientTest.kt index 3166f33..987f4f3 100644 --- a/app/src/test/java/app/hermes/mobile/core/network/JsonRpcGatewayClientTest.kt +++ b/app/src/test/java/app/hermes/mobile/core/network/JsonRpcGatewayClientTest.kt @@ -95,7 +95,12 @@ class JsonRpcGatewayClientTest { client.connect(wsUrl, allowCleartext = true) // Give WS a moment to open transport - kotlinx.coroutines.delay(100) + var retries = 0 + while (serverWebSocket == null && retries < 50) { + kotlinx.coroutines.delay(50) + retries++ + } + // Must still be Connecting before gateway.ready is received assertEquals(ConnectionState.Connecting, client.connectionState.value) diff --git a/app/src/test/java/app/hermes/mobile/core/pairing/HermesPairingParserTest.kt b/app/src/test/java/app/hermes/mobile/core/pairing/HermesPairingParserTest.kt index 858aee2..84bfd79 100644 --- a/app/src/test/java/app/hermes/mobile/core/pairing/HermesPairingParserTest.kt +++ b/app/src/test/java/app/hermes/mobile/core/pairing/HermesPairingParserTest.kt @@ -14,7 +14,7 @@ class HermesPairingParserTest { @Test fun testValidPairingPayloadParsing() { - val futureTime = (System.currentTimeMillis() / 1000) + 3600 + val futureTime = (System.currentTimeMillis() / 1000) + 300 val hostId = UUID.randomUUID().toString() val json = """ { @@ -26,7 +26,7 @@ class HermesPairingParserTest { "port": 9119, "scheme": "http", "expires_at": $futureTime, - "nonce": "random-nonce" + "nonce": "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY" } """.trimIndent() @@ -44,128 +44,127 @@ class HermesPairingParserTest { } @Test - fun testExpiredPayloadRejection() { - val pastTime = (System.currentTimeMillis() / 1000) - 3600 - val hostId = UUID.randomUUID().toString() + fun testCanonicalCrossContractFixture() { + val futureTime = (System.currentTimeMillis() / 1000) + 300 val json = """ { "v": 1, "type": "hermes-pair", - "host_id": "$hostId", - "name": "My Server", - "host": "192.168.1.5", + "host_id": "58af1471-a0a2-4e2b-9426-5068f2a2deab", + "name": "Office-PC", + "host": "192.168.1.150", "port": 9119, "scheme": "http", - "expires_at": $pastTime, - "nonce": "random-nonce" + "expires_at": $futureTime, + "nonce": "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY" } """.trimIndent() - val uri = "hermes://pair?data=${encodePayload(json)}" val result = HermesPairingParser.parse(uri) - + assertTrue(result is PairingValidationResult.Success) + val payload = (result as PairingValidationResult.Success).payload + assertEquals("58af1471-a0a2-4e2b-9426-5068f2a2deab", payload.hostId) + assertEquals("Office-PC", payload.name) + assertEquals("192.168.1.150", payload.host) + assertEquals(9119, payload.port) + assertEquals("http", payload.scheme) + assertEquals("QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY", payload.nonce) + } + + @Test + fun testExpiredPayloadRejection() { + val pastTime = (System.currentTimeMillis() / 1000) - 35 + val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":$pastTime,"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + val uri = "hermes://pair?data=${encodePayload(json)}" + val result = HermesPairingParser.parse(uri) assertTrue(result is PairingValidationResult.Expired) } + @Test + fun testExcessiveTTLRejection() { + val farFuture = (System.currentTimeMillis() / 1000) + 605 + val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":$farFuture,"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + val result = HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") + assertTrue(result is PairingValidationResult.InvalidPayload) + } + @Test fun testInvalidVersionRejection() { - val futureTime = (System.currentTimeMillis() / 1000) + 3600 - val hostId = UUID.randomUUID().toString() - val json = """ - { - "v": 2, - "type": "hermes-pair", - "host_id": "$hostId", - "name": "My Server", - "host": "192.168.1.5", - "port": 9119, - "scheme": "http", - "expires_at": $futureTime, - "nonce": "random-nonce" - } - """.trimIndent() + val json = """{"v":2,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidVersion) + } + + @Test + fun testInvalidTypeRejection() { + val json = """{"v":1,"type":"other","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload) + } + + @Test + fun testInvalidHostIdRejection() { + val json = """{"v":1,"type":"hermes-pair","host_id":"not-a-uuid","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload) + } + + @Test + fun testInvalidNameRejections() { + val futures = listOf("", " ", "Name\u0000", "A".repeat(129)) + futures.forEach { name -> + val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"$name","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload) + } + } + + @Test + fun testInvalidHostRejections() { + val hosts = listOf("1.1.1.1/path", "user@1.1.1.1", "1.1.1.1:9119", "1.1.1.1?q=1", "1.1.1.1#frag", "1 1") + hosts.forEach { host -> + val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"$host","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload) + } + } + + @Test + fun testInvalidPortRejections() { + val ports = listOf(0, 70000) + ports.forEach { port -> + val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":$port,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload) + } + } + + @Test + fun testInvalidSchemeRejection() { + val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"ftp","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidScheme) + } + + @Test + fun testInvalidNonceRejections() { + val shortNonce = Base64.getUrlEncoder().withoutPadding().encodeToString("12345".toByteArray()) + val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"$shortNonce"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}") is PairingValidationResult.InvalidPayload) + val invalidBase64 = "ThisIs!Not!Valid!Base64" + val json2 = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"S","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"$invalidBase64"}""" + assertTrue(HermesPairingParser.parse("hermes://pair?data=${encodePayload(json2)}") is PairingValidationResult.InvalidPayload) + } + + @Test + fun testOversizedPayloads() { + val longString = "A".repeat(3000) + val json = """{"v":1,"type":"hermes-pair","host_id":"58af1471-a0a2-4e2b-9426-5068f2a2deab","name":"$longString","host":"1.1.1.1","port":9119,"scheme":"http","expires_at":${(System.currentTimeMillis() / 1000) + 300},"nonce":"QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"}""" val uri = "hermes://pair?data=${encodePayload(json)}" - val result = HermesPairingParser.parse(uri) + assertTrue(HermesPairingParser.parse(uri) is PairingValidationResult.InvalidPayload) - assertTrue(result is PairingValidationResult.InvalidVersion) + val hugeUri = "hermes://pair?data=" + "A".repeat(5000) + assertTrue(HermesPairingParser.parse(hugeUri) is PairingValidationResult.InvalidPayload) } @Test fun testMalformedBase64Rejection() { val uri = "hermes://pair?data=ThisIs!Not!Valid!Base64" val result = HermesPairingParser.parse(uri) - - assertTrue(result is PairingValidationResult.InvalidPayload) - } - - @Test - fun testInvalidPortRejection() { - val futureTime = (System.currentTimeMillis() / 1000) + 3600 - val hostId = UUID.randomUUID().toString() - val json = """ - { - "v": 1, - "type": "hermes-pair", - "host_id": "$hostId", - "name": "My Server", - "host": "192.168.1.5", - "port": 70000, - "scheme": "http", - "expires_at": $futureTime, - "nonce": "random-nonce" - } - """.trimIndent() - - val uri = "hermes://pair?data=${encodePayload(json)}" - val result = HermesPairingParser.parse(uri) - - assertTrue(result is PairingValidationResult.InvalidPayload) - } - - @Test - fun testInvalidSchemeRejection() { - val futureTime = (System.currentTimeMillis() / 1000) + 3600 - val hostId = UUID.randomUUID().toString() - val json = """ - { - "v": 1, - "type": "hermes-pair", - "host_id": "$hostId", - "name": "My Server", - "host": "192.168.1.5", - "port": 9119, - "scheme": "ftp", - "expires_at": $futureTime, - "nonce": "random-nonce" - } - """.trimIndent() - - val uri = "hermes://pair?data=${encodePayload(json)}" - val result = HermesPairingParser.parse(uri) - - assertTrue(result is PairingValidationResult.InvalidScheme) - } - - @Test - fun testMissingHostIdRejection() { - val futureTime = (System.currentTimeMillis() / 1000) + 3600 - val json = """ - { - "v": 1, - "type": "hermes-pair", - "name": "My Server", - "host": "192.168.1.5", - "port": 9119, - "scheme": "http", - "expires_at": $futureTime, - "nonce": "random-nonce" - } - """.trimIndent() - - val uri = "hermes://pair?data=${encodePayload(json)}" - val result = HermesPairingParser.parse(uri) - assertTrue(result is PairingValidationResult.InvalidPayload) } } diff --git a/app/src/test/java/app/hermes/mobile/feature/hosts/HostsPairingTest.kt b/app/src/test/java/app/hermes/mobile/feature/hosts/HostsPairingTest.kt index 0622346..61e7c87 100644 --- a/app/src/test/java/app/hermes/mobile/feature/hosts/HostsPairingTest.kt +++ b/app/src/test/java/app/hermes/mobile/feature/hosts/HostsPairingTest.kt @@ -125,7 +125,41 @@ class HostsPairingTest { @Test fun testExistingTokensPreservedOnHostUpdate() = runTest { - // Just verify updateHost is called and tokenVault clear isn't. + val hostId = UUID.randomUUID().toString() + val existingEntity = HostEntity( + id = hostId, + displayName = "Old Name", + baseUrl = "http://192.168.1.5:9119", + allowCleartext = true, + enabled = true, + lastSeenAt = 1000L, + lastKnownStatus = "OFFLINE" + ) + + val payload = HermesPairingPayload( + v = 1, + type = "hermes-pair", + hostId = hostId, + name = "Updated Server Name", + host = "192.168.1.5", + port = 9119, + scheme = "http", + expiresAt = (System.currentTimeMillis() / 1000) + 3600, + nonce = "nonce" + ) + + coEvery { hostDao.getHost(hostId) } returns existingEntity + coEvery { connectionManager.updateHost(any()) } returns Unit + coEvery { connectionManager.connectHost(any()) } returns Result.success(Unit) + + viewModel.confirmPairing(payload, allowCleartext = true) + + coVerify(exactly = 0) { tokenVault.clearTokens(any()) } + coVerify(exactly = 0) { connectionManager.disconnectHost(any()) } + } + + @Test + fun testExistingTokensClearedOnEndpointChange() = runTest { val hostId = UUID.randomUUID().toString() val existingEntity = HostEntity( id = hostId, @@ -150,9 +184,12 @@ class HostsPairingTest { ) coEvery { hostDao.getHost(hostId) } returns existingEntity + coEvery { connectionManager.updateHost(any()) } returns Unit + coEvery { connectionManager.connectHost(any()) } returns Result.success(Unit) viewModel.confirmPairing(payload, allowCleartext = true) - coVerify(exactly = 0) { tokenVault.clearTokens(any()) } + coVerify { tokenVault.clearTokens(hostId) } + coVerify { connectionManager.disconnectHost(app.hermes.mobile.core.model.HermesHostId(hostId)) } } }