fix(security): credential wipe on endpoint change, PairingProbeState gating, and parser hardening
This commit is contained in:
parent
c27d902d3c
commit
7c90da222c
8 changed files with 305 additions and 122 deletions
|
|
@ -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<HermesPairingPayload>(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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>(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")
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue