diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt index adf0b93dc..f2ef17e5c 100644 --- a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/database/DatabaseManager.kt @@ -33,12 +33,17 @@ interface DatabaseManager { suspend fun switchActiveDatabase(address: String?) /** - * Associates the currently-active connection with the physical node [nodeNum] learned from the radio handshake, so - * the same node reached over a different transport (BLE / TCP / USB) shares one database. The first transport to - * report a node claims the active DB for it; a later transport folds its own data in and switches to the claimed - * DB. Idempotent once a node/transport pair has been unified. + * Associates the currently-active connection with the physical device learned from the radio handshake, so the same + * device reached over a different transport (BLE / TCP / USB) shares one database. The first transport to report + * the device claims the active DB for it; a later transport folds its own data in and switches to the claimed DB. + * Idempotent once a device/transport pair has been unified. + * + * [deviceId] is the factory-burned hardware id from MyNodeInfo (null/blank when the platform or firmware doesn't + * report one, or when lockdown zeroes it). It is the preferred claim key because it survives the node-number + * changes firmware 2.8 introduced (`my_node_num = crc32(public_key)` renumbers on upgrade, erase, and re-key); + * [nodeNum] remains the fallback claim for hardware without a device id and for claims made by older app versions. */ - suspend fun associateNode(nodeNum: Int) + suspend fun associateDevice(nodeNum: Int, deviceId: String?) /** Returns true if a database exists for the given device address. */ fun hasDatabaseFor(address: String?): Boolean diff --git a/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Crc32.kt b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Crc32.kt new file mode 100644 index 000000000..64d2a9d04 --- /dev/null +++ b/core/common/src/commonMain/kotlin/org/meshtastic/core/common/util/Crc32.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import okio.ByteString + +/** + * CRC-32 (IEEE 802.3 / zlib): reflected polynomial 0xEDB88320, initial value and final XOR 0xFFFFFFFF. + * + * Matches the firmware's `crc32Buffer` (ErriezCRC32), which firmware 2.8+ uses to derive the node number from the + * device public key: `my_node_num = crc32(config.security.public_key)` (NodeDB::createNewIdentity). Lets the app + * recognize a pubkey-derived ("canonical") node number when a node reappears under a new num after a firmware upgrade. + */ +object Crc32 { + private const val POLYNOMIAL: UInt = 0xEDB88320u + private const val INITIAL: UInt = 0xFFFFFFFFu + private const val BITS_PER_BYTE = 8 + private const val BYTE_MASK: UInt = 0xFFu + private const val TABLE_SIZE = 256 + + private val table = + UIntArray(TABLE_SIZE) { index -> + var crc = index.toUInt() + repeat(BITS_PER_BYTE) { crc = if (crc and 1u != 0u) (crc shr 1) xor POLYNOMIAL else crc shr 1 } + crc + } + + fun compute(bytes: ByteString): UInt { + var crc = INITIAL + for (i in 0 until bytes.size) { + val byte = bytes[i].toUInt() and BYTE_MASK + crc = (crc shr BITS_PER_BYTE) xor table[((crc xor byte) and BYTE_MASK).toInt()] + } + return crc.inv() + } +} + +fun ByteString.crc32(): UInt = Crc32.compute(this) diff --git a/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/Crc32Test.kt b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/Crc32Test.kt new file mode 100644 index 000000000..6458f10f7 --- /dev/null +++ b/core/common/src/commonTest/kotlin/org/meshtastic/core/common/util/Crc32Test.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.common.util + +import okio.ByteString +import okio.ByteString.Companion.encodeUtf8 +import okio.ByteString.Companion.toByteString +import kotlin.test.Test +import kotlin.test.assertEquals + +class Crc32Test { + + @Test + fun matchesStandardCheckValue() { + assertEquals(0xCBF43926u, "123456789".encodeUtf8().crc32()) + } + + @Test + fun matchesZlibForAsciiText() { + assertEquals(0x414FA339u, "The quick brown fox jumps over the lazy dog".encodeUtf8().crc32()) + } + + @Test + fun matchesZlibForKeySizedBuffer() { + val key = ByteArray(32) { it.toByte() }.toByteString() + assertEquals(0x91267E8Au, key.crc32()) + } + + @Test + fun emptyInputYieldsZero() { + assertEquals(0u, ByteString.EMPTY.crc32()) + } +} diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt index 4e8380983..2506d38fa 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/NodeInfoWriteDataSource.kt @@ -23,7 +23,8 @@ import org.meshtastic.core.database.entity.NodeEntity interface NodeInfoWriteDataSource { suspend fun upsert(node: NodeEntity) - suspend fun installConfig(mi: MyNodeEntity, nodes: List) + /** @return node numbers removed by identity migration (see `NodeInfoDao.installConfig`). */ + suspend fun installConfig(mi: MyNodeEntity, nodes: List): List suspend fun clearNodeDB(preserveFavorites: Boolean) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt index 61be88500..4a85eb70a 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/datasource/SwitchingNodeInfoWriteDataSource.kt @@ -34,9 +34,14 @@ class SwitchingNodeInfoWriteDataSource( withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().upsert(node) } } } - override suspend fun installConfig(mi: MyNodeEntity, nodes: List) { - withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } } - } + override suspend fun installConfig(mi: MyNodeEntity, nodes: List): List = + withContext(dispatchers.io) { + // Throw rather than no-op when no database is available: the config-flow manager treats a + // successful install as "node DB ready → Connected", and its catch runs the transport + // recovery path — silently skipping the install would fake a healthy connection. + dbManager.withDb { it.nodeInfoDao().installConfig(mi, nodes) } + ?: error("Node DB install skipped: no active database") + } override suspend fun clearNodeDB(preserveFavorites: Boolean) { withContext(dispatchers.io) { dbManager.withDb { it.nodeInfoDao().clearNodeInfo(preserveFavorites) } } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt index a0cd86a3d..c029ac717 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImpl.kt @@ -206,7 +206,13 @@ class MeshConfigFlowManagerImpl( scope.handledLaunch { try { - nodeRepository.installConfig(info, entities) + val removedNums = nodeRepository.installConfig(info, entities) + if (removedNums.isNotEmpty()) { + // Identity migration dropped stale rows (e.g. the device renumbered after a firmware + // 2.8 upgrade); evict them from the in-memory index so lookups can't resurrect them. + Logger.i { "Config install migrated ${removedNums.size} stale node identit(y/ies)" } + removedNums.forEach(nodeManager::removeByNodenum) + } } catch (e: CancellationException) { throw e } catch (@Suppress("TooGenericExceptionCaught") e: Exception) { @@ -238,6 +244,11 @@ class MeshConfigFlowManagerImpl( // Transition to Stage 1, discarding any stale data from a prior interrupted handshake. handshakeState.value = HandshakeState.ReceivingConfig(rawMyNodeInfo = myInfo) + // Device id before node num: RadioControllerImpl gates its DB association on a non-null num, + // so ordering this way guarantees the association never fires with a stale device id. + // Hex, not utf8: device_id is raw hardware bytes, and a lossy decode could collapse two + // distinct devices into the same id. + nodeManager.setMyDeviceId(myInfo.device_id.hex().takeIf { it.isNotBlank() }) nodeManager.setMyNodeNum(myInfo.my_node_num) nodeManager.setFirmwareEdition(myInfo.firmware_edition) applyEventFirmwareNotificationDefaults(myInfo.firmware_edition) @@ -335,7 +346,8 @@ class MeshConfigFlowManagerImpl( hasWifi = metadata?.hasWifi == true, channelUtilization = 0f, airUtilTx = 0f, - deviceId = device_id.utf8(), + // Hex, not utf8: device_id is raw hardware bytes (see setMyDeviceId above). + deviceId = device_id.hex().ifEmpty { null }, pioEnv = pio_env.ifEmpty { null }, ) } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 0b24b3004..ced03a830 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -124,6 +124,12 @@ class NodeManagerImpl( myNodeNum.value = num } + override val myDeviceId = MutableStateFlow(null) + + override fun setMyDeviceId(id: String?) { + myDeviceId.value = id + } + override val firmwareEdition = MutableStateFlow(null) override fun setFirmwareEdition(edition: FirmwareEdition?) { @@ -149,6 +155,7 @@ class NodeManagerImpl( isNodeDbReady.value = false allowNodeDbWrites.value = false myNodeNum.value = null + myDeviceId.value = null firmwareEdition.value = null } diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index d3d883a83..34e56c214 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt @@ -198,7 +198,7 @@ class NodeRepositoryImpl( withContext(dispatchers.io) { nodeInfoWriteDataSource.upsert(node.toEntity()) } /** Installs initial configuration data (local info and remote nodes) into the database. */ - override suspend fun installConfig(mi: MyNodeInfo, nodes: List) = withContext(dispatchers.io) { + override suspend fun installConfig(mi: MyNodeInfo, nodes: List): List = withContext(dispatchers.io) { nodeInfoWriteDataSource.installConfig(mi.toEntity(), nodes.map { it.toEntity() }) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt index 6dd40e464..070e55381 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/MeshConfigFlowManagerImplTest.kt @@ -35,6 +35,7 @@ import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import okio.ByteString.Companion.encodeUtf8 +import okio.ByteString.Companion.toByteString import org.meshtastic.core.model.ConnectionState import org.meshtastic.core.repository.CommandSender import org.meshtastic.core.repository.HandshakeConstants @@ -102,6 +103,9 @@ class MeshConfigFlowManagerImplTest { every { nodeManager.myNodeNum } returns MutableStateFlow(null) every { notificationPrefs.nodeEventsAutoDisabledForEvent } returns MutableStateFlow(false) every { notificationPrefs.nodeEventsEnabled } returns MutableStateFlow(true) + // autofill returns null for List, which would NPE the non-null return; individual + // tests override this stub where they exercise install failures or migration results. + everySuspend { nodeRepository.installConfig(any(), any()) } returns emptyList() manager = MeshConfigFlowManagerImpl( @@ -128,6 +132,25 @@ class MeshConfigFlowManagerImplTest { verify { nodeManager.setMyNodeNum(myNodeNum) } } + @Test + fun `handleMyInfo hex-encodes raw device_id bytes losslessly`() = testScope.runTest { + // device_id is raw hardware bytes, not text: a lossy utf8 decode would collapse distinct + // ids into the same replacement-character string. 0xFF bytes are invalid UTF-8 on purpose. + val rawId = byteArrayOf(0xFF.toByte(), 0x00, 0xA1.toByte(), 0xB2.toByte()).toByteString() + manager.handleMyInfo(protoMyNodeInfo.copy(device_id = rawId)) + advanceUntilIdle() + + verify { nodeManager.setMyDeviceId("ff00a1b2") } + } + + @Test + fun `handleMyInfo reports an absent device_id as null`() = testScope.runTest { + manager.handleMyInfo(protoMyNodeInfo.copy(device_id = okio.ByteString.EMPTY)) + advanceUntilIdle() + + verify { nodeManager.setMyDeviceId(null) } + } + @Test fun `handleMyInfo clears persisted radio config`() = testScope.runTest { manager.handleMyInfo(protoMyNodeInfo) @@ -737,7 +760,11 @@ class MeshConfigFlowManagerImplTest { // async DB install entry point are observed. The order is the invariant under test. val callOrder = mutableListOf() every { connectionManager.onHandshakeComplete() } calls { callOrder.add("handshakeComplete") } - everySuspend { nodeRepository.installConfig(any(), any()) } calls { callOrder.add("installConfig") } + everySuspend { nodeRepository.installConfig(any(), any()) } calls + { + callOrder.add("installConfig") + emptyList() + } manager.handleMyInfo(protoMyNodeInfo) advanceUntilIdle() diff --git a/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoTest.kt b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoTest.kt new file mode 100644 index 000000000..64c3ccabb --- /dev/null +++ b/core/database/src/androidHostTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoTest.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +@Config(sdk = [34]) +class NodeIdentityMigrationDaoTest : CommonNodeIdentityMigrationDaoTest() diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt index 589bceb77..bcc498495 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseConstants.kt @@ -32,8 +32,11 @@ object DatabaseConstants { const val LEGACY_DB_CLEANED_KEY: String = "legacy_db_cleaned" - // Cross-transport unification: map a node / transport address to the DB file that node's data lives in. - // Keys are dynamic (suffixed with the node num / normalized address), mirroring the `db_last_used:` pattern. + // Cross-transport unification: map a device / node / transport address to the DB file its data lives in. + // Keys are dynamic (suffixed with the hex device id / node num / normalized address), mirroring the + // `db_last_used:` pattern. The device-id key is preferred; the node-num key is the legacy fallback because + // firmware 2.8 made node numbers unstable (renumbered on upgrade/erase/re-key). + const val DEVICE_DB_FOR_PREFIX: String = "device_db_for:" const val NODE_DB_FOR_PREFIX: String = "node_db_for:" const val ADDR_DB_FOR_PREFIX: String = "addr_db_for:" diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt index ff17c479a..46c040b6f 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseManager.kt @@ -74,8 +74,6 @@ open class DatabaseManager( private fun addrDbKey(address: String?) = stringPreferencesKey("${DatabaseConstants.ADDR_DB_FOR_PREFIX}${normalizeAddress(address)}") - private fun nodeDbKey(nodeNum: Int) = stringPreferencesKey("${DatabaseConstants.NODE_DB_FOR_PREFIX}$nodeNum") - private var backfillJob: Job? = null @Volatile private var hasDelayedFirstDeviceBackfill = false @@ -114,7 +112,7 @@ open class DatabaseManager( /** * Name of the currently active database. Tracked explicitly rather than recomputed from the address, because - * cross-transport aliasing ([associateNode]) decouples the two: a secondary transport's address maps to the DB + * cross-transport aliasing ([associateDevice]) decouples the two: a secondary transport's address maps to the DB * claimed by the first transport, which `buildDbName(address)` would never produce. Written under [mutex]. */ @Volatile private var currentDbName: String = DatabaseConstants.DEFAULT_DB_NAME @@ -136,7 +134,7 @@ open class DatabaseManager( * Resolves the DB name to use for [address], honoring a cross-transport alias when one exists. A secondary * transport (e.g. TCP) that has been unified with a node points at the DB the first transport (e.g. BLE) claimed; * without an alias this falls back to the address-hashed name — today's default — for a first-time or primary - * connection. See [associateNode]. + * connection. See [associateDevice]. */ private suspend fun resolveDbName(address: String?): String { val fallback = buildDbName(address) @@ -195,24 +193,41 @@ open class DatabaseManager( } @Suppress("TooGenericExceptionCaught") - override suspend fun associateNode(nodeNum: Int) { + override suspend fun associateDevice(nodeNum: Int, deviceId: String?) { mutex.withLock { val sourceName = currentDbName // Never claim or merge into the sentinel "no device" DB. if (sourceName == DatabaseConstants.DEFAULT_DB_NAME) return@withLock - val claimed = datastore.data.first()[nodeDbKey(nodeNum)] + // The device-id claim is the durable one (node numbers renumber under firmware 2.8); the + // node-num claim stays as the fallback for hardware without a device id, for lockdown + // sessions (device_id zeroed), and for claims written by older app versions. Writes always + // refresh both keys so either lookup path resolves on the next connection. + val deviceKey = validDeviceIdOrNull(deviceId)?.let(::deviceDbPrefKey) + val nodeKey = nodeDbPrefKey(nodeNum) + val prefs = datastore.data.first() + val claimed = resolveDbClaim(prefs, deviceKey, nodeKey) + suspend fun writeClaims(dbName: String) = datastore.edit { + deviceKey?.let { key -> it[key] = dbName } + it[nodeKey] = dbName + } + when { claimed == null -> { - // First transport to learn this node: its current DB becomes the node's canonical DB. + // First transport to learn this device: its current DB becomes the device's canonical DB. // No address alias is needed — a primary connection already resolves to this DB via buildDbName. - datastore.edit { it[nodeDbKey(nodeNum)] = sourceName } + writeClaims(sourceName) Logger.i { "Claimed ${anonymizeDbName(sourceName)} as canonical DB for node $nodeNum" } } - claimed == sourceName -> Unit - - // Already unified — nothing to do. + claimed == sourceName -> { + // Already unified — just backfill/refresh any claim key that is missing or stale + // (e.g. the first connect after this device renumbered, or after an app update + // introduced device-id claims). + if ((deviceKey != null && prefs[deviceKey] != sourceName) || prefs[nodeKey] != sourceName) { + writeClaims(sourceName) + } + } else -> { // Secondary transport reached an already-known node: fold this DB into the canonical one, @@ -243,6 +258,9 @@ open class DatabaseManager( } markLastUsed(claimed) + // Refresh both claim keys (migrates a legacy nodeNum-only claim forward to the + // device-id key) and alias this transport's address to the canonical DB. + writeClaims(claimed) datastore.edit { it[addrDbKey(_currentAddress.value)] = claimed } Logger.i { "Unified ${anonymizeDbName(sourceName)} into ${anonymizeDbName(claimed)} for node $nodeNum" diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt index 630b7c142..41b107dd8 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DatabaseMerger.kt @@ -22,7 +22,7 @@ import co.touchlab.kermit.Logger /** * Folds the contents of one device database ([source]) into another ([dest]) when both turn out to belong to the same - * physical node reached over different transports (BLE / TCP / USB). Called once, by [DatabaseManager.associateNode], + * physical node reached over different transports (BLE / TCP / USB). Called once, by [DatabaseManager.associateDevice], * the first time a secondary transport learns a `myNodeNum` that another transport already claimed. * * Every per-device table is unified so switching transport is seamless: messages (+FTS), reactions, contact mute/read @@ -39,7 +39,7 @@ object DatabaseMerger { suspend fun merge(source: MeshtasticDatabase, dest: MeshtasticDatabase) { // All destination writes run in a single transaction so a crash or exception mid-merge rolls // back cleanly instead of leaving `dest` half-merged. This also makes a retried merge safe: if - // associateNode re-runs (e.g. a crash before the address alias is persisted), the rolled-back + // associateDevice re-runs (e.g. a crash before the address alias is persisted), the rolled-back // partial writes never happened, so the fresh-id packet/discovery re-inserts can't duplicate. // Reads from `source` use its own connection pool and don't participate in this transaction. var packets = 0 diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DeviceIdentity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DeviceIdentity.kt new file mode 100644 index 000000000..09be38e6d --- /dev/null +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/DeviceIdentity.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.stringPreferencesKey +import okio.ByteString.Companion.encodeUtf8 +import org.meshtastic.core.database.entity.MyNodeEntity + +/** Ingestion hex-encodes the 16-byte `device_id`; require that shape so lossy legacy values can't be compared. */ +private val HEX_DEVICE_ID = Regex("[0-9a-fA-F]{16,64}") + +/** + * A device id usable for hardware-identity comparison; null when absent, blank, the legacy placeholder, or not in the + * hex form current ingestion produces (older app versions persisted a lossy utf8 decode of the raw bytes — those values + * can collide across devices, so they are treated as absent rather than compared). The id is the factory-burned silicon + * identifier from `MyNodeInfo.device_id` — stable across firmware upgrades, erases, and key changes — but not reported + * by all hardware (e.g. classic ESP32) and deliberately zeroed for unauthenticated clients in lockdown mode, so callers + * must always tolerate null. + */ +internal fun validDeviceIdOrNull(id: String?): String? = + id?.takeIf { it != MyNodeEntity.DEVICE_ID_UNKNOWN && it.matches(HEX_DEVICE_ID) } + +/** Datastore key mapping a node number to its canonical DB. Legacy: node numbers renumber under firmware 2.8. */ +internal fun nodeDbPrefKey(nodeNum: Int): Preferences.Key = + stringPreferencesKey("${DatabaseConstants.NODE_DB_FOR_PREFIX}$nodeNum") + +/** + * Datastore key mapping a factory-burned device id to its canonical DB. The id is hex-encoded: it originates as raw + * hardware bytes decoded lossily into a string, so normalize to printable key material. + */ +internal fun deviceDbPrefKey(deviceId: String): Preferences.Key = + stringPreferencesKey("${DatabaseConstants.DEVICE_DB_FOR_PREFIX}${deviceId.encodeUtf8().hex()}") + +/** + * Resolves which canonical DB the connected device already claimed: the hardware-stable device-id claim wins, the + * node-number claim is the fallback for hardware without a device id and for claims written by older app versions. + */ +internal fun resolveDbClaim( + prefs: Preferences, + deviceKey: Preferences.Key?, + nodeKey: Preferences.Key, +): String? = deviceKey?.let { prefs[it] } ?: prefs[nodeKey] diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt index 45ca07f48..bac2ca4e4 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/dao/NodeInfoDao.kt @@ -23,10 +23,13 @@ import androidx.room3.Transaction import androidx.room3.Upsert import kotlinx.coroutines.flow.Flow import okio.ByteString +import org.meshtastic.core.common.util.crc32 import org.meshtastic.core.database.entity.MetadataEntity import org.meshtastic.core.database.entity.MyNodeEntity import org.meshtastic.core.database.entity.NodeEntity import org.meshtastic.core.database.entity.NodeWithRelations +import org.meshtastic.core.database.validDeviceIdOrNull +import org.meshtastic.core.model.NodeAddress import org.meshtastic.core.model.mergePowerChannelLabel import org.meshtastic.proto.HardwareModel @@ -74,7 +77,12 @@ interface NodeInfoDao { } } - /** Validates a new node before it is inserted into the database. */ + /** + * Validates a new node before it is inserted into the database. This is the mesh-time (single upsert) path: anyone + * can broadcast a NodeInfo, so a known key arriving under a different num is conservatively mapped back to the + * existing identity — even when the num is the canonical crc32-derived one. Renumber migration happens only in + * [installConfig], where the entries come from the connected device's own vetted node DB over the local link. + */ private suspend fun handleNewNodeUpsertValidation(newNode: NodeEntity): NodeEntity { // Check if the new node's public key (if present and not empty) // is already claimed by another existing node. @@ -90,6 +98,41 @@ interface NodeInfoDao { return newNode } + /** + * True when this node's num is the canonical pubkey-derived number introduced by firmware 2.8 (`my_node_num = + * crc32(public_key)`, see NodeDB::createNewIdentity). A canonical num proves the num/key pairing was not chosen + * independently of the key, so a same-key-new-num sighting is a renumber rather than a spoof. Only consulted for + * entries streamed by the connected device during a config install (trusted local link), never for mesh packets. + */ + private fun NodeEntity.hasCanonicalNum(): Boolean { + val key = publicKey ?: user.public_key + return key.size == KEY_SIZE && key != NodeEntity.ERROR_BYTE_STRING && key.crc32().toInt() == num + } + + /** + * Moves a node's identity from the [from] row to the incoming [to] entity after a legitimate renumber (the local + * device reconciled by [migrateLocalNodeIdentity], or a mesh peer whose new num is its canonical pubkey-derived + * number). Carries the app-local state the mesh doesn't broadcast, drops the stale row, and re-keys the DM thread + * so the conversation follows the node. The caller upserts [to] afterwards. + */ + private suspend fun migrateNodeIdentity(from: NodeEntity, to: NodeEntity) { + if (to.notes.isBlank()) to.notes = from.notes + if (to.powerChannelLabels.isEmpty()) to.powerChannelLabels = from.powerChannelLabels + to.manuallyVerified = to.manuallyVerified || from.manuallyVerified + to.isFavorite = to.isFavorite || from.isFavorite + to.isIgnored = to.isIgnored || from.isIgnored + to.isMuted = to.isMuted || from.isMuted + deleteNode(from.num) + deleteMetadata(from.num) + val oldId = from.user.id.ifEmpty { NodeAddress.numToDefaultId(from.num) } + val newId = to.user.id.ifEmpty { NodeAddress.numToDefaultId(to.num) } + if (oldId != newId) { + remapDmContactKey(oldId, newId) + remapDmContactSettings(oldId, newId) + deleteDmContactSettings(oldId) + } + } + /** * Resolves the public key for an existing node during an update. * @@ -141,9 +184,18 @@ interface NodeInfoDao { * updating telemetry and status fields from the incoming packet. * 2. **Update**: If it's a normal update, we validate the public key using [resolvePublicKey] to prevent conflicts * or accidental key wiping, and then update the node. + * + * [trustIncomingKey] skips the mismatch check and accepts a valid incoming key as-is. Set only for the local node + * during a config install: the connected device is authoritative for its own key over the local link, and an + * erase-and-reflash legitimately re-keys it (a mismatch there would otherwise poison the local node with + * [NodeEntity.ERROR_BYTE_STRING] and break PKI traffic until app data is cleared). */ @Suppress("CyclomaticComplexMethod", "MagicNumber") - private fun handleExistingNodeUpsertValidation(existingNode: NodeEntity, incomingNode: NodeEntity): NodeEntity { + private fun handleExistingNodeUpsertValidation( + existingNode: NodeEntity, + incomingNode: NodeEntity, + trustIncomingKey: Boolean = false, + ): NodeEntity { val resolvedNotes = incomingNode.notes.ifBlank { existingNode.notes } val resolvedPowerChannelLabels = incomingNode.powerChannelLabels.ifEmpty { existingNode.powerChannelLabels } @@ -163,7 +215,12 @@ interface NodeInfoDao { ) } - val resolvedKey = resolvePublicKey(existingNode, incomingNode) + val resolvedKey = + if (trustIncomingKey && (incomingNode.publicKey?.size ?: 0) == KEY_SIZE) { + incomingNode.publicKey + } else { + resolvePublicKey(existingNode, incomingNode) + } return incomingNode.copy( user = incomingNode.user.copy(public_key = resolvedKey ?: ByteString.EMPTY), @@ -337,9 +394,17 @@ interface NodeInfoDao { /** * Batch version of [getVerifiedNodeForUpsert]. Pre-fetches all existing nodes and public-key conflicts in two * queries instead of N individual queries, then processes each node in memory. + * + * [selfNum] is the connected device's node number during a config install; its key updates are trusted (see + * [handleExistingNodeUpsertValidation]) and a key conflict against it always migrates. Node numbers whose rows were + * migrated away are appended to [removedNums]. */ @Suppress("NestedBlockDepth") - private suspend fun getVerifiedNodesForUpsert(incomingNodes: List): List { + private suspend fun getVerifiedNodesForUpsert( + incomingNodes: List, + selfNum: Int? = null, + removedNums: MutableList = mutableListOf(), + ): List { // Prepare all incoming nodes (populate denormalized fields) incomingNodes.forEach { node -> node.publicKey = node.user.public_key @@ -366,7 +431,7 @@ interface NodeInfoDao { for (incoming in incomingNodes) { val existing = existingNodesMap[incoming.num] if (existing != null) { - result.add(handleExistingNodeUpsertValidation(existing, incoming)) + result.add(handleExistingNodeUpsertValidation(existing, incoming, incoming.num == selfNum)) } else { newNodes.add(incoming) } @@ -388,7 +453,16 @@ interface NodeInfoDao { if ((newNode.publicKey?.size ?: 0) > 0) { val conflicting = pkConflicts[newNode.publicKey] if (conflicting != null && conflicting.num != newNode.num) { - result.add(conflicting) + // Same key under a different num. Migrate when this is the connected device itself + // (device-authoritative over the local link) or the num is the canonical crc32(key) + // number (firmware 2.8 renumber); otherwise keep the existing identity. + if (newNode.num == selfNum || newNode.hasCanonicalNum()) { + migrateNodeIdentity(from = conflicting, to = newNode) + removedNums += conflicting.num + result.add(newNode) + } else { + result.add(conflicting) + } } else { result.add(newNode) } @@ -397,14 +471,94 @@ interface NodeInfoDao { } } + // Drop batch entries for identities that were migrated away — a device mid-transition can + // still list the old num alongside the new one, and re-inserting it would resurrect the ghost. + result.removeAll { it.num in removedNums } return result } + @Query("SELECT * FROM my_node LIMIT 1") + suspend fun getMyNodeEntity(): MyNodeEntity? + + /** Re-scopes stored messages from one owning node number to another (see `Packet.myNodeNum`). */ + @Query("UPDATE packet SET myNodeNum = :newNum WHERE myNodeNum = :oldNum") + suspend fun remapPacketOwner(oldNum: Int, newNum: Int) + + /** OR IGNORE: myNodeNum is part of the reactions PK; drop the rare duplicate instead of aborting. */ + @Query("UPDATE OR IGNORE reactions SET myNodeNum = :newNum WHERE myNodeNum = :oldNum") + suspend fun remapReactionOwner(oldNum: Int, newNum: Int) + + @Query("DELETE FROM reactions WHERE myNodeNum = :oldNum") + suspend fun deleteReactionsForOwner(oldNum: Int) + + /** Contact keys are `"$channel$userId"`, so a suffix match rewrites just the DM threads of one node. */ + @Query("UPDATE packet SET contact_key = replace(contact_key, :oldId, :newId) WHERE contact_key LIKE '%' || :oldId") + suspend fun remapDmContactKey(oldId: String, newId: String) + + @Query( + "UPDATE OR IGNORE contact_settings SET contact_key = replace(contact_key, :oldId, :newId) " + + "WHERE contact_key LIKE '%' || :oldId", + ) + suspend fun remapDmContactSettings(oldId: String, newId: String) + + @Query("DELETE FROM contact_settings WHERE contact_key LIKE '%' || :oldId") + suspend fun deleteDmContactSettings(oldId: String) + + /** + * Reconciles a change of the connected device's node number before a config install replaces `my_node`. + * + * This database was selected by transport address, so a new node number arriving at the same address is, by + * default, the same physical device under a new identity: a firmware 2.8 upgrade (`my_node_num` became + * `crc32(public_key)` instead of the MAC-derived number), an erase-and-reflash, a manual key regeneration or import + * — all of which renumber the device. Message history and app-local node state follow the device. + * + * The public key is deliberately NOT used to establish continuity here: keys can be regenerated or imported by the + * user, and under firmware 2.8 every key change is also a renumber. The only veto is a hardware one — when both + * sessions reported a factory-burned device id ([MyNodeEntity.deviceId]) and they differ, this is provably + * different hardware (e.g. a reused TCP address), so the old history stays scoped under the old number instead of + * leaking to the new device. + */ + private suspend fun migrateLocalNodeIdentity(previous: MyNodeEntity, mi: MyNodeEntity, incomingSelf: NodeEntity?) { + val oldNum = previous.myNodeNum + val newNum = mi.myNodeNum + val oldDeviceId = validDeviceIdOrNull(previous.deviceId) + val newDeviceId = validDeviceIdOrNull(mi.deviceId) + val differentHardware = oldDeviceId != null && newDeviceId != null && oldDeviceId != newDeviceId + + val oldSelf = getNodeByNum(oldNum)?.node + if (!differentHardware) { + remapPacketOwner(oldNum, newNum) + remapReactionOwner(oldNum, newNum) + deleteReactionsForOwner(oldNum) + } + if (!differentHardware && oldSelf != null && incomingSelf != null) { + migrateNodeIdentity(from = oldSelf, to = incomingSelf) + } else { + // Nothing to carry over — just drop the stale self row so it can't shadow the new identity. + deleteNode(oldNum) + deleteMetadata(oldNum) + } + } + + /** + * Installs the config download from the connected device, reconciling any node-number change of the device itself + * (firmware 2.8 renumber, erase-and-reflash, manual re-key) before the new `my_node` row lands. + * + * @return node numbers whose rows were removed by identity migration, so callers can evict them from in-memory + * caches. + */ @Transaction - suspend fun installConfig(mi: MyNodeEntity, nodes: List) { + suspend fun installConfig(mi: MyNodeEntity, nodes: List): List { + val removedNums = mutableListOf() + val previous = getMyNodeEntity() + if (previous != null && previous.myNodeNum != mi.myNodeNum) { + migrateLocalNodeIdentity(previous, mi, nodes.find { it.num == mi.myNodeNum }) + removedNums += previous.myNodeNum + } clearMyNodeInfo() setMyNodeInfo(mi) - putAll(getVerifiedNodesForUpsert(nodes)) + putAll(getVerifiedNodesForUpsert(nodes, selfNum = mi.myNodeNum, removedNums = removedNums)) + return removedNums } /** diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt index a55e2232f..28caf4645 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/MyNodeEntity.kt @@ -33,9 +33,14 @@ open class MyNodeEntity( val minAppVersion: Int, val maxChannels: Int, val hasWifi: Boolean, - val deviceId: String? = "unknown", + val deviceId: String? = DEVICE_ID_UNKNOWN, val pioEnv: String? = null, ) { + companion object { + /** Placeholder for hardware that doesn't report a factory-burned device id. */ + const val DEVICE_ID_UNKNOWN = "unknown" + } + /** A human readable description of the software/hardware version */ val firmwareString: String get() = "$model $firmwareVersion" diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DeviceIdentityTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DeviceIdentityTest.kt new file mode 100644 index 000000000..fcf394e71 --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/DeviceIdentityTest.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database + +import androidx.datastore.preferences.core.preferencesOf +import org.meshtastic.core.database.entity.MyNodeEntity +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** Covers the cross-transport DB-claim helpers behind [DatabaseManager.associateDevice]. */ +class DeviceIdentityTest { + + @Test + fun validDeviceIdRejectsAbsentBlankPlaceholderAndNonHexForms() { + assertNull(validDeviceIdOrNull(null)) + assertNull(validDeviceIdOrNull("")) + assertNull(validDeviceIdOrNull(" ")) + assertNull(validDeviceIdOrNull(MyNodeEntity.DEVICE_ID_UNKNOWN)) + // Legacy app versions persisted a lossy utf8 decode of the raw bytes — such values can + // collide across devices and must be treated as absent, not compared. + assertNull(validDeviceIdOrNull("��legacy id")) + assertNull(validDeviceIdOrNull("abcdef")) // too short to be a real 16-byte id + val hexId = "a1b2c3d4e5f60718a9b0c1d2e3f40516" + assertEquals(hexId, validDeviceIdOrNull(hexId)) + } + + @Test + fun deviceKeyIsDeterministicAndDistinctPerDevice() { + assertEquals(deviceDbPrefKey("hw-A"), deviceDbPrefKey("hw-A")) + assertTrue(deviceDbPrefKey("hw-A") != deviceDbPrefKey("hw-B")) + // Raw device-id bytes decode lossily to strings; the key must stay printable regardless. + val keyName = deviceDbPrefKey("\uFFFD id").name + assertTrue(keyName.startsWith(DatabaseConstants.DEVICE_DB_FOR_PREFIX)) + assertTrue(keyName.drop(DatabaseConstants.DEVICE_DB_FOR_PREFIX.length).all { it.isLetterOrDigit() }) + } + + @Test + fun deviceClaimIsPreferredOverNodeClaim() { + val deviceKey = deviceDbPrefKey("hw-A") + val nodeKey = nodeDbPrefKey(100) + val prefs = preferencesOf(deviceKey to "db_device", nodeKey to "db_node") + assertEquals("db_device", resolveDbClaim(prefs, deviceKey, nodeKey)) + } + + @Test + fun nodeClaimIsTheFallback() { + val deviceKey = deviceDbPrefKey("hw-A") + val nodeKey = nodeDbPrefKey(100) + val legacyOnly = preferencesOf(nodeKey to "db_node") + // Device id available but only a legacy nodeNum claim exists (pre-device-id install). + assertEquals("db_node", resolveDbClaim(legacyOnly, deviceKey, nodeKey)) + // No device id at all (classic ESP32, lockdown, old firmware). + assertEquals("db_node", resolveDbClaim(legacyOnly, deviceKey = null, nodeKey = nodeKey)) + } + + @Test + fun renumberedDeviceStillResolvesItsClaimViaDeviceKey() { + val deviceKey = deviceDbPrefKey("hw-A") + // Claims written before a firmware 2.8 renumber: device key + OLD node num key. + val prefs = preferencesOf(deviceKey to "db_claimed", nodeDbPrefKey(100) to "db_claimed") + // After the renumber the node key is new and unclaimed — the device key still resolves. + assertEquals("db_claimed", resolveDbClaim(prefs, deviceKey, nodeDbPrefKey(200))) + } + + @Test + fun unclaimedDeviceResolvesNothing() { + assertNull(resolveDbClaim(preferencesOf(), deviceDbPrefKey("hw-A"), nodeDbPrefKey(100))) + } +} diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt new file mode 100644 index 000000000..17758b41c --- /dev/null +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeIdentityMigrationDaoTest.kt @@ -0,0 +1,283 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +import kotlinx.coroutines.test.runTest +import okio.ByteString +import okio.ByteString.Companion.toByteString +import org.meshtastic.core.common.util.crc32 +import org.meshtastic.core.database.MeshtasticDatabase +import org.meshtastic.core.database.entity.MyNodeEntity +import org.meshtastic.core.database.entity.NodeEntity +import org.meshtastic.core.database.entity.Packet +import org.meshtastic.core.database.entity.ReactionEntity +import org.meshtastic.core.database.getInMemoryDatabaseBuilder +import org.meshtastic.core.model.DataPacket +import org.meshtastic.core.model.NodeAddress +import org.meshtastic.proto.HardwareModel +import org.meshtastic.proto.PortNum +import org.meshtastic.proto.User +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * Covers the device-identity reconciliation in [NodeInfoDao.installConfig]: firmware 2.8 renumbers every device + * (`my_node_num = crc32(public_key)`), and erases/manual re-keys renumber again — the stale identity must be migrated + * (or vetoed for provably different hardware) instead of shadowing the new one. + * + * Abstract, per repo convention: the androidHostTest subclass supplies the Robolectric runner (Room's android target + * needs real `android.database` exception classes for its upsert fallback), and a jvmTest subclass runs it on the JVM + * target. + */ +abstract class CommonNodeIdentityMigrationDaoTest { + + private lateinit var database: MeshtasticDatabase + private lateinit var dao: NodeInfoDao + private lateinit var packetDao: PacketDao + + // Ingestion hex-encodes device_id, and validDeviceIdOrNull requires that shape. + private val deviceIdA = "a1b2c3d4e5f60718a9b0c1d2e3f40516" + private val deviceIdB = "ffeeddccbbaa99887766554433221100" + + private val keyA = ByteArray(32) { 1 }.toByteString() + private val keyB = ByteArray(32) { 2 }.toByteString() + + private val oldNum = 100 + private val oldId = NodeAddress.numToDefaultId(oldNum) + + private fun myNodeEntity(num: Int, deviceId: String? = MyNodeEntity.DEVICE_ID_UNKNOWN) = MyNodeEntity( + myNodeNum = num, + model = "TBEAM", + firmwareVersion = "2.8.0", + couldUpdate = false, + shouldUpdate = false, + currentPacketId = 1L, + messageTimeoutMsec = 300000, + minAppVersion = 1, + maxChannels = 8, + hasWifi = false, + deviceId = deviceId, + ) + + private fun nodeEntity( + num: Int, + key: ByteString, + longName: String = "Node $num", + notes: String = "", + isFavorite: Boolean = false, + ) = NodeEntity( + num = num, + user = + User( + id = NodeAddress.numToDefaultId(num), + long_name = longName, + short_name = longName.takeLast(4), + hw_model = HardwareModel.TBEAM, + public_key = key, + ), + notes = notes, + isFavorite = isFavorite, + lastHeard = 1000, + ) + + private fun packet(owner: Int, contactKey: String) = Packet( + uuid = 0L, + myNodeNum = owner, + port_num = PortNum.TEXT_MESSAGE_APP.value, + contact_key = contactKey, + received_time = 1000L, + read = true, + data = + DataPacket( + to = NodeAddress.ID_BROADCAST, + bytes = "hello".encodeToByteArray().toByteString(), + dataType = PortNum.TEXT_MESSAGE_APP.value, + ), + ) + + private suspend fun createDb(myNode: MyNodeEntity) { + database = getInMemoryDatabaseBuilder().build() + dao = database.nodeInfoDao() + packetDao = database.packetDao() + dao.setMyNodeInfo(myNode) + } + + @AfterTest + fun closeDb() { + database.close() + } + + @Test + fun renumberMigratesHistoryAndLocalState() = runTest { + createDb(myNodeEntity(oldNum)) + dao.upsert(nodeEntity(oldNum, keyA, notes = "my notes", isFavorite = true)) + packetDao.insert(packet(oldNum, contactKey = "0$oldId")) // note-to-self thread + packetDao.insert(packet(oldNum, contactKey = "1!deadbeef")) // DM with a peer + packetDao.insert( + ReactionEntity(myNodeNum = oldNum, replyId = 7, userId = "!deadbeef", emoji = "x", timestamp = 1L), + ) + + val newNum = 200 + val newId = NodeAddress.numToDefaultId(newNum) + // Key changed too (manual regen / erase): continuity must not depend on the key. + val removed = dao.installConfig(myNodeEntity(newNum), listOf(nodeEntity(newNum, keyB))) + + assertEquals(listOf(oldNum), removed) + assertEquals(newNum, dao.getMyNodeEntity()?.myNodeNum) + assertNull(dao.getNodeByNum(oldNum), "stale self row must be dropped") + + val newSelf = assertNotNull(dao.getNodeByNum(newNum)).node + assertEquals("my notes", newSelf.notes, "app-local notes must follow the device") + assertTrue(newSelf.isFavorite, "favorite flag must follow the device") + + val packets = packetDao.getAllPacketsSnapshot() + assertTrue(packets.isNotEmpty()) + assertTrue(packets.all { it.myNodeNum == newNum }, "message history must be re-scoped to the new num") + assertTrue(packets.any { it.contact_key == "0$newId" }, "note-to-self thread must follow the new id") + assertTrue(packets.any { it.contact_key == "1!deadbeef" }, "peer DM threads must be untouched") + assertTrue(packetDao.getAllReactionsSnapshot().all { it.myNodeNum == newNum }) + } + + @Test + fun renumberDropsStaleSelfListedInSameBatch() = runTest { + createDb(myNodeEntity(oldNum)) + dao.upsert(nodeEntity(oldNum, keyA)) + + val newNum = 200 + // A device mid-transition can still stream its old identity alongside the new one. + val removed = + dao.installConfig(myNodeEntity(newNum), listOf(nodeEntity(oldNum, keyA), nodeEntity(newNum, keyA))) + + assertEquals(listOf(oldNum), removed) + assertNull(dao.getNodeByNum(oldNum), "old identity in the same batch must not be resurrected") + assertNotNull(dao.getNodeByNum(newNum)) + } + + @Test + fun sameHardwareEraseMigratesAcrossKeyChange() = runTest { + createDb(myNodeEntity(oldNum, deviceId = deviceIdA)) + dao.upsert(nodeEntity(oldNum, keyA, notes = "keep me")) + packetDao.insert(packet(oldNum, contactKey = "1!deadbeef")) + + val newNum = 300 + val removed = dao.installConfig(myNodeEntity(newNum, deviceId = deviceIdA), listOf(nodeEntity(newNum, keyB))) + + assertEquals(listOf(oldNum), removed) + assertNull(dao.getNodeByNum(oldNum)) + assertEquals("keep me", assertNotNull(dao.getNodeByNum(newNum)).node.notes) + assertTrue(packetDao.getAllPacketsSnapshot().all { it.myNodeNum == newNum }) + } + + @Test + fun differentHardwareVetoesHistoryMigration() = runTest { + createDb(myNodeEntity(oldNum, deviceId = deviceIdA)) + dao.upsert(nodeEntity(oldNum, keyA, notes = "old device")) + packetDao.insert(packet(oldNum, contactKey = "1!deadbeef")) + + val newNum = 300 + val removed = dao.installConfig(myNodeEntity(newNum, deviceId = deviceIdB), listOf(nodeEntity(newNum, keyB))) + + assertEquals(listOf(oldNum), removed) + assertNull(dao.getNodeByNum(oldNum), "stale self row is dropped even when history is not migrated") + val newSelf = assertNotNull(dao.getNodeByNum(newNum)).node + assertEquals("", newSelf.notes, "different hardware must not inherit app-local state") + assertTrue( + packetDao.getAllPacketsSnapshot().all { it.myNodeNum == oldNum }, + "history of different hardware stays scoped under the old num", + ) + } + + @Test + fun sameNumInstallLeavesEverythingInPlace() = runTest { + createDb(myNodeEntity(oldNum)) + dao.upsert(nodeEntity(oldNum, keyA, notes = "steady")) + packetDao.insert(packet(oldNum, contactKey = "1!deadbeef")) + + val removed = dao.installConfig(myNodeEntity(oldNum), listOf(nodeEntity(oldNum, keyA))) + + assertEquals(emptyList(), removed) + assertEquals("steady", assertNotNull(dao.getNodeByNum(oldNum)).node.notes) + assertTrue(packetDao.getAllPacketsSnapshot().all { it.myNodeNum == oldNum }) + } + + @Test + fun installTrustsDeviceReportedKeyChangeForSelf() = runTest { + createDb(myNodeEntity(oldNum)) + dao.upsert(nodeEntity(oldNum, keyA)) + + // Same num, new key (2.7-line erase keeps the MAC-derived num). Without the install-time + // trust this would poison the local node's key to ERROR_BYTE_STRING and break PKI traffic. + dao.installConfig(myNodeEntity(oldNum), listOf(nodeEntity(oldNum, keyB))) + + val self = assertNotNull(dao.getNodeByNum(oldNum)).node + assertEquals(keyB, self.publicKey) + } + + @Test + fun canonicalNeighborRenumberMigratesInInstallBatch() = runTest { + createDb(myNodeEntity(oldNum)) + val peerOldNum = 500 + val peerOldId = NodeAddress.numToDefaultId(peerOldNum) + dao.upsert(nodeEntity(peerOldNum, keyB, notes = "peer note", isFavorite = true)) + packetDao.insert(packet(oldNum, contactKey = "0$peerOldId")) + + val peerNewNum = keyB.crc32().toInt() // the firmware 2.8 canonical num for this key + val peerNewId = NodeAddress.numToDefaultId(peerNewNum) + dao.installConfig(myNodeEntity(oldNum), listOf(nodeEntity(oldNum, keyA), nodeEntity(peerNewNum, keyB))) + + assertNull(dao.getNodeByNum(peerOldNum), "peer's pre-2.8 row must be migrated away") + val migrated = assertNotNull(dao.getNodeByNum(peerNewNum)).node + assertEquals("peer note", migrated.notes) + assertTrue(migrated.isFavorite) + assertTrue( + packetDao.getAllPacketsSnapshot().any { it.contact_key == "0$peerNewId" }, + "the DM thread must follow the peer's new id", + ) + } + + @Test + fun nonCanonicalKeyConflictKeepsExistingIdentity() = runTest { + createDb(myNodeEntity(oldNum)) + val peerNum = 500 + dao.upsert(nodeEntity(peerNum, keyB)) + + val impostorNum = 600 // not crc32(keyB): an arbitrary num claiming a known key + dao.installConfig(myNodeEntity(oldNum), listOf(nodeEntity(impostorNum, keyB))) + + assertNotNull(dao.getNodeByNum(peerNum), "existing identity must be kept on a non-canonical conflict") + assertNull(dao.getNodeByNum(impostorNum), "non-canonical claimant must not be inserted") + } + + @Test + fun singleUpsertNeverMigratesEvenForCanonicalNum() = runTest { + createDb(myNodeEntity(oldNum)) + val peerOldNum = 500 + dao.upsert(nodeEntity(peerOldNum, keyB, isFavorite = true)) + + // Mesh-time upserts are unauthenticated: even a canonical crc32(key) num must not migrate + // identity — that only happens through installConfig (trusted local link). + val peerNewNum = keyB.crc32().toInt() + dao.upsert(nodeEntity(peerNewNum, keyB)) + + assertNotNull(dao.getNodeByNum(peerOldNum), "existing identity must be kept on mesh-time conflicts") + assertNull(dao.getNodeByNum(peerNewNum), "mesh-time claimant must not be inserted") + } +} diff --git a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoJvmTest.kt b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoJvmTest.kt new file mode 100644 index 000000000..62e2d59bf --- /dev/null +++ b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/dao/NodeIdentityMigrationDaoJvmTest.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.database.dao + +/** JVM-target run of the common identity-migration suite (the abstract base has no runner of its own). */ +class NodeIdentityMigrationDaoJvmTest : CommonNodeIdentityMigrationDaoTest() diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt index 6bf214f23..833d2a5ef 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeManager.kt @@ -56,6 +56,16 @@ interface NodeManager : NodeIdLookup { /** Sets the local node number. */ fun setMyNodeNum(num: Int?) + /** + * The connected device's factory-burned hardware id from MyNodeInfo, as a thread-safe [StateFlow]. Null when not + * connected or when the hardware/firmware doesn't report one. Unlike [myNodeNum] (which firmware 2.8 re-derives + * from the public key), this survives upgrades, erases, and key changes. + */ + val myDeviceId: StateFlow + + /** Sets the connected device's hardware id. */ + fun setMyDeviceId(id: String?) + /** The firmware edition reported by the connected device. */ val firmwareEdition: StateFlow diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt index e29dd04c5..833f40836 100644 --- a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt +++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/NodeRepository.kt @@ -173,9 +173,14 @@ interface NodeRepository { /** * Installs initial configuration data (local info and remote nodes) into the database. * - * Used during the initial connection handshake. + * Used during the initial connection handshake. When the connected device's identity changed since the last session + * (firmware 2.8 derives the node number from the public key, and an erase-and-reflash mints new keys), the stale + * identity is migrated or removed as part of the install. + * + * @return node numbers whose rows were removed by that identity migration, so callers can evict them from in-memory + * caches. */ - suspend fun installConfig(mi: MyNodeInfo, nodes: List) + suspend fun installConfig(mi: MyNodeInfo, nodes: List): List /** * Persists hardware metadata for a node. diff --git a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt index 2b7573760..c8187214d 100644 --- a/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt +++ b/core/service/src/commonMain/kotlin/org/meshtastic/core/service/RadioControllerImpl.kt @@ -91,14 +91,18 @@ class RadioControllerImpl( QueryController by QueryControllerImpl(commandSender, nodeManager, uiPrefs) { init { - // Unify per-node databases across transports. When the handshake reports our node number, tell the - // DatabaseManager to claim (or merge into) that node's canonical DB, so the same node reached over BLE, TCP, - // or USB shares one stored history. Keyed on (address, node) so a second transport for the same node re-fires - // even though the node number itself is unchanged. + // Unify per-device databases across transports. When the handshake reports our node number, tell the + // DatabaseManager to claim (or merge into) that device's canonical DB, so the same device reached over BLE, + // TCP, or USB shares one stored history. The hardware device id (when reported) is the durable claim key — + // firmware 2.8 renumbers devices (num = crc32(public_key)) on upgrade/erase/re-key — with the node number as + // fallback. Keyed on (address, node, device) so a second transport for the same device re-fires even though + // the identity itself is unchanged. scope.launch { - combine(meshPrefs.deviceAddress, nodeManager.myNodeNum) { address, nodeNum -> address to nodeNum } + combine(meshPrefs.deviceAddress, nodeManager.myNodeNum, nodeManager.myDeviceId) { address, nodeNum, devId -> + Triple(address, nodeNum, devId) + } .distinctUntilChanged() - .collect { (_, nodeNum) -> nodeNum?.let { databaseManager.associateNode(it) } } + .collect { (_, nodeNum, deviceId) -> nodeNum?.let { databaseManager.associateDevice(it, deviceId) } } } } diff --git a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt index c29387a45..caa0c77af 100644 --- a/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt +++ b/core/service/src/commonTest/kotlin/org/meshtastic/core/service/RadioControllerImplTest.kt @@ -89,6 +89,7 @@ class RadioControllerImplTest { myNodeNum: Int? = 1234, ): RadioControllerImpl { every { nodeManager.myNodeNum } returns MutableStateFlow(myNodeNum) + every { nodeManager.myDeviceId } returns MutableStateFlow(null) every { meshPrefs.deviceAddress } returns MutableStateFlow(null) return RadioControllerImpl( serviceRepository = serviceRepository, diff --git a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt index 65c404605..0cbdda725 100644 --- a/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt +++ b/core/takserver/src/commonTest/kotlin/org/meshtastic/core/takserver/TAKMeshIntegrationTest.kt @@ -322,7 +322,7 @@ class TAKMeshIntegrationTest { override suspend fun upsert(node: Node) {} - override suspend fun installConfig(mi: MyNodeInfo, nodes: List) {} + override suspend fun installConfig(mi: MyNodeInfo, nodes: List): List = emptyList() override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {} diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt index 5929cebef..190efb97b 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeDatabaseManager.kt @@ -30,6 +30,7 @@ class FakeDatabaseManager : var lastSwitchedAddress: String? = null var lastAssociatedNode: Int? = null + var lastAssociatedDeviceId: String? = null val existingDatabases = mutableSetOf() init { @@ -37,6 +38,7 @@ class FakeDatabaseManager : _cacheLimit.value = DEFAULT_CACHE_LIMIT lastSwitchedAddress = null lastAssociatedNode = null + lastAssociatedDeviceId = null existingDatabases.clear() } } @@ -51,8 +53,9 @@ class FakeDatabaseManager : lastSwitchedAddress = address } - override suspend fun associateNode(nodeNum: Int) { + override suspend fun associateDevice(nodeNum: Int, deviceId: String?) { lastAssociatedNode = nodeNum + lastAssociatedDeviceId = deviceId } override fun hasDatabaseFor(address: String?): Boolean = address != null && existingDatabases.contains(address) diff --git a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt index 15b4ad39e..3e9b01368 100644 --- a/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt +++ b/core/testing/src/commonMain/kotlin/org/meshtastic/core/testing/FakeNodeRepository.kt @@ -166,9 +166,10 @@ class FakeNodeRepository : _nodeDBbyNum.value = _nodeDBbyNum.value + (node.num to node) } - override suspend fun installConfig(mi: MyNodeInfo, nodes: List) { + override suspend fun installConfig(mi: MyNodeInfo, nodes: List): List { _myNodeInfo.value = mi _nodeDBbyNum.value = nodes.associateBy { it.num } + return emptyList() } override suspend fun insertMetadata(nodeNum: Int, metadata: DeviceMetadata) {