mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-09-20 18:58:05 -04:00
fix(node): keep a contact's public key when a different one arrives (#7118)
This commit is contained in:
1 parent
9db12c6e51
commit
8f4723cbb2
15 files changed
+2189
-50
No files matched your search
+10
-3
@@ -1127,14 +1127,21 @@ class NodeManagerImpl(
|
||||
node.copy(channel = channel, manuallyVerified = manuallyVerified)
|
||||
} else {
|
||||
val incomingKey = resolveValidatedPublicKeyHint(p.public_key)
|
||||
val sanitizedUser = if (incomingKey == null) p.copy(public_key = ByteString.EMPTY) else p
|
||||
// Prefer node.publicKey when valid (the authoritative stored key); fall back to node.user.public_key.
|
||||
val existingKey = resolveNodePublicKeyHint(node)
|
||||
val keyMatch = existingKey == null || existingKey == incomingKey
|
||||
val newUser = if (keyMatch) sanitizedUser else sanitizedUser.copy(public_key = ByteString.EMPTY)
|
||||
// Only two valid, different keys are a mismatch. A packet with no usable key says nothing about the one
|
||||
// on file, so it neither flags nor clears anything, and the stored key stays.
|
||||
val keyMismatch = existingKey != null && incomingKey != null && existingKey != incomingKey
|
||||
// First-wins, matching the DAO and the firmware: a different key for a node we already hold one for is
|
||||
// refused and recorded, never applied. Clearing the stored key here would break PKC direct messages to
|
||||
// that contact on the word of whoever sent the substitute.
|
||||
val keptKey = if (incomingKey == null || keyMismatch) existingKey else incomingKey
|
||||
val newUser = p.copy(public_key = keptKey ?: ByteString.EMPTY)
|
||||
node.copy(
|
||||
user = newUser,
|
||||
publicKey = newUser.public_key,
|
||||
keyMatch = node.keyMatch && !keyMismatch,
|
||||
newPublicKey = if (keyMismatch) incomingKey else node.newPublicKey,
|
||||
channel = channel,
|
||||
manuallyVerified = manuallyVerified,
|
||||
)
|
||||
|
||||
+2
@@ -304,5 +304,7 @@ class NodeRepositoryImpl(
|
||||
lastTransport = lastTransport,
|
||||
signsPackets = signsPackets,
|
||||
heardOnCurrentLora = heardOnCurrentLora,
|
||||
keyMatch = keyMatch,
|
||||
newPublicKey = newPublicKey,
|
||||
)
|
||||
}
|
||||
+9
-4
@@ -582,7 +582,7 @@ class NodeManagerImplTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `handleReceivedUser sets empty publicKey when key mismatch clears user key`() {
|
||||
fun `handleReceivedUser keeps the stored key when a different one arrives`() {
|
||||
val nodeNum = 1234
|
||||
val existingPk = ByteArray(32) { (it + 1).toByte() }.toByteString()
|
||||
val existingUser =
|
||||
@@ -607,9 +607,14 @@ class NodeManagerImplTest {
|
||||
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)
|
||||
// First-wins, matching firmware: anyone can broadcast a NodeInfo under this node's number, so the substitute
|
||||
// is refused rather than applied. Clearing the key here would break PKC direct messages to the contact on the
|
||||
// word of whoever sent it.
|
||||
assertEquals(existingPk, result.publicKey)
|
||||
assertEquals(existingPk, result.user.public_key)
|
||||
// The refusal is still surfaced — the row reads as a mismatch without the key having been destroyed.
|
||||
assertFalse(result.keyMatch)
|
||||
assertTrue(result.mismatchKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
+2
-1
@@ -146,8 +146,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity
|
||||
AutoMigration(from = 55, to = 56),
|
||||
AutoMigration(from = 56, to = 57),
|
||||
AutoMigration(from = 57, to = 58),
|
||||
AutoMigration(from = 58, to = 59),
|
||||
],
|
||||
version = 58,
|
||||
version = 59,
|
||||
exportSchema = true,
|
||||
)
|
||||
@androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class)
|
||||
|
||||
+65
-29
@@ -89,7 +89,7 @@ interface NodeInfoDao {
|
||||
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.
|
||||
if ((newNode.publicKey?.size ?: 0) > 0) {
|
||||
if (newNode.publicKey.isUsableKey()) {
|
||||
val nodeWithSamePK = findNodeByPublicKey(newNode.publicKey)
|
||||
if (nodeWithSamePK != null && nodeWithSamePK.num != newNode.num) {
|
||||
// This is a potential impersonation attempt.
|
||||
@@ -142,42 +142,73 @@ interface NodeInfoDao {
|
||||
* This function implements safety checks to prevent public key conflicts (PKC) and ensure robust handling of key
|
||||
* updates.
|
||||
*
|
||||
* First-wins: once a valid key is stored it is never replaced by a different inbound one. That would let any mesh
|
||||
* or MQTT peer destroy a contact's trusted key by broadcasting a NodeInfo under their node number, breaking PKC
|
||||
* direct messages until the node is deleted and re-added. Firmware refuses the same substitution
|
||||
* (`NodeDB::updateUser` logs "Public Key mismatch, drop NodeInfo" and keeps its copy), so overwriting here threw
|
||||
* away a key the radio itself still held.
|
||||
*
|
||||
* @param existingNode The current state of the node in the database.
|
||||
* @param incomingNode The new node data being upserted.
|
||||
* @return The resolved [ByteString] for the public key:
|
||||
* - [NodeEntity.ERROR_BYTE_STRING]: If there is a mismatch between a valid existing key and a new incoming key.
|
||||
* - `incomingNode.publicKey`: If the incoming key is new, matches the existing one, or if recovering from an error
|
||||
* state.
|
||||
* - `existingNode.publicKey`: If the incoming update has no key, or if the user is licensed but already has a valid
|
||||
* key (prevents wiping).
|
||||
* - [ByteString.EMPTY]: If the user is licensed and didn't previously have a key (or if key is explicitly cleared).
|
||||
* @return the resolved key, and whether it matched:
|
||||
* - the stored key with `keyMatch = false`: a *different* valid key arrived; the refusal is recorded, not applied.
|
||||
* - `incomingNode.publicKey`: the incoming key is new or matches the stored one.
|
||||
* - `existingNode.publicKey`: the incoming update has no key, or the user is licensed but already has a valid key
|
||||
* (prevents wiping).
|
||||
* - [ByteString.EMPTY]: the user is licensed and had no key before (or the key is explicitly cleared).
|
||||
*/
|
||||
private fun resolvePublicKey(existingNode: NodeEntity, incomingNode: NodeEntity): ByteString? {
|
||||
private fun resolvePublicKey(existingNode: NodeEntity, incomingNode: NodeEntity): ResolvedPublicKey {
|
||||
val existingKey = existingNode.publicKey ?: existingNode.user.public_key
|
||||
val incomingKey = incomingNode.publicKey
|
||||
|
||||
val incomingHasKey = (incomingKey?.size ?: 0) == KEY_SIZE
|
||||
val existingHasKey = existingKey.size == KEY_SIZE && existingKey != NodeEntity.ERROR_BYTE_STRING
|
||||
val incomingHasKey = incomingKey.isUsableKey()
|
||||
val existingHasKey = existingKey.isUsableKey()
|
||||
|
||||
return 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
|
||||
incomingHasKey ->
|
||||
when {
|
||||
existingHasKey && incomingKey != existingKey ->
|
||||
// A different key for a node we already hold one for: keep ours, record the refusal.
|
||||
ResolvedPublicKey(existingKey, keyMatch = false, newPublicKey = incomingKey)
|
||||
|
||||
existingHasKey ->
|
||||
// The key already on file. It settles nothing: a recorded refusal stands until the connected
|
||||
// radio speaks for itself, or the next legitimate beacon would hide the substitute.
|
||||
ResolvedPublicKey(
|
||||
incomingKey,
|
||||
keyMatch = existingNode.keyMatch && incomingNode.keyMatch,
|
||||
newPublicKey = incomingNode.newPublicKey ?: existingNode.newPublicKey,
|
||||
)
|
||||
|
||||
// A first key, or recovery from a legacy sentinel row.
|
||||
else -> ResolvedPublicKey(incomingKey, keyMatch = true)
|
||||
}
|
||||
}
|
||||
|
||||
existingHasKey -> existingKey
|
||||
existingHasKey -> ResolvedPublicKey(existingKey, existingNode.keyMatch, existingNode.newPublicKey)
|
||||
|
||||
incomingNode.user.is_licensed -> ByteString.EMPTY
|
||||
incomingNode.user.is_licensed -> ResolvedPublicKey(ByteString.EMPTY, keyMatch = true)
|
||||
|
||||
else -> existingKey
|
||||
else -> ResolvedPublicKey(existingKey, existingNode.keyMatch, existingNode.newPublicKey)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A resolved public key, whether the inbound one matched it, and the refused key when it did not — see
|
||||
* [resolvePublicKey].
|
||||
*/
|
||||
private data class ResolvedPublicKey(
|
||||
val key: ByteString?,
|
||||
val keyMatch: Boolean,
|
||||
val newPublicKey: ByteString? = null,
|
||||
)
|
||||
|
||||
/**
|
||||
* A key the DAO will act on: present, full length, and not the legacy mismatch sentinel. The sentinel is 32 bytes
|
||||
* too, so a size check alone would record it as a refused key or, on the local link, write it over the real one.
|
||||
*/
|
||||
private fun ByteString?.isUsableKey(): Boolean =
|
||||
this != null && size == KEY_SIZE && this != NodeEntity.ERROR_BYTE_STRING
|
||||
|
||||
/**
|
||||
* Handles the validation logic when upserting an existing node.
|
||||
*
|
||||
@@ -211,6 +242,8 @@ interface NodeInfoDao {
|
||||
return incomingNode.copy(
|
||||
user = existingNode.user,
|
||||
publicKey = existingNode.publicKey,
|
||||
keyMatch = existingNode.keyMatch,
|
||||
newPublicKey = existingNode.newPublicKey,
|
||||
longName = existingNode.longName,
|
||||
shortName = existingNode.shortName,
|
||||
manuallyVerified = existingNode.manuallyVerified,
|
||||
@@ -219,16 +252,19 @@ interface NodeInfoDao {
|
||||
)
|
||||
}
|
||||
|
||||
val resolvedKey =
|
||||
if (trustIncomingKey && (incomingNode.publicKey?.size ?: 0) == KEY_SIZE) {
|
||||
incomingNode.publicKey
|
||||
val resolved =
|
||||
if (trustIncomingKey && incomingNode.publicKey.isUsableKey()) {
|
||||
// The connected radio is authoritative for its own key, so this also clears any recorded mismatch.
|
||||
ResolvedPublicKey(incomingNode.publicKey, keyMatch = true)
|
||||
} else {
|
||||
resolvePublicKey(existingNode, incomingNode)
|
||||
}
|
||||
|
||||
return incomingNode.copy(
|
||||
user = incomingNode.user.copy(public_key = resolvedKey ?: ByteString.EMPTY),
|
||||
publicKey = resolvedKey,
|
||||
user = incomingNode.user.copy(public_key = resolved.key ?: ByteString.EMPTY),
|
||||
publicKey = resolved.key,
|
||||
keyMatch = resolved.keyMatch,
|
||||
newPublicKey = resolved.newPublicKey,
|
||||
notes = resolvedNotes,
|
||||
powerChannelLabels = resolvedPowerChannelLabels,
|
||||
)
|
||||
@@ -491,7 +527,7 @@ interface NodeInfoDao {
|
||||
}
|
||||
|
||||
// Batch validate new nodes' public keys (one query instead of N)
|
||||
val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.size > 0 } }.distinct()
|
||||
val publicKeysToCheck = newNodes.mapNotNull { node -> node.publicKey?.takeIf { it.isUsableKey() } }.distinct()
|
||||
val pkConflicts =
|
||||
if (publicKeysToCheck.isNotEmpty()) {
|
||||
publicKeysToCheck
|
||||
@@ -503,7 +539,7 @@ interface NodeInfoDao {
|
||||
}
|
||||
|
||||
for (newNode in newNodes) {
|
||||
if ((newNode.publicKey?.size ?: 0) > 0) {
|
||||
if (newNode.publicKey.isUsableKey()) {
|
||||
val conflicting = pkConflicts[newNode.publicKey]
|
||||
if (conflicting != null && conflicting.num != newNode.num) {
|
||||
// Same key under a different num. Migrate when this is the connected device itself
|
||||
|
||||
@@ -70,6 +70,8 @@ data class NodeWithRelations(
|
||||
manuallyVerified = node.manuallyVerified,
|
||||
signsPackets = node.signsPackets,
|
||||
heardOnCurrentLora = node.heardOnCurrentLora,
|
||||
keyMatch = node.keyMatch,
|
||||
newPublicKey = node.newPublicKey,
|
||||
)
|
||||
|
||||
fun toEntity() = with(node) {
|
||||
@@ -99,6 +101,8 @@ data class NodeWithRelations(
|
||||
lastTransport = lastTransport,
|
||||
signsPackets = signsPackets,
|
||||
heardOnCurrentLora = heardOnCurrentLora,
|
||||
keyMatch = keyMatch,
|
||||
newPublicKey = newPublicKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -164,6 +168,24 @@ data class NodeEntity(
|
||||
* firmware that does not report it, are never shown as unheard.
|
||||
*/
|
||||
@ColumnInfo(name = "heard_on_current_lora", defaultValue = "1") var heardOnCurrentLora: Boolean = true,
|
||||
/**
|
||||
* False once a *different* public key has arrived for a node one is already stored for.
|
||||
*
|
||||
* The stored key stands (first-wins) and this records the refusal, matching firmware — which drops the whole
|
||||
* NodeInfo on a key mismatch rather than overwriting — and Meshtastic-Apple. Overwriting the trusted key instead
|
||||
* would let any mesh or MQTT peer destroy it by broadcasting a NodeInfo under that node's number.
|
||||
*
|
||||
* Defaults true so rows written before this column existed are not read as mismatched; those rows record a mismatch
|
||||
* the old way, as [ERROR_BYTE_STRING] in [publicKey].
|
||||
*/
|
||||
@ColumnInfo(name = "key_match", defaultValue = "1") var keyMatch: Boolean = true,
|
||||
/**
|
||||
* The key that was refused, kept so the mismatch can be shown as more than a warning.
|
||||
*
|
||||
* Null whenever [keyMatch] is true. Rows that recorded a mismatch the old way, as [ERROR_BYTE_STRING] in
|
||||
* [publicKey], have no rejected key to report and stay null.
|
||||
*/
|
||||
@ColumnInfo(name = "new_public_key") var newPublicKey: ByteString? = null,
|
||||
) {
|
||||
val deviceMetrics: org.meshtastic.proto.DeviceMetrics?
|
||||
get() = deviceTelemetry.device_metrics
|
||||
@@ -231,5 +253,7 @@ data class NodeEntity(
|
||||
lastTransport = lastTransport,
|
||||
signsPackets = signsPackets,
|
||||
heardOnCurrentLora = heardOnCurrentLora,
|
||||
keyMatch = keyMatch,
|
||||
newPublicKey = newPublicKey,
|
||||
)
|
||||
}
|
||||
+96
-7
@@ -29,6 +29,7 @@ import org.meshtastic.proto.User
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@@ -109,14 +110,97 @@ abstract class CommonNodeInfoDaoTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a remote node changing its key is recorded as a mismatch`() = runTest {
|
||||
fun `a remote node changing its key keeps the stored key and records the refusal`() = runTest {
|
||||
createDb()
|
||||
val first = ByteArray(32) { 1 }.toByteString()
|
||||
val second = ByteArray(32) { 2 }.toByteString()
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = first)))
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = second)))
|
||||
val trusted = ByteArray(32) { 1 }.toByteString()
|
||||
val substitute = ByteArray(32) { 2 }.toByteString()
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = trusted)))
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = substitute)))
|
||||
|
||||
assertEquals(NodeEntity.ERROR_BYTE_STRING, dao.getNodeByNum(1)?.node?.publicKey)
|
||||
// First-wins: anyone can broadcast a NodeInfo under another node's number, so the substitute is refused
|
||||
// rather than applied. Overwriting would break PKC direct messages to that contact.
|
||||
val stored = dao.getNodeByNum(1)?.node
|
||||
assertEquals(trusted, stored?.publicKey)
|
||||
assertEquals(trusted, stored?.user?.public_key)
|
||||
assertFalse(stored?.keyMatch ?: true)
|
||||
assertEquals(substitute, stored?.newPublicKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the refused key is kept so the mismatch can name it`() = runTest {
|
||||
createDb()
|
||||
val trusted = ByteArray(32) { 1 }.toByteString()
|
||||
val substitute = ByteArray(32) { 2 }.toByteString()
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = trusted)))
|
||||
|
||||
// Nothing is refused yet, so there is no key to report.
|
||||
assertEquals(null, dao.getNodeByNum(1)?.node?.newPublicKey)
|
||||
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = substitute)))
|
||||
assertEquals(substitute, dao.getNodeByNum(1)?.node?.newPublicKey)
|
||||
|
||||
// The key already on file arriving again settles nothing: the refusal stands until the connected radio
|
||||
// speaks for itself, or the next legitimate beacon would hide the substitute.
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = trusted)))
|
||||
val stillFlagged = dao.getNodeByNum(1)?.node
|
||||
assertEquals(trusted, stillFlagged?.publicKey)
|
||||
assertFalse(stillFlagged?.keyMatch ?: true)
|
||||
assertEquals(substitute, stillFlagged?.newPublicKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the connected radio re-keying clears the refused key along with the mismatch`() = runTest {
|
||||
createDb()
|
||||
val own = myNodeInfo.myNodeNum
|
||||
val before = ByteArray(32) { 1 }.toByteString()
|
||||
dao.upsert(NodeEntity(num = own, user = User(id = "!own", public_key = before)))
|
||||
dao.upsert(NodeEntity(num = own, user = User(id = "!own", public_key = ByteArray(32) { 9 }.toByteString())))
|
||||
assertFalse(dao.getNodeByNum(own)?.node?.keyMatch ?: true)
|
||||
|
||||
// The local link is authoritative, so accepting the radio's own key also drops what was refused.
|
||||
val after = ByteArray(32) { 2 }.toByteString()
|
||||
dao.installConfig(myNodeInfo, listOf(NodeEntity(num = own, user = User(id = "!own", public_key = after))))
|
||||
|
||||
val stored = dao.getNodeByNum(own)?.node
|
||||
assertEquals(after, stored?.publicKey)
|
||||
assertTrue(stored?.keyMatch ?: false)
|
||||
assertEquals(null, stored?.newPublicKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the legacy mismatch sentinel arriving as a key is neither refused nor stored`() = runTest {
|
||||
createDb()
|
||||
val trusted = ByteArray(32) { 1 }.toByteString()
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = trusted)))
|
||||
|
||||
// A row that recorded a mismatch the old way carries the sentinel as its key. Re-upserting it through the
|
||||
// repository must not read as a fresh substitution, and the sentinel is not a key anyone refused.
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = NodeEntity.ERROR_BYTE_STRING)))
|
||||
val remote = dao.getNodeByNum(1)?.node
|
||||
assertEquals(trusted, remote?.publicKey)
|
||||
assertTrue(remote?.keyMatch ?: false)
|
||||
assertEquals(null, remote?.newPublicKey)
|
||||
|
||||
// Nor may the local link write it over the connected radio's real key. A key of its own, or the new-node
|
||||
// guard would read this upsert as node 1 claiming a second number and never insert it.
|
||||
val own = myNodeInfo.myNodeNum
|
||||
val ownKey = ByteArray(32) { 3 }.toByteString()
|
||||
dao.upsert(NodeEntity(num = own, user = User(id = "!own", public_key = ownKey)))
|
||||
dao.installConfig(
|
||||
myNodeInfo,
|
||||
listOf(NodeEntity(num = own, user = User(id = "!own", public_key = NodeEntity.ERROR_BYTE_STRING))),
|
||||
)
|
||||
assertEquals(ownKey, dao.getNodeByNum(own)?.node?.publicKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the stored key surviving a substitution still reads as a mismatch to the UI`() = runTest {
|
||||
createDb()
|
||||
val trusted = ByteArray(32) { 1 }.toByteString()
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = trusted)))
|
||||
dao.upsert(NodeEntity(num = 1, user = User(id = "!1", public_key = ByteArray(32) { 2 }.toByteString())))
|
||||
|
||||
assertTrue(dao.getNodeByNum(1)!!.toModel().mismatchKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -134,6 +218,7 @@ abstract class CommonNodeInfoDaoTest {
|
||||
val stored = dao.getNodeByNum(own)?.node
|
||||
assertEquals(after, stored?.publicKey)
|
||||
assertEquals(after, stored?.user?.public_key)
|
||||
assertTrue(stored?.keyMatch ?: false)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -147,7 +232,11 @@ abstract class CommonNodeInfoDaoTest {
|
||||
// local node number proves only that the sender claimed it.
|
||||
dao.upsert(NodeEntity(num = own, user = User(id = "!own", public_key = ByteArray(32) { 9 }.toByteString())))
|
||||
|
||||
assertEquals(NodeEntity.ERROR_BYTE_STRING, dao.getNodeByNum(own)?.node?.publicKey)
|
||||
// First-wins keeps the stored key; the refusal is recorded and still reads as a mismatch to the UI.
|
||||
val stored = dao.getNodeByNum(own)
|
||||
assertEquals(real, stored?.node?.publicKey)
|
||||
assertFalse(stored?.node?.keyMatch ?: true)
|
||||
assertTrue(stored!!.toModel().mismatchKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+50
@@ -380,6 +380,53 @@ class MeshtasticDatabaseMigrationTest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 58→59 adds `nodes.key_match` and `nodes.new_public_key`, the record of a refused key substitution. `key_match`
|
||||
* defaults to 1 so rows written before the column existed are not read as mismatched on first launch; those rows
|
||||
* recorded a mismatch the old way, as the zero sentinel in `public_key`, and have no refused key to report, so
|
||||
* `new_public_key` stays null. This proves both defaults and that the stored key survives the addition byte for
|
||||
* byte, which is the whole point of first-wins.
|
||||
*/
|
||||
@Test
|
||||
fun keyMatchColumnsDefaultToMatchedAndPreserveNodes() = runTest {
|
||||
val storedKeyHex = "01".repeat(PUBLIC_KEY_BYTES)
|
||||
helper.createDatabase(KEY_MATCH_FROM_VERSION).use { connection ->
|
||||
// Every NOT NULL column without a default in schema 58; the BLOBs are empty protos.
|
||||
val columns =
|
||||
"num, user, position, latitude, longitude, snr, rssi, last_heard, device_metrics, channel, " +
|
||||
"via_mqtt, hops_away, is_favorite, environment_metrics, power_metrics, paxcounter"
|
||||
connection.execSQL(
|
||||
"INSERT INTO nodes ($columns, long_name, public_key) VALUES " +
|
||||
"(42, x'', x'', 0.0, 0.0, 0.0, 0, 1000, x'', 0, 0, 1, 1, x'', x'', x'', " +
|
||||
"'Minnie Mouse', x'$storedKeyHex')",
|
||||
)
|
||||
connection.execSQL(
|
||||
"INSERT INTO nodes ($columns, long_name) VALUES " +
|
||||
"(43, x'', x'', 0.0, 0.0, 0.0, 0, 2000, x'', 0, 0, 2, 0, x'', x'', x'', 'Mickey')",
|
||||
)
|
||||
}
|
||||
|
||||
helper.runMigrationsAndValidate(
|
||||
KEY_MATCH_TO_VERSION,
|
||||
listOf(MeshtasticDatabase.MIGRATION_52_53),
|
||||
).use { connection ->
|
||||
// Both rows survive; neither reads as a mismatch, and neither has a refused key to report.
|
||||
assertEquals(listOf("42", "43"), queryColumn(connection, "SELECT num FROM nodes ORDER BY num"))
|
||||
assertEquals(listOf("1", "1"), queryColumn(connection, "SELECT key_match FROM nodes ORDER BY num"))
|
||||
assertEquals(
|
||||
listOf<String?>(null, null),
|
||||
queryColumn(connection, "SELECT new_public_key FROM nodes ORDER BY num"),
|
||||
)
|
||||
// The stored key is exactly what was written; the column addition touched nothing.
|
||||
assertEquals(
|
||||
listOf(storedKeyHex.uppercase()),
|
||||
queryColumn(connection, "SELECT hex(public_key) FROM nodes WHERE num = 42"),
|
||||
)
|
||||
assertEquals(listOf("Minnie Mouse"), queryColumn(connection, "SELECT long_name FROM nodes WHERE num = 42"))
|
||||
assertEquals(listOf("1000"), queryColumn(connection, "SELECT last_heard FROM nodes WHERE num = 42"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryColumn(connection: SQLiteConnection, sql: String): List<String?> =
|
||||
connection.prepare(sql).use { statement ->
|
||||
buildList {
|
||||
@@ -408,6 +455,9 @@ class MeshtasticDatabaseMigrationTest {
|
||||
const val PINNED_COLUMN_TO_VERSION = 57
|
||||
const val HEARD_ON_LORA_FROM_VERSION = 57
|
||||
const val HEARD_ON_LORA_TO_VERSION = 58
|
||||
const val KEY_MATCH_FROM_VERSION = 58
|
||||
const val KEY_MATCH_TO_VERSION = 59
|
||||
const val PUBLIC_KEY_BYTES = 32
|
||||
|
||||
/** Room's runtime FTS content-sync triggers, verbatim from the generated MeshtasticDatabase_Impl. */
|
||||
val FTS_SYNC_TRIGGERS =
|
||||
|
||||
@@ -74,6 +74,13 @@ data class Node(
|
||||
val nodeStatus: String? = null,
|
||||
/** The transport mechanism this node was last heard over (see [MeshPacket.TransportMechanism]). */
|
||||
val lastTransport: Int = 0,
|
||||
/**
|
||||
* False once a different public key arrived for a node one is already stored for. The stored key stands; this
|
||||
* records the refusal. See [mismatchKey], which is what the UI asks.
|
||||
*/
|
||||
val keyMatch: Boolean = true,
|
||||
/** The key a mismatch refused, kept so the warning can name it. Null whenever [keyMatch] is true. */
|
||||
val newPublicKey: ByteString? = null,
|
||||
) {
|
||||
val capabilities: Capabilities by lazy { Capabilities(metadata?.firmware_version) }
|
||||
|
||||
@@ -92,8 +99,16 @@ data class Node(
|
||||
val hasPKC
|
||||
get() = (publicKey ?: user.public_key).size > 0
|
||||
|
||||
/**
|
||||
* True when a different public key has arrived for this node than the one on file.
|
||||
*
|
||||
* Two shapes, because the app used to record a mismatch by overwriting the stored key with [ERROR_BYTE_STRING]. It
|
||||
* now keeps the key and clears [keyMatch] instead — firmware drops the NodeInfo outright rather than overwrite, so
|
||||
* destroying the trusted key handed any mesh or MQTT peer a way to break PKC direct messages to a contact. Rows
|
||||
* written before that change still carry the sentinel, so both still read as a mismatch.
|
||||
*/
|
||||
val mismatchKey
|
||||
get() = (publicKey ?: user.public_key) == ERROR_BYTE_STRING
|
||||
get() = !keyMatch || (publicKey ?: user.public_key) == ERROR_BYTE_STRING
|
||||
|
||||
/**
|
||||
* Last measured SNR in dB, or null when this node has no reading yet ([snr] still holds [SNR_UNSET]).
|
||||
|
||||
@@ -22,9 +22,20 @@ import okio.ByteString
|
||||
import okio.ByteString.Companion.decodeBase64
|
||||
import okio.ByteString.Companion.toByteString
|
||||
import org.meshtastic.core.common.util.CommonUri
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.proto.SharedContact
|
||||
import org.meshtastic.proto.User
|
||||
|
||||
/**
|
||||
* The [SharedContact] to encode for [node].
|
||||
*
|
||||
* [isOwnContact] marks it manually verified (design#149 point 2): you hold your own radio's key, and a QR shown in
|
||||
* person is the in-person exchange. Relaying someone else's contact only passes on what was already recorded, it never
|
||||
* asserts verification on their behalf.
|
||||
*/
|
||||
fun Node.toSharedContact(isOwnContact: Boolean = false): SharedContact =
|
||||
SharedContact(node_num = num, user = user, manually_verified = isOwnContact || manuallyVerified)
|
||||
|
||||
/**
|
||||
* Return a [SharedContact] that represents the contact encoded by the URL.
|
||||
*
|
||||
|
||||
+52
@@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package org.meshtastic.core.model.util
|
||||
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.proto.User
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class ToSharedContactTest {
|
||||
|
||||
private fun node(manuallyVerified: Boolean = false) =
|
||||
Node(num = 7, user = User(id = "!7", long_name = "Seven"), manuallyVerified = manuallyVerified)
|
||||
|
||||
@Test
|
||||
fun `sharing your own contact marks it manually verified`() {
|
||||
assertTrue(node().toSharedContact(isOwnContact = true).manually_verified)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `relaying someone else's contact asserts nothing on their behalf`() {
|
||||
assertFalse(node().toSharedContact(isOwnContact = false).manually_verified)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a contact already verified in person stays verified when relayed`() {
|
||||
assertTrue(node(manuallyVerified = true).toSharedContact(isOwnContact = false).manually_verified)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `carries the node number and user through`() {
|
||||
val shared = node().toSharedContact()
|
||||
assertEquals(7, shared.node_num)
|
||||
assertEquals("Seven", shared.user?.long_name)
|
||||
}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import androidx.compose.runtime.Composable
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.meshtastic.core.model.Node
|
||||
import org.meshtastic.core.model.util.getSharedContactUrl
|
||||
import org.meshtastic.core.model.util.toSharedContact
|
||||
import org.meshtastic.core.resources.Res
|
||||
import org.meshtastic.core.resources.share_contact
|
||||
import org.meshtastic.core.resources.share_contact_subject
|
||||
@@ -30,13 +31,17 @@ import org.meshtastic.proto.SharedContact
|
||||
/**
|
||||
* Displays a dialog with the contact's information as a QR code and URI.
|
||||
*
|
||||
* Sharing your own contact marks it manually verified (design#149 point 2): you hold your own radio's key, and a QR
|
||||
* shown in person is the in-person exchange. Relaying someone else's contact asserts nothing on their behalf.
|
||||
*
|
||||
* @param contact The node representing the contact to share. Null if no contact is selected.
|
||||
* @param isOwnContact True when [contact] is the connected radio.
|
||||
* @param onDismiss Callback invoked when the dialog is dismissed.
|
||||
*/
|
||||
@Composable
|
||||
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit) {
|
||||
fun SharedContactDialog(contact: Node?, onDismiss: () -> Unit, isOwnContact: Boolean = false) {
|
||||
if (contact == null) return
|
||||
val contactToShare = SharedContact(user = contact.user, node_num = contact.num)
|
||||
val contactToShare = contact.toSharedContact(isOwnContact)
|
||||
val uriString = contactToShare.getSharedContactUrl().toString()
|
||||
QrDialog(
|
||||
title = stringResource(Res.string.share_contact),
|
||||
|
||||
+11
-2
@@ -144,7 +144,15 @@ private fun NodeDetailScaffold(
|
||||
)
|
||||
}
|
||||
|
||||
NodeDetailOverlays(activeOverlay, node, compassUiState, actualCompassViewModel, { activeOverlay = null }) {
|
||||
val isLocalNode = node != null && node.num == uiState.ourNode?.num
|
||||
NodeDetailOverlays(
|
||||
activeOverlay,
|
||||
node,
|
||||
isLocalNode,
|
||||
compassUiState,
|
||||
actualCompassViewModel,
|
||||
{ activeOverlay = null },
|
||||
) {
|
||||
viewModel.handleNodeMenuAction(NodeMenuAction.RequestPosition(it))
|
||||
}
|
||||
}
|
||||
@@ -154,6 +162,7 @@ private fun NodeDetailScaffold(
|
||||
private fun NodeDetailOverlays(
|
||||
overlay: NodeDetailOverlay?,
|
||||
node: Node?,
|
||||
isLocal: Boolean,
|
||||
compassUiState: CompassUiState,
|
||||
compassViewModel: CompassViewModel?,
|
||||
onDismiss: () -> Unit,
|
||||
@@ -181,7 +190,7 @@ private fun NodeDetailOverlays(
|
||||
}
|
||||
|
||||
when (overlay) {
|
||||
is NodeDetailOverlay.SharedContact -> node?.let { SharedContactDialog(it, onDismiss) }
|
||||
is NodeDetailOverlay.SharedContact -> node?.let { SharedContactDialog(it, onDismiss, isOwnContact = isLocal) }
|
||||
|
||||
is NodeDetailOverlay.FirmwareReleaseInfo ->
|
||||
NodeDetailBottomSheet(onDismiss) { FirmwareReleaseSheetContent(firmwareRelease = overlay.release) }
|
||||
|
||||
+1
-1
@@ -197,7 +197,7 @@ fun NodeListScreen(
|
||||
|
||||
var showShareContact by remember { mutableStateOf(false) }
|
||||
if (showShareContact) {
|
||||
SharedContactDialog(contact = ourNode, onDismiss = { showShareContact = false })
|
||||
SharedContactDialog(contact = ourNode, onDismiss = { showShareContact = false }, isOwnContact = true)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
|
||||
Reference in new issue
Block a user