mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-04-20 22:23:37 +00:00
refactor(node): Improve public key conflict handling (#4486)
Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
parent
78820863da
commit
cab39408df
5 changed files with 153 additions and 38 deletions
|
|
@ -22,6 +22,7 @@ import androidx.test.platform.app.InstrumentationRegistry
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okio.ByteString
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
|
|
@ -338,10 +339,77 @@ class NodeInfoDaoTest {
|
|||
|
||||
@Test
|
||||
fun testPkcMismatch() = runBlocking {
|
||||
val newNode = testNodes[1].copy(user = testNodes[1].user.copy(public_key = ByteArray(32) { 99 }.toByteString()))
|
||||
nodeInfoDao.putAll(listOf(newNode))
|
||||
val nodes = getNodes()
|
||||
val containsMismatchNode = nodes.any { it.mismatchKey }
|
||||
assertTrue(containsMismatchNode)
|
||||
// First, ensure the node is in the DB with Key A
|
||||
val nodeA = testNodes[10].copy(publicKey = ByteArray(32) { 1 }.toByteString())
|
||||
nodeInfoDao.upsert(nodeA)
|
||||
|
||||
// Now upsert with Key B (mismatch)
|
||||
val nodeB =
|
||||
nodeA.copy(
|
||||
publicKey = ByteArray(32) { 2 }.toByteString(),
|
||||
user = nodeA.user.copy(public_key = ByteArray(32) { 2 }.toByteString()),
|
||||
)
|
||||
nodeInfoDao.upsert(nodeB)
|
||||
|
||||
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
|
||||
assertEquals(NodeEntity.ERROR_BYTE_STRING, stored.publicKey)
|
||||
assertTrue(stored.toModel().mismatchKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRoutineUpdatePreservesKey() = runBlocking {
|
||||
// First, ensure the node is in the DB with Key A
|
||||
val keyA = ByteArray(32) { 1 }.toByteString()
|
||||
val nodeA = testNodes[10].copy(publicKey = keyA, user = testNodes[10].user.copy(public_key = keyA))
|
||||
nodeInfoDao.upsert(nodeA)
|
||||
|
||||
// Now upsert with an empty key (common in position/telemetry updates)
|
||||
val nodeEmpty = nodeA.copy(publicKey = null, user = nodeA.user.copy(public_key = ByteString.EMPTY))
|
||||
nodeInfoDao.upsert(nodeEmpty)
|
||||
|
||||
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
|
||||
assertEquals(keyA, stored.publicKey)
|
||||
assertFalse(stored.toModel().mismatchKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRecoveryFromErrorState() = runBlocking {
|
||||
// Start in Error state
|
||||
val nodeError =
|
||||
testNodes[10].copy(
|
||||
publicKey = NodeEntity.ERROR_BYTE_STRING,
|
||||
user = testNodes[10].user.copy(public_key = NodeEntity.ERROR_BYTE_STRING),
|
||||
)
|
||||
nodeInfoDao.doUpsert(nodeError)
|
||||
assertTrue(nodeInfoDao.getNodeByNum(nodeError.num)!!.toModel().mismatchKey)
|
||||
|
||||
// Now upsert with a valid Key C
|
||||
val keyC = ByteArray(32) { 3 }.toByteString()
|
||||
val nodeC = nodeError.copy(publicKey = keyC, user = nodeError.user.copy(public_key = keyC))
|
||||
nodeInfoDao.upsert(nodeC)
|
||||
|
||||
val stored = nodeInfoDao.getNodeByNum(nodeError.num)!!.node
|
||||
assertEquals(keyC, stored.publicKey)
|
||||
assertFalse(stored.toModel().mismatchKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLicensedUserClearsKey() = runBlocking {
|
||||
// Start with a key
|
||||
val keyA = ByteArray(32) { 1 }.toByteString()
|
||||
val nodeA = testNodes[10].copy(publicKey = keyA, user = testNodes[10].user.copy(public_key = keyA))
|
||||
nodeInfoDao.upsert(nodeA)
|
||||
|
||||
// Upsert as licensed user
|
||||
val nodeLicensed =
|
||||
nodeA.copy(
|
||||
user = nodeA.user.copy(is_licensed = true, public_key = ByteString.EMPTY),
|
||||
publicKey = ByteString.EMPTY,
|
||||
)
|
||||
nodeInfoDao.upsert(nodeLicensed)
|
||||
|
||||
val stored = nodeInfoDao.getNodeByNum(nodeA.num)!!.node
|
||||
assertTrue(stored.publicKey == null || (stored.publicKey?.size ?: 0) == 0)
|
||||
assertFalse(stored.toModel().mismatchKey)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ interface NodeInfoDao {
|
|||
return newNode
|
||||
}
|
||||
|
||||
@Suppress("CyclomaticComplexMethod", "MagicNumber")
|
||||
private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity {
|
||||
val isPlaceholder = incomingNode.user.hw_model == HardwareModel.UNSET
|
||||
val hasExistingUser = existingNode.user.hw_model != HardwareModel.UNSET
|
||||
|
|
@ -113,27 +114,40 @@ interface NodeInfoDao {
|
|||
)
|
||||
}
|
||||
|
||||
// A public key is considered matching if the incoming key equals the existing key,
|
||||
// OR if the existing key is empty (allowing a new key to be set or an update to proceed).
|
||||
val existingResolvedKey = existingNode.publicKey ?: existingNode.user.public_key
|
||||
val isPublicKeyMatchingOrExistingIsEmpty = existingResolvedKey == incomingNode.publicKey || !existingNode.hasPKC
|
||||
val existingKey = existingNode.publicKey ?: existingNode.user.public_key
|
||||
val incomingKey = incomingNode.publicKey
|
||||
|
||||
val incomingHasKey = (incomingKey?.size ?: 0) == 32
|
||||
val existingHasKey = (existingKey?.size ?: 0) == 32 && existingKey != NodeEntity.ERROR_BYTE_STRING
|
||||
|
||||
val resolvedKey =
|
||||
when {
|
||||
incomingHasKey -> {
|
||||
if (existingHasKey && incomingKey != existingKey) {
|
||||
// Actual mismatch between two non-empty keys
|
||||
NodeEntity.ERROR_BYTE_STRING
|
||||
} else {
|
||||
// New key, same key, or recovery from Error state
|
||||
incomingKey
|
||||
}
|
||||
}
|
||||
incomingNode.user.is_licensed -> {
|
||||
// Explicitly clear key for licensed (HAM) users
|
||||
ByteString.EMPTY
|
||||
}
|
||||
else -> {
|
||||
// Routine update without key: preserve what we have (even if it's currently Error)
|
||||
existingKey
|
||||
}
|
||||
}
|
||||
|
||||
val resolvedNotes = if (incomingNode.notes.isBlank()) existingNode.notes else incomingNode.notes
|
||||
|
||||
return if (isPublicKeyMatchingOrExistingIsEmpty) {
|
||||
// Keys match or existing key was empty: trust the incoming node data completely.
|
||||
// This allows for legitimate updates to user info and other fields.
|
||||
incomingNode.copy(notes = resolvedNotes)
|
||||
} else {
|
||||
// Public key mismatch: This could be a factory reset or a hardware ID collision.
|
||||
// We allow the name and user info to update, but we clear the public key
|
||||
// to indicate that this node is no longer "verified" against the previous key.
|
||||
incomingNode.copy(
|
||||
user = incomingNode.user.copy(public_key = NodeEntity.ERROR_BYTE_STRING),
|
||||
publicKey = NodeEntity.ERROR_BYTE_STRING,
|
||||
notes = resolvedNotes,
|
||||
)
|
||||
}
|
||||
return incomingNode.copy(
|
||||
user = incomingNode.user.copy(public_key = resolvedKey ?: ByteString.EMPTY),
|
||||
publicKey = resolvedKey,
|
||||
notes = resolvedNotes,
|
||||
)
|
||||
}
|
||||
|
||||
@Query("SELECT * FROM my_node")
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue