refactor(node): Improve public key conflict handling (#4486)

Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com>
This commit is contained in:
James Rich 2026-02-06 13:55:20 -06:00 committed by GitHub
parent 78820863da
commit cab39408df
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 153 additions and 38 deletions

View file

@ -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)
}
}

View file

@ -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")