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. }