Add secure key backup/restore/delete for security config (#6105)

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
James Rich
2026-07-05 20:40:29 -05:00
committed by GitHub
parent 4d07bc6641
commit 2fa0abd231
17 changed files with 601 additions and 175 deletions

View File

@@ -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

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Unit> = 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()
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Config.SecurityConfig> = 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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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)
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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())
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<nodeNum>").
*/
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)
}

View File

@@ -117,6 +117,8 @@
<string name="audio_config">Audio Config</string>
<string name="available_pins">Available pins</string>
<string name="back">Back</string>
<string name="backup_keys">Backup Keys</string>
<string name="backup_keys_confirmation">Saves the public and private keys to secure, encrypted storage on this device.</string>
<string name="backup_restore">Backup &amp; Restore</string>
<string name="bad">Bad</string>
<string name="bandwidth">Bandwidth</string>
@@ -333,6 +335,8 @@
<string name="delete_custom_tile_source">Delete Network Tile Source</string>
<string name="delete_for_everyone">Delete for everyone</string>
<string name="delete_for_me">Delete for me</string>
<string name="delete_key_backup">Delete Key Backup</string>
<string name="delete_key_backup_confirmation">Deletes the encrypted key backup stored on this device. This cannot be undone.</string>
<plurals name="delete_messages">
<item quantity="one">Delete message?</item>
<item quantity="other">Delete %1$s messages?</item>
@@ -564,12 +568,9 @@
<string name="expand_chart">Expand chart</string>
<string name="expanded">Expanded</string>
<string name="expires">Expires</string>
<!-- EXPORT -->
<string name="export_configuration">Export configuration</string>
<string name="export_data_csv">Export all packets</string>
<string name="export_gpx">Export GPX</string>
<string name="export_keys">Export Keys</string>
<string name="export_keys_confirmation">Exports public and private keys to a file. Please store somewhere securely.</string>
<string name="export_tak_data_package">Export TAK Data Package</string>
<string name="external_notification">External Notification</string>
<string name="external_notification_config">External Notification Config</string>
@@ -773,6 +774,12 @@
<string name="ip_port">Port:</string>
<string name="ipv4_mode">IPv4 mode</string>
<string name="json_output_enabled">JSON output enabled</string>
<!-- KEY -->
<string name="key_backup_deleted">Key backup deleted</string>
<string name="key_backup_not_found">No key backup found for this node</string>
<string name="key_backup_restore_failed">Failed to restore keys from backup</string>
<string name="key_backup_restored">Keys restored — saving to device</string>
<string name="key_backup_saved">Keys backed up securely</string>
<string name="key_verification_final_title">Key Verification Complete</string>
<string name="key_verification_request_title">Key Verification Request</string>
<string name="key_verification_title">Key Verification</string>
@@ -1330,6 +1337,8 @@
<string name="resend">Resend</string>
<string name="reset">Reset</string>
<string name="reset_to_defaults">Reset to defaults</string>
<string name="restore_keys">Restore Keys</string>
<string name="restore_keys_confirmation">Restores the backed-up public and private keys to this node.</string>
<string name="retry">Retry</string>
<string name="ringtone">Ringtone</string>
<string name="ringtone_file_empty">File is empty</string>

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}
}

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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
}
}

View File

@@ -23,6 +23,7 @@
<ID>LongMethod:PowerConfigItemList.kt:@Composable fun PowerConfigScreen</ID>
<ID>LongMethod:RadioConfigViewModel.kt:RadioConfigViewModel$fun setResponseStateLoading</ID>
<ID>LongMethod:RadioConfigViewModel.kt:RadioConfigViewModel$private fun processPacketResponse</ID>
<ID>LongMethod:SecurityConfigScreen.android.kt:@Composable actual fun SecurityKeyBackupActions</ID>
<ID>LongMethod:SerialConfigItemList.kt:@Composable fun SerialConfigScreen</ID>
<ID>LongMethod:StoreForwardConfigItemList.kt:@Composable fun StoreForwardConfigScreen</ID>
<ID>LongMethod:TelemetryConfigItemList.kt:@Composable fun TelemetryConfigScreen</ID>
@@ -71,7 +72,7 @@
<ID>ModifierMissing:RadioConfig.kt:@Composable fun RadioConfigItemList</ID>
<ID>ModifierMissing:RangeTestConfigItemList.kt:@Composable fun RangeTestConfigScreen</ID>
<ID>ModifierMissing:RemoteHardwareConfigItemList.kt:@Composable fun RemoteHardwareConfigScreen</ID>
<ID>ModifierMissing:SecurityConfigScreen.android.kt:@Composable actual fun ExportSecurityConfigButton</ID>
<ID>ModifierMissing:SecurityConfigScreen.android.kt:@Composable actual fun SecurityKeyBackupActions</ID>
<ID>ModifierMissing:SecurityConfigScreen.kt:@Composable @Suppress("LongMethod") fun SecurityConfigScreenCommon</ID>
<ID>ModifierMissing:SerialConfigItemList.kt:@Composable fun SerialConfigScreen</ID>
<ID>ModifierMissing:SettingsScreen.kt:@Suppress("LongMethod", "CyclomaticComplexMethod") @Composable fun SettingsScreen</ID>
@@ -85,7 +86,7 @@
<ID>MultipleEmitters:MQTTConfigItemList.kt:@Composable private fun MqttAddressAndProbe</ID>
<ID>MultipleEmitters:PacketResponseStateDialog.kt:@Composable private fun ErrorContent</ID>
<ID>MultipleEmitters:PacketResponseStateDialog.kt:@Composable private fun SuccessContent</ID>
<ID>MultipleEmitters:SecurityConfigScreen.android.kt:@Composable actual fun ExportSecurityConfigButton</ID>
<ID>MultipleEmitters:SecurityConfigScreen.android.kt:@Composable actual fun SecurityKeyBackupActions</ID>
<ID>MutableStateAutoboxing:DesktopSettingsScreen.kt:mutableStateOf(0)</ID>
<ID>ParameterNaming:ChannelConfigScreen.kt:onPositiveClicked: (List&lt;ChannelSettings>) -> Unit</ID>
<ID>ParameterNaming:CleanNodeDatabaseScreen.kt:onCheckedChanged: (Boolean) -> Unit</ID>
@@ -112,6 +113,6 @@
<ID>ViewModelForwarding:Debug.kt:DebugLogSettings(viewModel = viewModel)</ID>
<ID>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) }, )</ID>
<ID>ViewModelForwarding:PositionConfigScreen.kt:DeviceLocationButton( viewModel = viewModel, enabled = state.connected, onLocationReceived = { locationInput = it }, )</ID>
<ID>ViewModelForwarding:SecurityConfigScreen.kt:ExportSecurityConfigButton( viewModel = viewModel, enabled = state.connected, securityConfig = securityConfig, )</ID>
<ID>ViewModelForwarding:SecurityConfigScreen.kt:SecurityKeyBackupActions( viewModel = viewModel, enabled = state.connected, securityConfig = securityConfig, )</ID>
</CurrentIssues>
</SmellBaseline>

View File

@@ -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 },
)
}

View File

@@ -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()
}
}

View File

@@ -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,

View File

@@ -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,
)
}

View File

@@ -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,

View File

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

View File

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