mirror of
https://github.com/meshtastic/Meshtastic-Android.git
synced 2026-07-08 12:26:23 -04:00
Add secure key backup/restore/delete for security config (#6105)
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
15
.skills/compose-ui/strings-index.txt
generated
15
.skills/compose-ui/strings-index.txt
generated
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<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>
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user