fix(node): keep a contact's public key when a different one arrives (#7118)

This commit is contained in:
James Rich authored and GitHub committed 2026-09-11 16:20:25 +00:00
1 parent 9db12c6e51
commit 8f4723cbb2
15 files changed
+2189 -50

No files matched your search

@@ -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,
)
@@ -304,5 +304,7 @@ class NodeRepositoryImpl(
lastTransport = lastTransport,
signsPackets = signsPackets,
heardOnCurrentLora = heardOnCurrentLora,
keyMatch = keyMatch,
newPublicKey = newPublicKey,
)
}
@@ -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
@@ -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)
@@ -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,
)
}
@@ -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
@@ -380,6 +380,53 @@ class MeshtasticDatabaseMigrationTest {
}
}
/**
* 5859 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.
*
@@ -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),
@@ -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) }
@@ -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(