diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt
index 37846a1bb..f6deec545 100644
--- a/.skills/compose-ui/strings-index.txt
+++ b/.skills/compose-ui/strings-index.txt
@@ -99,6 +99,8 @@ audio
audio_config
available_pins
back
+backup_keys
+backup_keys_confirmation
backup_restore
bad
bandwidth
@@ -312,6 +314,8 @@ delete
delete_custom_tile_source
delete_for_everyone
delete_for_me
+delete_key_backup
+delete_key_backup_confirmation
delete_messages
delete_messages_title
delete_selection
@@ -540,12 +544,9 @@ exchange_position
expand_chart
expanded
expires
-### EXPORT ###
export_configuration
export_data_csv
export_gpx
-export_keys
-export_keys_confirmation
export_tak_data_package
external_notification
external_notification_config
@@ -749,6 +750,12 @@ ip_address
ip_port
ipv4_mode
json_output_enabled
+### KEY ###
+key_backup_deleted
+key_backup_not_found
+key_backup_restore_failed
+key_backup_restored
+key_backup_saved
key_verification_final_title
key_verification_request_title
key_verification_title
@@ -1288,6 +1295,8 @@ requesting_from
resend
reset
reset_to_defaults
+restore_keys
+restore_keys_confirmation
retry
ringtone
ringtone_file_empty
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt
deleted file mode 100644
index 7f5c58cc7..000000000
--- a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCase.kt
+++ /dev/null
@@ -1,52 +0,0 @@
-/*
- * 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.domain.usecase.settings
-
-import kotlinx.serialization.json.buildJsonObject
-import kotlinx.serialization.json.put
-import okio.BufferedSink
-import org.koin.core.annotation.Single
-import org.meshtastic.core.common.util.nowMillis
-import org.meshtastic.proto.Config
-
-/** Use case for exporting security configuration to a JSON format. */
-@Single
-open class ExportSecurityConfigUseCase {
- /**
- * Exports the provided [Config.SecurityConfig] as a JSON string to the given [BufferedSink].
- *
- * @param sink The sink to write the JSON to.
- * @param securityConfig The security configuration to export.
- * @return A [Result] indicating success or failure.
- */
- open operator fun invoke(sink: BufferedSink, securityConfig: Config.SecurityConfig): Result = runCatching {
- // Convert ByteStrings to Base64 strings
- val publicKeyBase64 = securityConfig.public_key.base64()
- val privateKeyBase64 = securityConfig.private_key.base64()
-
- // Create a JSON object
- val jsonObject = buildJsonObject {
- put("timestamp", nowMillis)
- put("public_key", publicKeyBase64)
- put("private_key", privateKeyBase64)
- }
-
- val jsonString = jsonObject.toString()
- sink.writeUtf8(jsonString)
- sink.flush()
- }
-}
diff --git a/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportSecurityConfigUseCase.kt b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportSecurityConfigUseCase.kt
new file mode 100644
index 000000000..2c1171f11
--- /dev/null
+++ b/core/domain/src/commonMain/kotlin/org/meshtastic/core/domain/usecase/settings/ImportSecurityConfigUseCase.kt
@@ -0,0 +1,41 @@
+/*
+ * 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.domain.usecase.settings
+
+import okio.ByteString.Companion.decodeBase64
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.StoredSecurityKeys
+import org.meshtastic.proto.Config
+
+/** Use case for restoring security configuration (public/private keys) from a [StoredSecurityKeys] backup. */
+@Single
+open class ImportSecurityConfigUseCase {
+ /**
+ * Decodes a key backup previously saved via [org.meshtastic.core.repository.SecurityKeyBackupStore] into a
+ * [Config.SecurityConfig], ready to apply to a node via the admin-config-write path.
+ *
+ * @param stored The stored key backup to decode.
+ * @return A [Result] containing the decoded [Config.SecurityConfig], or a failure if the backup is malformed.
+ */
+ open operator fun invoke(stored: StoredSecurityKeys): Result = runCatching {
+ val publicKey = stored.publicKeyBase64.decodeBase64()
+ val privateKey = stored.privateKeyBase64.decodeBase64()
+ requireNotNull(publicKey) { "public_key is not valid base64" }
+ requireNotNull(privateKey) { "private_key is not valid base64" }
+ Config.SecurityConfig(public_key = publicKey, private_key = privateKey)
+ }
+}
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
deleted file mode 100644
index acc4889b3..000000000
--- a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ExportSecurityConfigUseCaseTest.kt
+++ /dev/null
@@ -1,60 +0,0 @@
-/*
- * 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.domain.usecase.settings
-
-import kotlinx.serialization.json.Json
-import kotlinx.serialization.json.jsonObject
-import kotlinx.serialization.json.jsonPrimitive
-import okio.Buffer
-import okio.ByteString.Companion.toByteString
-import org.meshtastic.proto.Config
-import kotlin.test.BeforeTest
-import kotlin.test.Test
-import kotlin.test.assertEquals
-import kotlin.test.assertTrue
-
-class ExportSecurityConfigUseCaseTest {
-
- private lateinit var useCase: ExportSecurityConfigUseCase
-
- @BeforeTest
- fun setUp() {
- useCase = ExportSecurityConfigUseCase()
- }
-
- @Test
- fun `invoke writes valid JSON to output stream`() {
- // Arrange
- val publicKey = byteArrayOf(1, 2, 3).toByteString()
- val privateKey = byteArrayOf(4, 5, 6).toByteString()
- val config = Config.SecurityConfig(public_key = publicKey, private_key = privateKey)
- val buffer = Buffer()
-
- // Act
- val result = useCase(buffer, config)
-
- // Assert
- assertTrue(result.isSuccess)
- val json = Json.parseToJsonElement(buffer.readUtf8()).jsonObject
- assertTrue(json.containsKey("timestamp"))
- assertTrue(json.containsKey("public_key"))
- assertTrue(json.containsKey("private_key"))
- // Check base64 values
- assertEquals("AQID", json["public_key"]?.jsonPrimitive?.content)
- assertEquals("BAUG", json["private_key"]?.jsonPrimitive?.content)
- }
-}
diff --git a/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ImportSecurityConfigUseCaseTest.kt b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ImportSecurityConfigUseCaseTest.kt
new file mode 100644
index 000000000..288adb04c
--- /dev/null
+++ b/core/domain/src/commonTest/kotlin/org/meshtastic/core/domain/usecase/settings/ImportSecurityConfigUseCaseTest.kt
@@ -0,0 +1,45 @@
+/*
+ * 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.domain.usecase.settings
+
+import org.meshtastic.core.repository.StoredSecurityKeys
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertTrue
+
+class ImportSecurityConfigUseCaseTest {
+
+ private lateinit var useCase: ImportSecurityConfigUseCase
+
+ @BeforeTest
+ fun setUp() {
+ useCase = ImportSecurityConfigUseCase()
+ }
+
+ @Test
+ fun `invoke decodes a valid backup into a SecurityConfig`() {
+ val stored = StoredSecurityKeys(publicKeyBase64 = "AQID", privateKeyBase64 = "BAUG", timestamp = 123L)
+
+ val result = useCase(stored)
+
+ assertTrue(result.isSuccess)
+ val config = result.getOrThrow()
+ assertEquals(byteArrayOf(1, 2, 3).toList(), config.public_key.toByteArray().toList())
+ assertEquals(byteArrayOf(4, 5, 6).toList(), config.private_key.toByteArray().toList())
+ }
+}
diff --git a/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SecurityKeyBackupStore.kt b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SecurityKeyBackupStore.kt
new file mode 100644
index 000000000..c7ea04b15
--- /dev/null
+++ b/core/repository/src/commonMain/kotlin/org/meshtastic/core/repository/SecurityKeyBackupStore.kt
@@ -0,0 +1,37 @@
+/*
+ * 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.repository
+
+/** A public/private key pair backed up for a single node, plus when it was saved. */
+data class StoredSecurityKeys(val publicKeyBase64: String, val privateKeyBase64: String, val timestamp: Long)
+
+/**
+ * Encrypted per-node storage for security (public/private) key backups.
+ *
+ * Platform implementations should use secure storage (e.g., EncryptedSharedPreferences on Android), keyed by node
+ * number, mirroring iOS's per-node Keychain entries ("PrivateKeyNode").
+ */
+interface SecurityKeyBackupStore {
+ /** Retrieves the stored key backup for the given node number, or null if none is stored. */
+ fun get(nodeNum: Int): StoredSecurityKeys?
+
+ /** Saves (overwriting any existing) key backup for the given node number. */
+ fun save(nodeNum: Int, publicKeyBase64: String, privateKeyBase64: String, timestamp: Long)
+
+ /** Clears the stored key backup for the given node number. */
+ fun delete(nodeNum: Int)
+}
diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml
index b09e794b2..d88c99a71 100644
--- a/core/resources/src/commonMain/composeResources/values/strings.xml
+++ b/core/resources/src/commonMain/composeResources/values/strings.xml
@@ -117,6 +117,8 @@
Audio Config
Available pins
Back
+ Backup Keys
+ Saves the public and private keys to secure, encrypted storage on this device.
Backup & Restore
Bad
Bandwidth
@@ -333,6 +335,8 @@
Delete Network Tile Source
Delete for everyone
Delete for me
+ Delete Key Backup
+ Deletes the encrypted key backup stored on this device. This cannot be undone.
- Delete message?
- Delete %1$s messages?
@@ -564,12 +568,9 @@
Expand chart
Expanded
Expires
-
Export configuration
Export all packets
Export GPX
- Export Keys
- Exports public and private keys to a file. Please store somewhere securely.
Export TAK Data Package
External Notification
External Notification Config
@@ -773,6 +774,12 @@
Port:
IPv4 mode
JSON output enabled
+
+ Key backup deleted
+ No key backup found for this node
+ Failed to restore keys from backup
+ Keys restored — saving to device
+ Keys backed up securely
Key Verification Complete
Key Verification Request
Key Verification
@@ -1330,6 +1337,8 @@
Resend
Reset
Reset to defaults
+ Restore Keys
+ Restores the backed-up public and private keys to this node.
Retry
Ringtone
File is empty
diff --git a/core/service/src/androidMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt
new file mode 100644
index 000000000..5f94b7b3b
--- /dev/null
+++ b/core/service/src/androidMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt
@@ -0,0 +1,80 @@
+/*
+ * 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.service
+
+import android.app.Application
+import android.content.SharedPreferences
+import androidx.security.crypto.EncryptedSharedPreferences
+import androidx.security.crypto.MasterKey
+import co.touchlab.kermit.Logger
+import org.koin.core.annotation.Single
+import org.meshtastic.core.repository.SecurityKeyBackupStore
+import org.meshtastic.core.repository.StoredSecurityKeys
+
+/**
+ * Encrypted per-node storage for security key backups.
+ *
+ * Uses EncryptedSharedPreferences backed by an AES-256-GCM MasterKey (hardware keystore when available), mirroring
+ * [LockdownPassphraseStoreImpl]. Keyed by node number, matching iOS's per-node Keychain entries.
+ */
+@Single(binds = [SecurityKeyBackupStore::class])
+class SecurityKeyBackupStoreImpl(app: Application) : SecurityKeyBackupStore {
+
+ @Suppress("TooGenericExceptionCaught")
+ private val prefs: SharedPreferences? by lazy {
+ try {
+ val masterKey = MasterKey.Builder(app).setKeyScheme(MasterKey.KeyScheme.AES256_GCM).build()
+ EncryptedSharedPreferences.create(
+ app,
+ PREFS_FILE_NAME,
+ masterKey,
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
+ )
+ } catch (e: Exception) {
+ Logger.e(e) { "Failed to initialize encrypted security key backup store" }
+ null
+ }
+ }
+
+ @Suppress("ReturnCount")
+ override fun get(nodeNum: Int): StoredSecurityKeys? {
+ val p = prefs ?: return null
+ val publicKey = p.getString("${nodeNum}_public", null) ?: return null
+ val privateKey = p.getString("${nodeNum}_private", null) ?: return null
+ val timestamp = p.getLong("${nodeNum}_timestamp", 0L)
+ return StoredSecurityKeys(publicKey, privateKey, timestamp)
+ }
+
+ override fun save(nodeNum: Int, publicKeyBase64: String, privateKeyBase64: String, timestamp: Long) {
+ val p = prefs ?: error("Encrypted security key backup store unavailable")
+ p.edit()
+ .putString("${nodeNum}_public", publicKeyBase64)
+ .putString("${nodeNum}_private", privateKeyBase64)
+ .putLong("${nodeNum}_timestamp", timestamp)
+ .apply()
+ }
+
+ override fun delete(nodeNum: Int) {
+ val p = prefs ?: return
+ p.edit().remove("${nodeNum}_public").remove("${nodeNum}_private").remove("${nodeNum}_timestamp").apply()
+ }
+
+ private companion object {
+ private const val PREFS_FILE_NAME = "security_key_backup_store"
+ }
+}
diff --git a/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt
new file mode 100644
index 000000000..891338d5d
--- /dev/null
+++ b/core/service/src/jvmMain/kotlin/org/meshtastic/core/service/SecurityKeyBackupStoreImpl.kt
@@ -0,0 +1,154 @@
+/*
+ * 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.service
+
+import co.touchlab.kermit.Logger
+import org.koin.core.annotation.Single
+import org.meshtastic.core.database.desktopDataDir
+import org.meshtastic.core.repository.SecurityKeyBackupStore
+import org.meshtastic.core.repository.StoredSecurityKeys
+import java.io.File
+import java.io.FileInputStream
+import java.io.FileOutputStream
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/**
+ * File-backed encrypted key-backup store for JVM/Desktop, mirroring [LockdownPassphraseStoreImpl]'s desktop
+ * counterpart. Uses a PKCS12 KeyStore to hold an AES-256 master key and AES-256-GCM to encrypt each node's key backup,
+ * stored as individual `.enc` files under `$MESHTASTIC_DATA_DIR/security_keys/`.
+ */
+@Single(binds = [SecurityKeyBackupStore::class])
+@Suppress("TooGenericExceptionCaught")
+class SecurityKeyBackupStoreImpl : SecurityKeyBackupStore {
+
+ private val storeDir: File by lazy { File(desktopDataDir(), STORE_DIR).also { it.mkdirs() } }
+
+ private val masterKey: SecretKey? by lazy {
+ try {
+ loadOrCreateMasterKey()
+ } catch (e: Exception) {
+ Logger.e(e) { "SecurityKeyBackup: Failed to initialize desktop keystore" }
+ null
+ }
+ }
+
+ @Suppress("ReturnCount")
+ override fun get(nodeNum: Int): StoredSecurityKeys? {
+ val key = masterKey ?: return null
+ val file = entryFile(nodeNum)
+ if (!file.exists()) return null
+ return try {
+ val plaintext = decrypt(key, file.readBytes())
+ deserialize(plaintext)
+ } catch (e: Exception) {
+ Logger.e(e) { "SecurityKeyBackup: Failed to read key backup for node" }
+ null
+ }
+ }
+
+ override fun save(nodeNum: Int, publicKeyBase64: String, privateKeyBase64: String, timestamp: Long) {
+ val key = masterKey ?: error("SecurityKeyBackup: Cannot save keys - keystore unavailable")
+ val plaintext = "$timestamp\n$publicKeyBase64\n$privateKeyBase64".encodeToByteArray()
+ entryFile(nodeNum).writeBytes(encrypt(key, plaintext))
+ }
+
+ override fun delete(nodeNum: Int) {
+ val file = entryFile(nodeNum)
+ if (file.exists() && !file.delete()) {
+ Logger.w { "SecurityKeyBackup: Key backup file was not deleted for node" }
+ }
+ }
+
+ private fun entryFile(nodeNum: Int): File = File(storeDir, "$nodeNum.enc")
+
+ @Suppress("ReturnCount")
+ private fun deserialize(plaintext: ByteArray): StoredSecurityKeys? {
+ val parts = plaintext.decodeToString().split("\n", limit = 3)
+ if (parts.size != SERIALIZED_LINE_COUNT) {
+ Logger.w { "SecurityKeyBackup: Invalid key backup entry format" }
+ return null
+ }
+ val timestamp = parts[0].toLongOrNull() ?: return null
+ return StoredSecurityKeys(publicKeyBase64 = parts[1], privateKeyBase64 = parts[2], timestamp = timestamp)
+ }
+
+ // region Encryption
+
+ private fun encrypt(key: SecretKey, plaintext: ByteArray): ByteArray {
+ val cipher = Cipher.getInstance(AES_GCM_TRANSFORM)
+ cipher.init(Cipher.ENCRYPT_MODE, key)
+ val iv = cipher.iv
+ val ciphertext = cipher.doFinal(plaintext)
+ // Format: [1 byte IV length][IV][ciphertext]
+ return byteArrayOf(iv.size.toByte()) + iv + ciphertext
+ }
+
+ private fun decrypt(key: SecretKey, data: ByteArray): ByteArray {
+ val ivLength = data[0].toInt() and BYTE_MASK
+ val iv = data.copyOfRange(1, 1 + ivLength)
+ val ciphertext = data.copyOfRange(1 + ivLength, data.size)
+ val cipher = Cipher.getInstance(AES_GCM_TRANSFORM)
+ cipher.init(Cipher.DECRYPT_MODE, key, GCMParameterSpec(GCM_TAG_BITS, iv))
+ return cipher.doFinal(ciphertext)
+ }
+
+ // endregion
+
+ // region KeyStore
+
+ private fun loadOrCreateMasterKey(): SecretKey {
+ val ksFile = File(storeDir, KEYSTORE_FILE)
+ val ks = KeyStore.getInstance(KEYSTORE_TYPE)
+ val protection = KeyStore.PasswordProtection(KEYSTORE_PASSWORD)
+ if (ksFile.exists()) {
+ FileInputStream(ksFile).use { ks.load(it, KEYSTORE_PASSWORD) }
+ val entry = ks.getEntry(KEY_ALIAS, protection)
+ // Fail loudly rather than regenerate: overwriting the master key would orphan every existing .enc backup.
+ check(entry is KeyStore.SecretKeyEntry) { "Keystore exists but master key $KEY_ALIAS is missing/invalid" }
+ return entry.secretKey
+ }
+ val keyGen = KeyGenerator.getInstance(AES_ALGORITHM)
+ keyGen.init(AES_KEY_BITS)
+ val secretKey = keyGen.generateKey()
+ ks.load(null, KEYSTORE_PASSWORD)
+ ks.setEntry(KEY_ALIAS, KeyStore.SecretKeyEntry(secretKey), protection)
+ FileOutputStream(ksFile).use { ks.store(it, KEYSTORE_PASSWORD) }
+ return secretKey
+ }
+
+ // endregion
+
+ private companion object {
+ private const val STORE_DIR = "security_keys"
+ private const val KEYSTORE_FILE = "keystore.p12"
+ private const val KEYSTORE_TYPE = "PKCS12"
+ private const val KEY_ALIAS = "security_key_backup_master"
+
+ // Intentional: mirrors LockdownPassphraseStoreImpl's documented desktop threat model.
+ private val KEYSTORE_PASSWORD = "meshtastic-security-keys".toCharArray()
+ private const val AES_ALGORITHM = "AES"
+ private const val AES_GCM_TRANSFORM = "AES/GCM/NoPadding"
+ private const val AES_KEY_BITS = 256
+ private const val GCM_TAG_BITS = 128
+ private const val BYTE_MASK = 0xFF
+ private const val SERIALIZED_LINE_COUNT = 3
+ }
+}
diff --git a/feature/settings/detekt-baseline.xml b/feature/settings/detekt-baseline.xml
index df34a7cc8..7a54174fc 100644
--- a/feature/settings/detekt-baseline.xml
+++ b/feature/settings/detekt-baseline.xml
@@ -23,6 +23,7 @@
LongMethod:PowerConfigItemList.kt:@Composable fun PowerConfigScreen
LongMethod:RadioConfigViewModel.kt:RadioConfigViewModel$fun setResponseStateLoading
LongMethod:RadioConfigViewModel.kt:RadioConfigViewModel$private fun processPacketResponse
+ LongMethod:SecurityConfigScreen.android.kt:@Composable actual fun SecurityKeyBackupActions
LongMethod:SerialConfigItemList.kt:@Composable fun SerialConfigScreen
LongMethod:StoreForwardConfigItemList.kt:@Composable fun StoreForwardConfigScreen
LongMethod:TelemetryConfigItemList.kt:@Composable fun TelemetryConfigScreen
@@ -71,7 +72,7 @@
ModifierMissing:RadioConfig.kt:@Composable fun RadioConfigItemList
ModifierMissing:RangeTestConfigItemList.kt:@Composable fun RangeTestConfigScreen
ModifierMissing:RemoteHardwareConfigItemList.kt:@Composable fun RemoteHardwareConfigScreen
- ModifierMissing:SecurityConfigScreen.android.kt:@Composable actual fun ExportSecurityConfigButton
+ ModifierMissing:SecurityConfigScreen.android.kt:@Composable actual fun SecurityKeyBackupActions
ModifierMissing:SecurityConfigScreen.kt:@Composable @Suppress("LongMethod") fun SecurityConfigScreenCommon
ModifierMissing:SerialConfigItemList.kt:@Composable fun SerialConfigScreen
ModifierMissing:SettingsScreen.kt:@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun SettingsScreen
@@ -85,7 +86,7 @@
MultipleEmitters:MQTTConfigItemList.kt:@Composable private fun MqttAddressAndProbe
MultipleEmitters:PacketResponseStateDialog.kt:@Composable private fun ErrorContent
MultipleEmitters:PacketResponseStateDialog.kt:@Composable private fun SuccessContent
- MultipleEmitters:SecurityConfigScreen.android.kt:@Composable actual fun ExportSecurityConfigButton
+ MultipleEmitters:SecurityConfigScreen.android.kt:@Composable actual fun SecurityKeyBackupActions
MutableStateAutoboxing:DesktopSettingsScreen.kt:mutableStateOf(0)
ParameterNaming:ChannelConfigScreen.kt:onPositiveClicked: (List<ChannelSettings>) -> Unit
ParameterNaming:CleanNodeDatabaseScreen.kt:onCheckedChanged: (Boolean) -> Unit
@@ -112,6 +113,6 @@
ViewModelForwarding:Debug.kt:DebugLogSettings(viewModel = viewModel)
ViewModelForwarding:Debug.kt:DebugSearchStateWithViewModel( viewModel = viewModel, modifier = Modifier.graphicsLayer(alpha = animatedAlpha), searchState = searchState, filterTexts = filterTexts, presetFilters = viewModel.presetFilters, logs = logs, filterMode = filterMode, onFilterModeChange = { filterMode = it }, onExportLogs = { val format = LocalDateTime.Format { year() monthNumber() day() char('_') hour() minute() second() } val timestamp = fromEpochMilliseconds(nowMillis).toLocalDateTime(TimeZone.UTC).format(format) val fileName = "meshtastic_debug_$timestamp.txt" exportLogsLauncher(fileName) }, )
ViewModelForwarding:PositionConfigScreen.kt:DeviceLocationButton( viewModel = viewModel, enabled = state.connected, onLocationReceived = { locationInput = it }, )
- ViewModelForwarding:SecurityConfigScreen.kt:ExportSecurityConfigButton( viewModel = viewModel, enabled = state.connected, securityConfig = securityConfig, )
+ ViewModelForwarding:SecurityConfigScreen.kt:SecurityKeyBackupActions( viewModel = viewModel, enabled = state.connected, securityConfig = securityConfig, )
diff --git a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt
index 13db1fd3d..77a37ef6f 100644
--- a/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt
+++ b/feature/settings/src/androidMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.android.kt
@@ -16,62 +16,76 @@
*/
package org.meshtastic.feature.settings.radio.component
-import android.app.Activity
-import android.content.Intent
-import androidx.activity.compose.rememberLauncherForActivityResult
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
-import androidx.lifecycle.compose.collectAsStateWithLifecycle
-import com.eygraber.uri.toKmpUri
import org.jetbrains.compose.resources.stringResource
-import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.resources.Res
-import org.meshtastic.core.resources.export_keys
-import org.meshtastic.core.resources.export_keys_confirmation
+import org.meshtastic.core.resources.backup_keys
+import org.meshtastic.core.resources.backup_keys_confirmation
+import org.meshtastic.core.resources.delete_key_backup
+import org.meshtastic.core.resources.delete_key_backup_confirmation
+import org.meshtastic.core.resources.restore_keys
+import org.meshtastic.core.resources.restore_keys_confirmation
import org.meshtastic.core.ui.component.MeshtasticResourceDialog
+import org.meshtastic.core.ui.icon.Delete
import org.meshtastic.core.ui.icon.MeshtasticIcons
-import org.meshtastic.core.ui.icon.Warning
+import org.meshtastic.core.ui.icon.Refresh
+import org.meshtastic.core.ui.icon.Save
import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.Config
@Composable
-actual fun ExportSecurityConfigButton(
+actual fun SecurityKeyBackupActions(
viewModel: RadioConfigViewModel,
enabled: Boolean,
securityConfig: Config.SecurityConfig,
) {
- val node by viewModel.destNode.collectAsStateWithLifecycle()
- var showEditSecurityConfigDialog by rememberSaveable { mutableStateOf(false) }
+ var refreshTrigger by remember { mutableIntStateOf(0) }
+ val hasBackup = remember(refreshTrigger) { viewModel.securityKeyBackupExists() }
- val exportConfigLauncher =
- rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
- if (it.resultCode == Activity.RESULT_OK) {
- it.data?.data?.let { uri -> viewModel.exportSecurityConfig(uri.toKmpUri(), securityConfig) }
- }
- }
+ var showBackupDialog by rememberSaveable { mutableStateOf(false) }
+ var showRestoreDialog by rememberSaveable { mutableStateOf(false) }
+ var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
- if (showEditSecurityConfigDialog) {
+ if (showBackupDialog) {
MeshtasticResourceDialog(
- titleRes = Res.string.export_keys,
- messageRes = Res.string.export_keys_confirmation,
- onDismiss = { showEditSecurityConfigDialog = false },
+ titleRes = Res.string.backup_keys,
+ messageRes = Res.string.backup_keys_confirmation,
+ onDismiss = { showBackupDialog = false },
onConfirm = {
- showEditSecurityConfigDialog = false
- val intent =
- Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
- addCategory(Intent.CATEGORY_OPENABLE)
- type = "application/*"
- putExtra(Intent.EXTRA_TITLE, "${node?.user?.short_name}_keys_$nowMillis.json")
- }
- exportConfigLauncher.launch(intent)
+ showBackupDialog = false
+ viewModel.backupSecurityKeys(securityConfig) { refreshTrigger++ }
+ },
+ )
+ }
+ if (showRestoreDialog) {
+ MeshtasticResourceDialog(
+ titleRes = Res.string.restore_keys,
+ messageRes = Res.string.restore_keys_confirmation,
+ onDismiss = { showRestoreDialog = false },
+ onConfirm = {
+ showRestoreDialog = false
+ viewModel.restoreSecurityKeys()
+ },
+ )
+ }
+ if (showDeleteDialog) {
+ MeshtasticResourceDialog(
+ titleRes = Res.string.delete_key_backup,
+ messageRes = Res.string.delete_key_backup_confirmation,
+ onDismiss = { showDeleteDialog = false },
+ onConfirm = {
+ showDeleteDialog = false
+ viewModel.deleteSecurityKeyBackup { refreshTrigger++ }
},
)
}
@@ -79,9 +93,25 @@ actual fun ExportSecurityConfigButton(
HorizontalDivider()
NodeActionButton(
modifier = Modifier.padding(horizontal = 8.dp),
- title = stringResource(Res.string.export_keys),
+ title = stringResource(Res.string.backup_keys),
enabled = enabled,
- icon = MeshtasticIcons.Warning,
- onClick = { showEditSecurityConfigDialog = true },
+ icon = MeshtasticIcons.Save,
+ onClick = { showBackupDialog = true },
+ )
+ HorizontalDivider()
+ NodeActionButton(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ title = stringResource(Res.string.restore_keys),
+ enabled = enabled && hasBackup,
+ icon = MeshtasticIcons.Refresh,
+ onClick = { showRestoreDialog = true },
+ )
+ HorizontalDivider()
+ NodeActionButton(
+ modifier = Modifier.padding(horizontal = 8.dp),
+ title = stringResource(Res.string.delete_key_backup),
+ enabled = enabled && hasBackup,
+ icon = MeshtasticIcons.Delete,
+ onClick = { showDeleteDialog = true },
)
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
index 199808db2..9de0552b7 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModel.kt
@@ -37,11 +37,12 @@ import org.jetbrains.compose.resources.StringResource
import org.koin.core.annotation.InjectedParam
import org.koin.core.annotation.KoinViewModel
import org.meshtastic.core.common.util.CommonUri
+import org.meshtastic.core.common.util.nowMillis
import org.meshtastic.core.common.util.safeCatching
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
-import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
@@ -64,11 +65,18 @@ import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.NodeRepository
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.SecurityKeyBackupStore
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.resources.Res
import org.meshtastic.core.resources.UiText
import org.meshtastic.core.resources.cant_shutdown
+import org.meshtastic.core.resources.key_backup_deleted
+import org.meshtastic.core.resources.key_backup_not_found
+import org.meshtastic.core.resources.key_backup_restore_failed
+import org.meshtastic.core.resources.key_backup_restored
+import org.meshtastic.core.resources.key_backup_saved
import org.meshtastic.core.resources.timeout
+import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.core.ui.util.getChannelList
import org.meshtastic.core.ui.viewmodel.safeLaunch
import org.meshtastic.feature.settings.navigation.ConfigRoute
@@ -131,7 +139,7 @@ open class RadioConfigViewModel(
private val homoglyphEncodingPrefs: HomoglyphPrefs,
protected val importProfileUseCase: ImportProfileUseCase,
protected val exportProfileUseCase: ExportProfileUseCase,
- protected val exportSecurityConfigUseCase: ExportSecurityConfigUseCase,
+ protected val importSecurityConfigUseCase: ImportSecurityConfigUseCase,
private val installProfileUseCase: InstallProfileUseCase,
private val radioConfigUseCase: RadioConfigUseCase,
private val adminActionsUseCase: AdminActionsUseCase,
@@ -140,6 +148,8 @@ open class RadioConfigViewModel(
private val fileService: FileService,
private val mqttManager: MqttManager,
private val lockdownCoordinator: LockdownCoordinator,
+ private val securityKeyBackupStore: SecurityKeyBackupStore,
+ private val snackbarManager: SnackbarManager,
) : ViewModel() {
val lockdownTokenInfo = serviceRepository.lockdownTokenInfo
@@ -553,11 +563,62 @@ open class RadioConfigViewModel(
}
}
- fun exportSecurityConfig(uri: CommonUri, securityConfig: Config.SecurityConfig) {
- safeLaunch(tag = "exportSecurityConfig") {
- fileService.write(uri) { sink ->
- exportSecurityConfigUseCase(sink, securityConfig).onSuccess { /* Success */ }.onFailure { throw it }
+ /** Whether an encrypted key backup exists for the node currently being configured. */
+ fun securityKeyBackupExists(): Boolean {
+ val nodeNum = destNum ?: destNode.value?.num ?: return false
+ return securityKeyBackupStore.get(nodeNum) != null
+ }
+
+ /** Saves the node's current public/private keys to OS-backed encrypted storage, keyed by node number. */
+ fun backupSecurityKeys(securityConfig: Config.SecurityConfig, onComplete: () -> Unit = {}) {
+ val nodeNum = destNum ?: destNode.value?.num ?: return
+ safeLaunch(tag = "backupSecurityKeys") {
+ // Guard against the empty SecurityConfig() fallback: backing up blanks and later restoring them would
+ // overwrite the device's real keys.
+ if (securityConfig.public_key.size == 0 || securityConfig.private_key.size == 0) {
+ snackbarManager.showSnackbar(message = UiText.Resource(Res.string.key_backup_restore_failed).resolve())
+ return@safeLaunch
}
+ securityKeyBackupStore.save(
+ nodeNum = nodeNum,
+ publicKeyBase64 = securityConfig.public_key.base64(),
+ privateKeyBase64 = securityConfig.private_key.base64(),
+ timestamp = nowMillis,
+ )
+ snackbarManager.showSnackbar(message = UiText.Resource(Res.string.key_backup_saved).resolve())
+ onComplete()
+ }
+ }
+
+ /** Restores the previously backed-up keys for this node and pushes them to the device via admin config. */
+ fun restoreSecurityKeys() {
+ val nodeNum = destNum ?: destNode.value?.num ?: return
+ safeLaunch(tag = "restoreSecurityKeys") {
+ val stored = securityKeyBackupStore.get(nodeNum)
+ if (stored == null) {
+ snackbarManager.showSnackbar(message = UiText.Resource(Res.string.key_backup_not_found).resolve())
+ return@safeLaunch
+ }
+ importSecurityConfigUseCase(stored)
+ .onSuccess {
+ setConfig(Config(security = it))
+ snackbarManager.showSnackbar(message = UiText.Resource(Res.string.key_backup_restored).resolve())
+ }
+ .onFailure {
+ snackbarManager.showSnackbar(
+ message = UiText.Resource(Res.string.key_backup_restore_failed).resolve(),
+ )
+ }
+ }
+ }
+
+ /** Deletes the encrypted key backup for this node, if any. */
+ fun deleteSecurityKeyBackup(onComplete: () -> Unit = {}) {
+ val nodeNum = destNum ?: destNode.value?.num ?: return
+ safeLaunch(tag = "deleteSecurityKeyBackup") {
+ securityKeyBackupStore.delete(nodeNum)
+ snackbarManager.showSnackbar(message = UiText.Resource(Res.string.key_backup_deleted).resolve())
+ onComplete()
}
}
diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
index 9d98f53ac..fe75ec7ce 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
@@ -70,7 +70,7 @@ import org.meshtastic.proto.Config
import kotlin.random.Random
@Composable
-expect fun ExportSecurityConfigButton(
+expect fun SecurityKeyBackupActions(
viewModel: RadioConfigViewModel,
enabled: Boolean,
securityConfig: Config.SecurityConfig,
@@ -157,7 +157,7 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
icon = MeshtasticIcons.Warning,
onClick = { showKeyGenerationDialog = true },
)
- ExportSecurityConfigButton(
+ SecurityKeyBackupActions(
viewModel = viewModel,
enabled = state.connected,
securityConfig = securityConfig,
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt
index 8e7ef116d..aaaaea37c 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/ProfileRoundTripTest.kt
@@ -36,8 +36,8 @@ import okio.BufferedSource
import org.meshtastic.core.common.util.CommonUri
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
-import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
@@ -51,9 +51,11 @@ import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.SecurityKeyBackupStore
import org.meshtastic.core.repository.ServiceRepository
import org.meshtastic.core.testing.FakeLockdownCoordinator
import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.Config
import org.meshtastic.proto.DeviceProfile
@@ -81,7 +83,9 @@ class ProfileRoundTripTest {
private val mapConsentPrefs: MapConsentPrefs = mock(MockMode.autofill)
private val analyticsPrefs: AnalyticsPrefs = mock(MockMode.autofill)
private val homoglyphEncodingPrefs: HomoglyphPrefs = mock(MockMode.autofill)
- private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill)
+ private val importSecurityConfigUseCase: ImportSecurityConfigUseCase = mock(MockMode.autofill)
+ private val securityKeyBackupStore: SecurityKeyBackupStore = mock(MockMode.autofill)
+ private val snackbarManager: SnackbarManager = mock(MockMode.autofill)
private val installProfileUseCase: InstallProfileUseCase = mock(MockMode.autofill)
private val radioConfigUseCase: RadioConfigUseCase = mock(MockMode.autofill)
private val adminActionsUseCase: AdminActionsUseCase = mock(MockMode.autofill)
@@ -125,7 +129,7 @@ class ProfileRoundTripTest {
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
importProfileUseCase = ImportProfileUseCase(),
exportProfileUseCase = ExportProfileUseCase(),
- exportSecurityConfigUseCase = exportSecurityConfigUseCase,
+ importSecurityConfigUseCase = importSecurityConfigUseCase,
installProfileUseCase = installProfileUseCase,
radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase,
@@ -134,6 +138,8 @@ class ProfileRoundTripTest {
fileService = fileService,
mqttManager = mqttManager,
lockdownCoordinator = FakeLockdownCoordinator(),
+ securityKeyBackupStore = securityKeyBackupStore,
+ snackbarManager = snackbarManager,
)
}
diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
index e5a781139..055f206b3 100644
--- a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/RadioConfigViewModelTest.kt
@@ -38,10 +38,11 @@ import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
+import okio.ByteString.Companion.encodeUtf8
import org.meshtastic.core.domain.usecase.settings.AdminActionsUseCase
import org.meshtastic.core.domain.usecase.settings.ExportProfileUseCase
-import org.meshtastic.core.domain.usecase.settings.ExportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.ImportProfileUseCase
+import org.meshtastic.core.domain.usecase.settings.ImportSecurityConfigUseCase
import org.meshtastic.core.domain.usecase.settings.InstallProfileUseCase
import org.meshtastic.core.domain.usecase.settings.ProcessRadioResponseUseCase
import org.meshtastic.core.domain.usecase.settings.RadioConfigUseCase
@@ -58,10 +59,13 @@ import org.meshtastic.core.repository.MapConsentPrefs
import org.meshtastic.core.repository.MqttManager
import org.meshtastic.core.repository.PacketRepository
import org.meshtastic.core.repository.RadioConfigRepository
+import org.meshtastic.core.repository.SecurityKeyBackupStore
import org.meshtastic.core.repository.ServiceRepository
+import org.meshtastic.core.repository.StoredSecurityKeys
import org.meshtastic.core.repository.UiPrefs
import org.meshtastic.core.testing.FakeLockdownCoordinator
import org.meshtastic.core.testing.FakeNodeRepository
+import org.meshtastic.core.ui.util.SnackbarManager
import org.meshtastic.feature.settings.navigation.ConfigRoute
import org.meshtastic.proto.ChannelSet
import org.meshtastic.proto.ChannelSettings
@@ -99,7 +103,7 @@ class RadioConfigViewModelTest {
private val importProfileUseCase: ImportProfileUseCase = mock(MockMode.autofill)
private val exportProfileUseCase: ExportProfileUseCase = mock(MockMode.autofill)
- private val exportSecurityConfigUseCase: ExportSecurityConfigUseCase = mock(MockMode.autofill)
+ private val importSecurityConfigUseCase: ImportSecurityConfigUseCase = mock(MockMode.autofill)
private val installProfileUseCase: InstallProfileUseCase = mock(MockMode.autofill)
private val radioConfigUseCase: RadioConfigUseCase = mock(MockMode.autofill)
private val adminActionsUseCase: AdminActionsUseCase = mock(MockMode.autofill)
@@ -108,6 +112,8 @@ class RadioConfigViewModelTest {
private val fileService: FileService = mock(MockMode.autofill)
private val mqttManager: MqttManager = mock(MockMode.autofill)
private val uiPrefs: UiPrefs = mock(MockMode.autofill)
+ private val securityKeyBackupStore: SecurityKeyBackupStore = mock(MockMode.autofill)
+ private val snackbarManager: SnackbarManager = mock(MockMode.autofill)
private lateinit var viewModel: RadioConfigViewModel
@@ -156,7 +162,9 @@ class RadioConfigViewModelTest {
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
importProfileUseCase = importProfileUseCase,
exportProfileUseCase = exportProfileUseCase,
- exportSecurityConfigUseCase = exportSecurityConfigUseCase,
+ importSecurityConfigUseCase = importSecurityConfigUseCase,
+ securityKeyBackupStore = securityKeyBackupStore,
+ snackbarManager = snackbarManager,
installProfileUseCase = installProfileUseCase,
radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase,
@@ -551,7 +559,9 @@ class RadioConfigViewModelTest {
homoglyphEncodingPrefs = homoglyphEncodingPrefs,
importProfileUseCase = importProfileUseCase,
exportProfileUseCase = exportProfileUseCase,
- exportSecurityConfigUseCase = exportSecurityConfigUseCase,
+ importSecurityConfigUseCase = importSecurityConfigUseCase,
+ securityKeyBackupStore = securityKeyBackupStore,
+ snackbarManager = snackbarManager,
installProfileUseCase = installProfileUseCase,
radioConfigUseCase = radioConfigUseCase,
adminActionsUseCase = adminActionsUseCase,
@@ -854,6 +864,61 @@ class RadioConfigViewModelTest {
verifySuspend(exactly(0)) { radioConfigUseCase.getConfig(any(), any()) }
}
+ @Test
+ fun `backupSecurityKeys refuses to persist empty keys`() = runTest {
+ val node = Node(num = 123, user = User(id = "!123"))
+ nodeRepository.setNodes(listOf(node))
+ viewModel = createViewModel()
+
+ // Empty SecurityConfig is the fallback before the device's config response arrives — backing it up and later
+ // restoring it would wipe the device's real keys with blanks.
+ viewModel.backupSecurityKeys(Config.SecurityConfig())
+
+ verify(exactly(0)) { securityKeyBackupStore.save(any(), any(), any(), any()) }
+ }
+
+ @Test
+ fun `backupSecurityKeys persists real keys`() = runTest {
+ val node = Node(num = 123, user = User(id = "!123"))
+ nodeRepository.setNodes(listOf(node))
+ viewModel = createViewModel()
+
+ val config = Config.SecurityConfig(public_key = "pub".encodeUtf8(), private_key = "priv".encodeUtf8())
+ viewModel.backupSecurityKeys(config)
+
+ // Pin the exact base64 so a public/private swap or encoding change is caught.
+ verify { securityKeyBackupStore.save(123, "cHVi", "cHJpdg==", any()) }
+ }
+
+ @Test
+ fun `restoreSecurityKeys is a no-op when no backup exists`() = runTest {
+ val node = Node(num = 123, user = User(id = "!123"))
+ nodeRepository.setNodes(listOf(node))
+ viewModel = createViewModel()
+ every { securityKeyBackupStore.get(123) } returns null
+
+ viewModel.restoreSecurityKeys()
+
+ verifySuspend(exactly(0)) { radioConfigUseCase.setConfig(any(), any()) }
+ }
+
+ @Test
+ fun `restoreSecurityKeys pushes decoded config to the device on success`() = runTest {
+ val node = Node(num = 123, user = User(id = "!123"))
+ nodeRepository.setNodes(listOf(node))
+ viewModel = createViewModel()
+
+ val stored = StoredSecurityKeys(publicKeyBase64 = "cHVi", privateKeyBase64 = "cHJpdg==", timestamp = 1L)
+ val decoded = Config.SecurityConfig(public_key = "pub".encodeUtf8(), private_key = "priv".encodeUtf8())
+ every { securityKeyBackupStore.get(123) } returns stored
+ every { importSecurityConfigUseCase(stored) } returns Result.success(decoded)
+ everySuspend { radioConfigUseCase.setConfig(any(), any()) } returns 42
+
+ viewModel.restoreSecurityKeys()
+
+ verifySuspend { radioConfigUseCase.setConfig(123, Config(security = decoded)) }
+ }
+
private fun myNodeInfo(myNodeNum: Int) = MyNodeInfo(
myNodeNum = myNodeNum,
hasGPS = false,
diff --git a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.ios.kt b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.ios.kt
index f1377f43d..663cec053 100644
--- a/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.ios.kt
+++ b/feature/settings/src/iosMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.ios.kt
@@ -21,10 +21,10 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.Config
@Composable
-actual fun ExportSecurityConfigButton(
+actual fun SecurityKeyBackupActions(
viewModel: RadioConfigViewModel,
enabled: Boolean,
securityConfig: Config.SecurityConfig,
) {
- // No-op for iOS for now
+ // No-op for iOS for now; iOS already has its own native Backup/Restore/Delete flow via Keychain.
}
diff --git a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.jvm.kt b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.jvm.kt
index 4f48cf9a7..2fee27d4b 100644
--- a/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.jvm.kt
+++ b/feature/settings/src/jvmMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.jvm.kt
@@ -21,11 +21,11 @@ import org.meshtastic.feature.settings.radio.RadioConfigViewModel
import org.meshtastic.proto.Config
@Composable
-actual fun ExportSecurityConfigButton(
+actual fun SecurityKeyBackupActions(
viewModel: RadioConfigViewModel,
enabled: Boolean,
securityConfig: Config.SecurityConfig,
) {
- // Desktop currently does not implement a specific "export security config" button
- // within the config screen. If it did, we'd add it here.
+ // Desktop currently does not implement key backup/restore within the config screen.
+ // If it did, we'd add it here.
}