fix: improve PKI message routing and resolve database migration racecondition (#4996)

This commit is contained in:
James Rich 2026-04-04 19:37:20 -05:00 committed by GitHub
parent d0e3b682ab
commit b3be9e2c38
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 277 additions and 37 deletions

View file

@ -95,23 +95,31 @@ class CommandSenderImpl(
private fun computeHopLimit(): Int = (localConfig.value.lora?.hop_limit ?: 0).takeIf { it > 0 } ?: DEFAULT_HOP_LIMIT
private fun getAdminChannelIndex(toNum: Int): Int {
/**
* Resolves the correct channel index for sending a packet to [toNum].
*
* When both the local node and the destination support PKC, returns [DataPacket.PKC_CHANNEL_INDEX] so that
* [buildMeshPacket] enables PKI encryption. Otherwise falls back to the node's heard-on channel (for general
* packets) or the dedicated admin channel (for admin packets).
*/
private fun getChannelIndex(toNum: Int, isAdmin: Boolean = false): Int {
val myNum = nodeManager.myNodeNum.value ?: return 0
val myNode = nodeManager.nodeDBbyNodeNum[myNum]
val destNode = nodeManager.nodeDBbyNodeNum[toNum]
val adminChannelIndex =
when {
myNum == toNum -> 0
myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX
else ->
channelSet.value.settings
.indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) }
.coerceAtLeast(0)
}
return adminChannelIndex
return when {
myNum == toNum -> 0
myNode?.hasPKC == true && destNode?.hasPKC == true -> DataPacket.PKC_CHANNEL_INDEX
isAdmin ->
channelSet.value.settings
.indexOfFirst { it.name.equals(ADMIN_CHANNEL_NAME, ignoreCase = true) }
.coerceAtLeast(0)
else -> destNode?.channel ?: 0
}
}
private fun getAdminChannelIndex(toNum: Int): Int = getChannelIndex(toNum, isAdmin = true)
override fun sendData(p: DataPacket) {
if (p.id == 0) p.id = generatePacketId()
val bytes = p.bytes ?: ByteString.EMPTY
@ -191,7 +199,7 @@ class CommandSenderImpl(
packetHandler.sendToRadio(
buildMeshPacket(
to = idNum,
channel = if (destNum == null) 0 else nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
channel = if (destNum == null) 0 else getChannelIndex(destNum),
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Data(
@ -214,7 +222,7 @@ class CommandSenderImpl(
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
channel = getChannelIndex(destNum),
priority = MeshPacket.Priority.BACKGROUND,
decoded =
Data(
@ -249,7 +257,7 @@ class CommandSenderImpl(
packetHandler.sendToRadio(
buildMeshPacket(
to = destNum,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
channel = getChannelIndex(destNum),
decoded =
Data(
portnum = PortNum.NODEINFO_APP,
@ -267,7 +275,7 @@ class CommandSenderImpl(
to = destNum,
wantAck = true,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
channel = getChannelIndex(destNum),
decoded = Data(portnum = PortNum.TRACEROUTE_APP, want_response = true, dest = destNum),
),
)
@ -305,7 +313,7 @@ class CommandSenderImpl(
buildMeshPacket(
to = destNum,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
channel = getChannelIndex(destNum),
decoded = Data(portnum = portNum, payload = payloadBytes, want_response = true, dest = destNum),
),
)
@ -342,7 +350,7 @@ class CommandSenderImpl(
to = destNum,
wantAck = true,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
channel = getChannelIndex(destNum),
decoded =
Data(
portnum = PortNum.NEIGHBORINFO_APP,
@ -358,7 +366,7 @@ class CommandSenderImpl(
to = destNum,
wantAck = true,
id = requestId,
channel = nodeManager.nodeDBbyNodeNum[destNum]?.channel ?: 0,
channel = getChannelIndex(destNum),
decoded = Data(portnum = PortNum.NEIGHBORINFO_APP, want_response = true, dest = destNum),
),
)
@ -397,7 +405,14 @@ class CommandSenderImpl(
if (channel == DataPacket.PKC_CHANNEL_INDEX) {
pkiEncrypted = true
publicKey = nodeManager.nodeDBbyNodeNum[to]?.user?.public_key ?: ByteString.EMPTY
val destNode = nodeManager.nodeDBbyNodeNum[to]
// Resolve the public key using the same fallback as Node.hasPKC:
// standalone publicKey (populated after Room round-trip) first, then
// the embedded user.public_key (always available in-memory).
publicKey = destNode?.let { it.publicKey ?: it.user.public_key } ?: ByteString.EMPTY
if (publicKey.size == 0) {
Logger.w { "buildMeshPacket: no public key for node ${to.toUInt()}, PKI encryption will fail" }
}
actualChannel = 0
}

View file

@ -99,6 +99,7 @@ class HistoryManagerImpl(private val meshPrefs: MeshPrefs, private val packetHan
MeshPacket(
from = myNodeNum,
to = myNodeNum,
id = kotlin.random.Random.nextInt(1, Int.MAX_VALUE),
decoded = Data(portnum = PortNum.STORE_FORWARD_APP, payload = request.encode().toByteString()),
priority = MeshPacket.Priority.BACKGROUND,
),

View file

@ -79,7 +79,14 @@ class MeshActionHandlerImpl(
override suspend fun onServiceAction(action: ServiceAction) {
Logger.d { "ServiceAction dispatched: ${action::class.simpleName}" }
ignoreExceptionSuspend {
val myNodeNum = nodeManager.myNodeNum.value ?: return@ignoreExceptionSuspend
val myNodeNum = nodeManager.myNodeNum.value
if (myNodeNum == null) {
Logger.w { "MeshActionHandlerImpl: myNodeNum is null, skipping ServiceAction!" }
if (action is ServiceAction.SendContact) {
action.result.complete(false)
}
return@ignoreExceptionSuspend
}
when (action) {
is ServiceAction.Favorite -> handleFavorite(action, myNodeNum)
is ServiceAction.Ignore -> handleIgnore(action, myNodeNum)

View file

@ -304,7 +304,7 @@ class MeshDataHandlerImpl(
if (p != null && p.status != MessageStatus.RECEIVED) {
val updatedPacket =
p.copy(status = m, relays = if (isAck) p.relays + 1 else p.relays, relayNode = relayNode)
packetRepository.value.update(updatedPacket)
packetRepository.value.update(updatedPacket, routingError = routingError)
}
reaction?.let { r ->

View file

@ -103,7 +103,9 @@ class NodeManagerImpl(
val byId = mutableMapOf<String, Node>()
nodes.values.forEach { byId[it.user.id] = it }
_nodeDBbyID.value = persistentMapOf<String, Node>().putAll(byId)
myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum
if (myNodeNum.value == null) {
myNodeNum.value = nodeRepository.myNodeInfo.value?.myNodeNum
}
}
}
@ -195,7 +197,12 @@ class NodeManagerImpl(
} else {
val keyMatch = !node.hasPKC || node.user.public_key == p.public_key
val newUser = if (keyMatch) p else p.copy(public_key = ByteString.EMPTY)
node.copy(user = newUser, channel = channel, manuallyVerified = manuallyVerified)
node.copy(
user = newUser,
publicKey = newUser.public_key,
channel = channel,
manuallyVerified = manuallyVerified,
)
}
if (newNode && !shouldPreserve) {
scope.handledLaunch {
@ -278,7 +285,7 @@ class NodeManagerImpl(
if (info.via_mqtt) {
newUser = newUser.copy(long_name = "${newUser.long_name} (MQTT)")
}
next = next.copy(user = newUser)
next = next.copy(user = newUser, publicKey = newUser.public_key)
}
}
val position = info.position

View file

@ -256,12 +256,20 @@ class PacketRepositoryImpl(private val dbManager: DatabaseProvider, private val
insertRoomPacket(packetToSave)
}
override suspend fun update(packet: DataPacket): Unit = withContext(dispatchers.io) {
override suspend fun update(packet: DataPacket, routingError: Int): Unit = withContext(dispatchers.io) {
val dao = dbManager.currentDb.value.packetDao()
// Match on key fields that identify the packet, rather than the entire data object
dao.findPacketsWithId(packet.id)
.find { it.data.id == packet.id && it.data.from == packet.from && it.data.to == packet.to }
?.let { dao.update(it.copy(data = packet)) }
?.let { existing ->
val updated =
if (routingError >= 0) {
existing.copy(data = packet, routingError = routingError)
} else {
existing.copy(data = packet)
}
dao.update(updated)
}
}
override suspend fun insertReaction(reaction: Reaction, myNodeNum: Int) =

View file

@ -18,6 +18,8 @@ package org.meshtastic.core.data.manager
import dev.mokkery.MockMode
import dev.mokkery.mock
import okio.ByteString
import okio.ByteString.Companion.toByteString
import org.meshtastic.core.model.DataPacket
import org.meshtastic.core.model.Node
import org.meshtastic.core.repository.NodeRepository
@ -34,6 +36,7 @@ import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue
import org.meshtastic.proto.NodeInfo as ProtoNodeInfo
import org.meshtastic.proto.Position as ProtoPosition
class NodeManagerImplTest {
@ -226,4 +229,103 @@ class NodeManagerImplTest {
assertTrue(!nodeManager.nodeDBbyNodeNum.containsKey(nodeNum))
assertTrue(!nodeManager.nodeDBbyID.containsKey("!testnode"))
}
@Test
fun `handleReceivedUser sets publicKey from user public_key`() {
val nodeNum = 1234
val pk = ByteArray(32) { (it + 1).toByte() }.toByteString()
val existingUser =
User(id = "!12345678", long_name = "Existing", short_name = "EX", hw_model = HardwareModel.TLORA_V2)
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser) }
val incomingUser =
User(
id = "!12345678",
long_name = "Updated",
short_name = "UP",
hw_model = HardwareModel.TLORA_V2,
public_key = pk,
)
nodeManager.handleReceivedUser(nodeNum, incomingUser)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]!!
assertEquals(pk, result.publicKey)
assertEquals(pk, result.user.public_key)
assertTrue(result.hasPKC)
}
@Test
fun `handleReceivedUser sets empty publicKey when key mismatch clears user key`() {
val nodeNum = 1234
val existingPk = ByteArray(32) { (it + 1).toByte() }.toByteString()
val existingUser =
User(
id = "!12345678",
long_name = "Existing",
short_name = "EX",
hw_model = HardwareModel.TLORA_V2,
public_key = existingPk,
)
nodeManager.updateNode(nodeNum) { it.copy(user = existingUser, publicKey = existingPk) }
val differentPk = ByteArray(32) { (it + 10).toByte() }.toByteString()
val incomingUser =
User(
id = "!12345678",
long_name = "Updated",
short_name = "UP",
hw_model = HardwareModel.TLORA_V2,
public_key = differentPk,
)
nodeManager.handleReceivedUser(nodeNum, incomingUser)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]!!
// Key mismatch: newUser gets public_key cleared to EMPTY, and publicKey should match
assertEquals(ByteString.EMPTY, result.publicKey)
assertEquals(ByteString.EMPTY, result.user.public_key)
}
@Test
fun `installNodeInfo sets publicKey from user public_key`() {
val nodeNum = 5678
val pk = ByteArray(32) { (it + 1).toByte() }.toByteString()
val user =
User(
id = "!abcd1234",
long_name = "Remote Node",
short_name = "RN",
hw_model = HardwareModel.HELTEC_V3,
public_key = pk,
)
val info = ProtoNodeInfo(num = nodeNum, user = user, last_heard = 1000, channel = 0)
nodeManager.installNodeInfo(info)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]!!
assertEquals(pk, result.publicKey)
assertEquals(pk, result.user.public_key)
assertTrue(result.hasPKC)
}
@Test
fun `installNodeInfo clears publicKey for licensed users`() {
val nodeNum = 5678
val pk = ByteArray(32) { (it + 1).toByte() }.toByteString()
val user =
User(
id = "!abcd1234",
long_name = "Licensed Op",
short_name = "LO",
hw_model = HardwareModel.HELTEC_V3,
public_key = pk,
is_licensed = true,
)
val info = ProtoNodeInfo(num = nodeNum, user = user, last_heard = 1000, channel = 0)
nodeManager.installNodeInfo(info)
val result = nodeManager.nodeDBbyNodeNum[nodeNum]!!
assertEquals(ByteString.EMPTY, result.publicKey)
assertEquals(ByteString.EMPTY, result.user.public_key)
}
}