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 {
|
fun parse(rawUri: String): PairingValidationResult {
|
||||||
try {
|
try {
|
||||||
|
if (rawUri.toByteArray(Charsets.UTF_8).size > 4096) {
|
||||||
|
return PairingValidationResult.InvalidPayload("URI exceeds maximum length of 4096 bytes")
|
||||||
|
}
|
||||||
|
|
||||||
val uri = URI(rawUri)
|
val uri = URI(rawUri)
|
||||||
if (uri.scheme != "hermes" || uri.host != "pair") {
|
if (uri.scheme != "hermes" || uri.host != "pair") {
|
||||||
return PairingValidationResult.InvalidPayload("Invalid scheme or host")
|
return PairingValidationResult.InvalidPayload("Invalid scheme or host")
|
||||||
|
|
@ -29,6 +33,10 @@ object HermesPairingParser {
|
||||||
return PairingValidationResult.InvalidPayload("Malformed Base64")
|
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 jsonString = String(decodedBytes, Charsets.UTF_8)
|
||||||
val payload = try {
|
val payload = try {
|
||||||
json.decodeFromString<HermesPairingPayload>(jsonString)
|
json.decodeFromString<HermesPairingPayload>(jsonString)
|
||||||
|
|
@ -43,13 +51,32 @@ object HermesPairingParser {
|
||||||
return PairingValidationResult.InvalidPayload("Invalid type")
|
return PairingValidationResult.InvalidPayload("Invalid type")
|
||||||
}
|
}
|
||||||
try {
|
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) {
|
} catch (e: IllegalArgumentException) {
|
||||||
return PairingValidationResult.InvalidPayload("Invalid host_id")
|
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()) {
|
if (payload.host.isBlank()) {
|
||||||
return PairingValidationResult.InvalidPayload("Host is empty")
|
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) {
|
if (payload.port !in 1..65535) {
|
||||||
return PairingValidationResult.InvalidPayload("Invalid port")
|
return PairingValidationResult.InvalidPayload("Invalid port")
|
||||||
}
|
}
|
||||||
|
|
@ -57,9 +84,25 @@ object HermesPairingParser {
|
||||||
return PairingValidationResult.InvalidScheme("Scheme must be http or https")
|
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)
|
return PairingValidationResult.Expired(payload.expiresAt)
|
||||||
}
|
}
|
||||||
|
if (payload.expiresAt > now + 600) {
|
||||||
|
return PairingValidationResult.InvalidPayload("Expiry exceeds maximum TTL of 600 seconds")
|
||||||
|
}
|
||||||
|
|
||||||
return PairingValidationResult.Success(payload)
|
return PairingValidationResult.Success(payload)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,34 @@ data class HermesPairingPayload(
|
||||||
val scheme: String = "http",
|
val scheme: String = "http",
|
||||||
@SerialName("expires_at") val expiresAt: Long,
|
@SerialName("expires_at") val expiresAt: Long,
|
||||||
val nonce: String
|
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 {
|
sealed interface PairingValidationResult {
|
||||||
data class Success(val payload: HermesPairingPayload) : PairingValidationResult
|
data class Success(val payload: HermesPairingPayload) : PairingValidationResult
|
||||||
|
|
|
||||||
|
|
@ -200,10 +200,15 @@ fun HostsScreen(
|
||||||
|
|
||||||
uiState.scannedPayload?.let { payload ->
|
uiState.scannedPayload?.let { payload ->
|
||||||
val existingHost = hosts.find { it.id.value == payload.hostId }
|
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(
|
PairingPreviewDialog(
|
||||||
payload = payload,
|
payload = payload,
|
||||||
isExistingHost = existingHost != null,
|
isExistingHost = existingHost != null,
|
||||||
existingHostName = existingHost?.displayName,
|
existingHostName = existingHost?.displayName,
|
||||||
|
isEndpointChanged = isEndpointChanged,
|
||||||
|
oldEndpointUrl = existingHost?.baseUrl,
|
||||||
onConfirm = { allowCleartext ->
|
onConfirm = { allowCleartext ->
|
||||||
viewModel.confirmPairing(payload, allowCleartext)
|
viewModel.confirmPairing(payload, allowCleartext)
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -151,6 +151,11 @@ class HostsViewModel(
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val existingHost = connectionManager.hostDao.getHost(payload.hostId)
|
val existingHost = connectionManager.hostDao.getHost(payload.hostId)
|
||||||
val hostToConnect = if (existingHost != null) {
|
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(
|
val updatedHost = HermesHost(
|
||||||
id = HermesHostId(payload.hostId),
|
id = HermesHostId(payload.hostId),
|
||||||
displayName = payload.name,
|
displayName = payload.name,
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,18 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import app.hermes.mobile.core.pairing.HermesPairingPayload
|
import app.hermes.mobile.core.pairing.HermesPairingPayload
|
||||||
import app.hermes.mobile.core.network.HermesRestClient
|
import app.hermes.mobile.core.network.HermesRestClient
|
||||||
|
import app.hermes.mobile.core.model.HermesServerStatus
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -17,15 +26,18 @@ fun PairingPreviewDialog(
|
||||||
payload: HermesPairingPayload,
|
payload: HermesPairingPayload,
|
||||||
isExistingHost: Boolean,
|
isExistingHost: Boolean,
|
||||||
existingHostName: String?,
|
existingHostName: String?,
|
||||||
|
isEndpointChanged: Boolean,
|
||||||
|
oldEndpointUrl: String?,
|
||||||
onConfirm: (Boolean) -> Unit,
|
onConfirm: (Boolean) -> Unit,
|
||||||
onCancel: () -> 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") }
|
var allowCleartext by remember { mutableStateOf(payload.scheme == "http") }
|
||||||
val coroutineScope = rememberCoroutineScope()
|
val coroutineScope = rememberCoroutineScope()
|
||||||
val restClient = remember { HermesRestClient() }
|
val restClient = remember { HermesRestClient() }
|
||||||
|
|
||||||
LaunchedEffect(payload) {
|
fun doProbe() {
|
||||||
|
probeState = PairingProbeState.Probing
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
try {
|
try {
|
||||||
val url = "${payload.scheme}://${payload.host}:${payload.port}"
|
val url = "${payload.scheme}://${payload.host}:${payload.port}"
|
||||||
|
|
@ -35,17 +47,23 @@ fun PairingPreviewDialog(
|
||||||
if (result.isSuccess) {
|
if (result.isSuccess) {
|
||||||
val status = result.getOrNull()
|
val status = result.getOrNull()
|
||||||
if (status != null) {
|
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 {
|
} else {
|
||||||
probeStatus = "Probe failed: ${result.exceptionOrNull()?.message}"
|
probeState = PairingProbeState.Failed(result.exceptionOrNull()?.message ?: "Unknown error")
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
probeStatus = "Probe failed: ${e.message}"
|
probeState = PairingProbeState.Failed(e.message ?: "Unknown error")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(payload) {
|
||||||
|
doProbe()
|
||||||
|
}
|
||||||
|
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = onCancel,
|
onDismissRequest = onCancel,
|
||||||
title = { Text("Pairing Preview") },
|
title = { Text("Pairing Preview") },
|
||||||
|
|
@ -55,10 +73,50 @@ fun PairingPreviewDialog(
|
||||||
Text("Address: ${payload.scheme}://${payload.host}:${payload.port}", style = MaterialTheme.typography.bodyMedium)
|
Text("Address: ${payload.scheme}://${payload.host}:${payload.port}", style = MaterialTheme.typography.bodyMedium)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
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) {
|
if (isExistingHost) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
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) {
|
Surface(color = MaterialTheme.colorScheme.secondaryContainer, shape = MaterialTheme.shapes.small) {
|
||||||
Text(
|
Text(
|
||||||
"Will update endpoint for existing host '${existingHostName ?: payload.name}'",
|
"Will update endpoint for existing host '${existingHostName ?: payload.name}'",
|
||||||
|
|
@ -67,6 +125,7 @@ fun PairingPreviewDialog(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (payload.scheme == "http") {
|
if (payload.scheme == "http") {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
|
@ -88,7 +147,10 @@ fun PairingPreviewDialog(
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
confirmButton = {
|
confirmButton = {
|
||||||
TextButton(onClick = { onConfirm(allowCleartext) }) {
|
Button(
|
||||||
|
onClick = { onConfirm(allowCleartext) },
|
||||||
|
enabled = probeState is PairingProbeState.Success
|
||||||
|
) {
|
||||||
Text(if (isExistingHost) "Update Host" else "Add Host")
|
Text(if (isExistingHost) "Update Host" else "Add Host")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -95,7 +95,12 @@ class JsonRpcGatewayClientTest {
|
||||||
client.connect(wsUrl, allowCleartext = true)
|
client.connect(wsUrl, allowCleartext = true)
|
||||||
|
|
||||||
// Give WS a moment to open transport
|
// 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
|
// Must still be Connecting before gateway.ready is received
|
||||||
assertEquals(ConnectionState.Connecting, client.connectionState.value)
|
assertEquals(ConnectionState.Connecting, client.connectionState.value)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ class HermesPairingParserTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testValidPairingPayloadParsing() {
|
fun testValidPairingPayloadParsing() {
|
||||||
val futureTime = (System.currentTimeMillis() / 1000) + 3600
|
val futureTime = (System.currentTimeMillis() / 1000) + 300
|
||||||
val hostId = UUID.randomUUID().toString()
|
val hostId = UUID.randomUUID().toString()
|
||||||
val json = """
|
val json = """
|
||||||
{
|
{
|
||||||
|
|
@ -26,7 +26,7 @@ class HermesPairingParserTest {
|
||||||
"port": 9119,
|
"port": 9119,
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"expires_at": $futureTime,
|
"expires_at": $futureTime,
|
||||||
"nonce": "random-nonce"
|
"nonce": "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
|
|
@ -44,128 +44,127 @@ class HermesPairingParserTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testExpiredPayloadRejection() {
|
fun testCanonicalCrossContractFixture() {
|
||||||
val pastTime = (System.currentTimeMillis() / 1000) - 3600
|
val futureTime = (System.currentTimeMillis() / 1000) + 300
|
||||||
val hostId = UUID.randomUUID().toString()
|
|
||||||
val json = """
|
val json = """
|
||||||
{
|
{
|
||||||
"v": 1,
|
"v": 1,
|
||||||
"type": "hermes-pair",
|
"type": "hermes-pair",
|
||||||
"host_id": "$hostId",
|
"host_id": "58af1471-a0a2-4e2b-9426-5068f2a2deab",
|
||||||
"name": "My Server",
|
"name": "Office-PC",
|
||||||
"host": "192.168.1.5",
|
"host": "192.168.1.150",
|
||||||
"port": 9119,
|
"port": 9119,
|
||||||
"scheme": "http",
|
"scheme": "http",
|
||||||
"expires_at": $pastTime,
|
"expires_at": $futureTime,
|
||||||
"nonce": "random-nonce"
|
"nonce": "QUJDREVGR0hJSktMTU5PUHFyc3R1dnd4eXoxMjM0NTY"
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val uri = "hermes://pair?data=${encodePayload(json)}"
|
val uri = "hermes://pair?data=${encodePayload(json)}"
|
||||||
val result = HermesPairingParser.parse(uri)
|
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)
|
assertTrue(result is PairingValidationResult.Expired)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testInvalidVersionRejection() {
|
fun testExcessiveTTLRejection() {
|
||||||
val futureTime = (System.currentTimeMillis() / 1000) + 3600
|
val farFuture = (System.currentTimeMillis() / 1000) + 605
|
||||||
val hostId = UUID.randomUUID().toString()
|
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 json = """
|
val result = HermesPairingParser.parse("hermes://pair?data=${encodePayload(json)}")
|
||||||
{
|
assertTrue(result is PairingValidationResult.InvalidPayload)
|
||||||
"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()
|
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testInvalidVersionRejection() {
|
||||||
|
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 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
|
@Test
|
||||||
fun testMalformedBase64Rejection() {
|
fun testMalformedBase64Rejection() {
|
||||||
val uri = "hermes://pair?data=ThisIs!Not!Valid!Base64"
|
val uri = "hermes://pair?data=ThisIs!Not!Valid!Base64"
|
||||||
val result = HermesPairingParser.parse(uri)
|
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)
|
assertTrue(result is PairingValidationResult.InvalidPayload)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -125,7 +125,41 @@ class HostsPairingTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testExistingTokensPreservedOnHostUpdate() = runTest {
|
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 hostId = UUID.randomUUID().toString()
|
||||||
val existingEntity = HostEntity(
|
val existingEntity = HostEntity(
|
||||||
id = hostId,
|
id = hostId,
|
||||||
|
|
@ -150,9 +184,12 @@ class HostsPairingTest {
|
||||||
)
|
)
|
||||||
|
|
||||||
coEvery { hostDao.getHost(hostId) } returns existingEntity
|
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)
|
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