From 8f4723cbb2abe34464674b007b3ee1033e7bb992 Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:20:25 +0000 Subject: [PATCH] fix(node): keep a contact's public key when a different one arrives (#7118) --- .../core/data/manager/NodeManagerImpl.kt | 13 +- .../data/repository/NodeRepositoryImpl.kt | 2 + .../core/data/manager/NodeManagerImplTest.kt | 13 +- .../59.json | 1833 +++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 3 +- .../core/database/dao/NodeInfoDao.kt | 94 +- .../core/database/entity/NodeEntity.kt | 24 + .../database/dao/CommonNodeInfoDaoTest.kt | 103 +- .../MeshtasticDatabaseMigrationTest.kt | 50 + .../kotlin/org/meshtastic/core/model/Node.kt | 17 +- .../core/model/util/SharedContact.kt | 11 + .../core/model/util/ToSharedContactTest.kt | 52 + .../core/ui/component/ContactSharing.kt | 9 +- .../feature/node/detail/NodeDetailScreens.kt | 13 +- .../feature/node/list/NodeListScreen.kt | 2 +- 15 files changed, 2189 insertions(+), 50 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/59.json create mode 100644 core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ToSharedContactTest.kt 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 d2e1f48fa9..e58830091d 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 @@ -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, ) 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 a660407dad..737a6113ba 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 @@ -304,5 +304,7 @@ class NodeRepositoryImpl( lastTransport = lastTransport, signsPackets = signsPackets, heardOnCurrentLora = heardOnCurrentLora, + keyMatch = keyMatch, + newPublicKey = newPublicKey, ) } diff --git a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt index b6f64a84ad..b5d819306f 100644 --- a/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt +++ b/core/data/src/commonTest/kotlin/org/meshtastic/core/data/manager/NodeManagerImplTest.kt @@ -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 diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/59.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/59.json new file mode 100644 index 0000000000..af4479201d --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/59.json @@ -0,0 +1,1833 @@ +{ + "formatVersion": 1, + "database": { + "version": 59, + "identityHash": "3a701cc22f7c57b09cac889f64cd9360", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `power_channel_labels` TEXT NOT NULL DEFAULT '[]', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, `has_xeddsa_signed` INTEGER NOT NULL DEFAULT 0, `heard_on_current_lora` INTEGER NOT NULL DEFAULT 1, `key_match` INTEGER NOT NULL DEFAULT 1, `new_public_key` BLOB, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "airQualityTelemetry", + "columnName": "air_quality_metrics", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "powerChannelLabels", + "columnName": "power_channel_labels", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'[]'" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "signsPackets", + "columnName": "has_xeddsa_signed", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "heardOnCurrentLora", + "columnName": "heard_on_current_lora", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "keyMatch", + "columnName": "key_match", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "newPublicKey", + "columnName": "new_public_key", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '', `translated_text` TEXT, `show_translated` INTEGER NOT NULL DEFAULT 0)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "translatedText", + "columnName": "translated_text", + "affinity": "TEXT" + }, + { + "fieldPath": "showTranslated", + "columnName": "show_translated", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "packet_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)", + "fields": [ + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "packet", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END" + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, `draft` TEXT NOT NULL DEFAULT '', `pinned` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "draft", + "columnName": "draft", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "pinned", + "columnName": "pinned", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL, `rssi` INTEGER, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "device_link", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`short_code` TEXT NOT NULL, `link_description` TEXT, `is_vendor` INTEGER NOT NULL, `regions` TEXT, `targets` TEXT, PRIMARY KEY(`short_code`))", + "fields": [ + { + "fieldPath": "shortCode", + "columnName": "short_code", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "linkDescription", + "columnName": "link_description", + "affinity": "TEXT" + }, + { + "fieldPath": "isVendor", + "columnName": "is_vendor", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "regions", + "columnName": "regions", + "affinity": "TEXT" + }, + { + "fieldPath": "targets", + "columnName": "targets", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "short_code" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + }, + { + "tableName": "discovery_session", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `timestamp` INTEGER NOT NULL, `presets_scanned` TEXT NOT NULL, `home_preset` TEXT NOT NULL, `total_unique_nodes` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `total_messages` INTEGER NOT NULL DEFAULT 0, `total_sensor_packets` INTEGER NOT NULL DEFAULT 0, `furthest_node_distance` REAL NOT NULL DEFAULT 0.0, `completion_status` TEXT NOT NULL DEFAULT 'complete', `ai_summary` TEXT, `user_latitude` REAL NOT NULL DEFAULT 0.0, `user_longitude` REAL NOT NULL DEFAULT 0.0, `total_dwell_seconds` INTEGER NOT NULL DEFAULT 0, `device_address` TEXT, `home_lora_config` BLOB, `home_primary_channel` BLOB)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetsScanned", + "columnName": "presets_scanned", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "homePreset", + "columnName": "home_preset", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "totalUniqueNodes", + "columnName": "total_unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalMessages", + "columnName": "total_messages", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "totalSensorPackets", + "columnName": "total_sensor_packets", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "furthestNodeDistance", + "columnName": "furthest_node_distance", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "completionStatus", + "columnName": "completion_status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'complete'" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "userLatitude", + "columnName": "user_latitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "userLongitude", + "columnName": "user_longitude", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "totalDwellSeconds", + "columnName": "total_dwell_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "deviceAddress", + "columnName": "device_address", + "affinity": "TEXT" + }, + { + "fieldPath": "homeLoraConfig", + "columnName": "home_lora_config", + "affinity": "BLOB" + }, + { + "fieldPath": "homePrimaryChannel", + "columnName": "home_primary_channel", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "discovery_preset_result", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `session_id` INTEGER NOT NULL, `preset_name` TEXT NOT NULL, `dwell_duration_seconds` INTEGER NOT NULL DEFAULT 0, `unique_nodes` INTEGER NOT NULL DEFAULT 0, `direct_neighbor_count` INTEGER NOT NULL DEFAULT 0, `mesh_neighbor_count` INTEGER NOT NULL DEFAULT 0, `infrastructure_node_count` INTEGER NOT NULL DEFAULT 0, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `avg_channel_utilization` REAL NOT NULL DEFAULT 0.0, `avg_airtime_rate` REAL NOT NULL DEFAULT 0.0, `packet_success_rate` REAL NOT NULL DEFAULT 0.0, `packet_failure_rate` REAL NOT NULL DEFAULT 0.0, `ai_summary` TEXT, `num_packets_tx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx` INTEGER NOT NULL DEFAULT 0, `num_packets_rx_bad` INTEGER NOT NULL DEFAULT 0, `num_rx_dupe` INTEGER NOT NULL DEFAULT 0, `num_tx_relay` INTEGER NOT NULL DEFAULT 0, `num_tx_relay_canceled` INTEGER NOT NULL DEFAULT 0, `num_online_nodes` INTEGER NOT NULL DEFAULT 0, `num_total_nodes` INTEGER NOT NULL DEFAULT 0, `uptime_seconds` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`session_id`) REFERENCES `discovery_session`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sessionId", + "columnName": "session_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetName", + "columnName": "preset_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "dwellDurationSeconds", + "columnName": "dwell_duration_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uniqueNodes", + "columnName": "unique_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "directNeighborCount", + "columnName": "direct_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "meshNeighborCount", + "columnName": "mesh_neighbor_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "infrastructureNodeCount", + "columnName": "infrastructure_node_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "avgChannelUtilization", + "columnName": "avg_channel_utilization", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "avgAirtimeRate", + "columnName": "avg_airtime_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetSuccessRate", + "columnName": "packet_success_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "packetFailureRate", + "columnName": "packet_failure_rate", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0.0" + }, + { + "fieldPath": "aiSummary", + "columnName": "ai_summary", + "affinity": "TEXT" + }, + { + "fieldPath": "numPacketsTx", + "columnName": "num_packets_tx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRx", + "columnName": "num_packets_rx", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numPacketsRxBad", + "columnName": "num_packets_rx_bad", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numRxDupe", + "columnName": "num_rx_dupe", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelay", + "columnName": "num_tx_relay", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTxRelayCanceled", + "columnName": "num_tx_relay_canceled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numOnlineNodes", + "columnName": "num_online_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "numTotalNodes", + "columnName": "num_total_nodes", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "uptimeSeconds", + "columnName": "uptime_seconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovery_preset_result_session_id", + "unique": false, + "columnNames": [ + "session_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovery_preset_result_session_id` ON `${TABLE_NAME}` (`session_id`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_session", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "session_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "discovered_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `preset_result_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `short_name` TEXT, `long_name` TEXT, `neighbor_type` TEXT NOT NULL DEFAULT 'direct', `latitude` REAL, `longitude` REAL, `distance_from_user` REAL, `hop_count` INTEGER NOT NULL DEFAULT 0, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER, `message_count` INTEGER NOT NULL DEFAULT 0, `sensor_packet_count` INTEGER NOT NULL DEFAULT 0, `is_infrastructure` INTEGER NOT NULL DEFAULT 0, FOREIGN KEY(`preset_result_id`) REFERENCES `discovery_preset_result`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "presetResultId", + "columnName": "preset_result_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "neighborType", + "columnName": "neighbor_type", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'direct'" + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL" + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL" + }, + { + "fieldPath": "distanceFromUser", + "columnName": "distance_from_user", + "affinity": "REAL" + }, + { + "fieldPath": "hopCount", + "columnName": "hop_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER" + }, + { + "fieldPath": "messageCount", + "columnName": "message_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sensorPacketCount", + "columnName": "sensor_packet_count", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isInfrastructure", + "columnName": "is_infrastructure", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_discovered_node_preset_result_id", + "unique": false, + "columnNames": [ + "preset_result_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_preset_result_id` ON `${TABLE_NAME}` (`preset_result_id`)" + }, + { + "name": "index_discovered_node_node_num", + "unique": false, + "columnNames": [ + "node_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_discovered_node_node_num` ON `${TABLE_NAME}` (`node_num`)" + } + ], + "foreignKeys": [ + { + "table": "discovery_preset_result", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "preset_result_id" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "event_firmware_edition", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`edition` TEXT NOT NULL, `display_name` TEXT NOT NULL, `welcome_message` TEXT NOT NULL, `event_start` TEXT, `event_end` TEXT, `time_zone` TEXT, `location` TEXT, `icon_url` TEXT, `accent_color` TEXT, `tag` TEXT, `domain` TEXT, `theme_json` TEXT, `firmware_json` TEXT, `links_json` TEXT NOT NULL, PRIMARY KEY(`edition`))", + "fields": [ + { + "fieldPath": "edition", + "columnName": "edition", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "welcomeMessage", + "columnName": "welcome_message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "eventStart", + "columnName": "event_start", + "affinity": "TEXT" + }, + { + "fieldPath": "eventEnd", + "columnName": "event_end", + "affinity": "TEXT" + }, + { + "fieldPath": "timeZone", + "columnName": "time_zone", + "affinity": "TEXT" + }, + { + "fieldPath": "location", + "columnName": "location", + "affinity": "TEXT" + }, + { + "fieldPath": "iconUrl", + "columnName": "icon_url", + "affinity": "TEXT" + }, + { + "fieldPath": "accentColor", + "columnName": "accent_color", + "affinity": "TEXT" + }, + { + "fieldPath": "tag", + "columnName": "tag", + "affinity": "TEXT" + }, + { + "fieldPath": "domain", + "columnName": "domain", + "affinity": "TEXT" + }, + { + "fieldPath": "themeJson", + "columnName": "theme_json", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareJson", + "columnName": "firmware_json", + "affinity": "TEXT" + }, + { + "fieldPath": "linksJson", + "columnName": "links_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "edition" + ] + } + }, + { + "tableName": "merge_marker", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`source_db_name` TEXT NOT NULL, `merged_at` INTEGER NOT NULL, PRIMARY KEY(`source_db_name`))", + "fields": [ + { + "fieldPath": "sourceDbName", + "columnName": "source_db_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mergedAt", + "columnName": "merged_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "source_db_name" + ] + } + }, + { + "tableName": "channel_set", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `channel_set` BLOB NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "channelSet", + "columnName": "channel_set", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "bootloader_ota_quirks_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `devices_json` TEXT NOT NULL, `soft_device_variants_json` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "devicesJson", + "columnName": "devices_json", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "softDeviceVariantsJson", + "columnName": "soft_device_variants_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "maintenance_uf2_cache", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `manifest_json` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "manifestJson", + "columnName": "manifest_json", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '3a701cc22f7c57b09cac889f64cd9360')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 5526e3b166..b4e0086b83 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -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) 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 9b644622b6..f6af4b437d 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 @@ -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 diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index a6f63be758..8faf12790d 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -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, ) } diff --git a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt index 8fdee44b81..c3b9cb5f53 100644 --- a/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt +++ b/core/database/src/commonTest/kotlin/org/meshtastic/core/database/dao/CommonNodeInfoDaoTest.kt @@ -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 diff --git a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt index 269f3ba440..6096fcfd66 100644 --- a/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt +++ b/core/database/src/jvmTest/kotlin/org/meshtastic/core/database/MeshtasticDatabaseMigrationTest.kt @@ -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(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 = 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 = diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index e312cbc3d1..b35513bf81 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -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]). diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt index 6a58930385..766a4e1073 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/util/SharedContact.kt @@ -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. * diff --git a/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ToSharedContactTest.kt b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ToSharedContactTest.kt new file mode 100644 index 0000000000..a65b6c4168 --- /dev/null +++ b/core/model/src/commonTest/kotlin/org/meshtastic/core/model/util/ToSharedContactTest.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.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) + } +} diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt index 4747686d7a..1a8e8ca2bf 100644 --- a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ContactSharing.kt @@ -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), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt index b79d792fe0..dccbae94f8 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodeDetailScreens.kt @@ -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) } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt index 713668b7f9..2981ba555a 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/list/NodeListScreen.kt @@ -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(