feat: Add key export functionality (#2158)

This commit is contained in:
James Rich 2025-06-18 21:03:33 +00:00 committed by GitHub
parent 9b045cee5f
commit e205f1d6d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 111 additions and 3 deletions

View file

@ -20,6 +20,7 @@ package com.geeksville.mesh.ui.radioconfig
import android.app.Application
import android.net.Uri
import android.os.RemoteException
import android.util.Base64
import androidx.annotation.StringRes
import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
@ -30,6 +31,7 @@ import com.geeksville.mesh.AdminProtos
import com.geeksville.mesh.ChannelProtos
import com.geeksville.mesh.ClientOnlyProtos.DeviceProfile
import com.geeksville.mesh.ConfigProtos
import com.geeksville.mesh.ConfigProtos.Config.SecurityConfig
import com.geeksville.mesh.IMeshService
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.ModuleConfigProtos
@ -66,6 +68,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.FileOutputStream
import javax.inject.Inject
@ -379,7 +382,6 @@ class RadioConfigViewModel @Inject constructor(
fun exportProfile(uri: Uri, profile: DeviceProfile) = viewModelScope.launch {
writeToUri(uri, profile)
}
private suspend fun writeToUri(uri: Uri, message: MessageLite) = withContext(Dispatchers.IO) {
try {
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
@ -394,6 +396,49 @@ class RadioConfigViewModel @Inject constructor(
}
}
fun exportSecurityConfig(uri: Uri, securityConfig: SecurityConfig) = viewModelScope.launch {
writeSecurityKeysJsonToUri(uri, securityConfig)
}
private suspend fun writeSecurityKeysJsonToUri(uri: Uri, securityConfig: SecurityConfig) =
withContext(Dispatchers.IO) {
try {
val publicKeyBytes =
securityConfig.publicKey.toByteArray()
val privateKeyBytes =
securityConfig.privateKey.toByteArray()
// Convert byte arrays to Base64 strings for human readability in JSON
val publicKeyBase64 = Base64.encodeToString(publicKeyBytes, Base64.NO_WRAP)
val privateKeyBase64 = Base64.encodeToString(privateKeyBytes, Base64.NO_WRAP)
// Create a JSON object
val jsonObject = JSONObject().apply {
put("timestamp", System.currentTimeMillis())
put("public_key", publicKeyBase64)
put("private_key", privateKeyBase64)
}
// Convert JSON object to a string
val jsonString =
jsonObject.toString(4)
app.contentResolver.openFileDescriptor(uri, "wt")?.use { parcelFileDescriptor ->
FileOutputStream(parcelFileDescriptor.fileDescriptor).use { outputStream ->
outputStream.write(jsonString.toByteArray(Charsets.UTF_8))
}
}
setResponseStateSuccess()
} catch (ex: Exception) {
val errorMessage = "Can't write security keys JSON error: ${ex.message}"
errormsg(errorMessage)
sendError(ex.customMessage)
}
}
fun installProfile(protobuf: DeviceProfile) = with(protobuf) {
meshService?.beginEditSettings()
if (hasLongName() || hasShortName()) {

View file

@ -17,6 +17,11 @@
package com.geeksville.mesh.ui.radioconfig.components
import android.app.Activity
import android.content.Intent
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@ -24,6 +29,7 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.twotone.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@ -40,6 +46,7 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.geeksville.mesh.ConfigProtos.Config.SecurityConfig
import com.geeksville.mesh.MeshProtos
import com.geeksville.mesh.R
import com.geeksville.mesh.config
import com.geeksville.mesh.copy
@ -60,6 +67,7 @@ fun SecurityConfigScreen(
viewModel: RadioConfigViewModel = hiltViewModel(),
) {
val state by viewModel.radioConfigState.collectAsStateWithLifecycle()
val node by viewModel.destNode.collectAsStateWithLifecycle()
if (state.responseState.isWaiting()) {
PacketResponseStateDialog(
@ -69,25 +77,40 @@ fun SecurityConfigScreen(
}
SecurityConfigItemList(
user = node?.user,
securityConfig = state.radioConfig.security,
enabled = state.connected,
onConfirm = { securityInput ->
val config = config { security = securityInput }
viewModel.setConfig(config)
}
},
onExport = { uri, securityConfig ->
viewModel.exportSecurityConfig(uri, securityConfig)
},
)
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Suppress("LongMethod")
@Composable
fun SecurityConfigItemList(
user: MeshProtos.User? = null,
securityConfig: SecurityConfig,
enabled: Boolean,
onConfirm: (config: SecurityConfig) -> Unit,
onExport: (uri: Uri, securityConfig: SecurityConfig) -> Unit = { _, _ -> },
) {
val focusManager = LocalFocusManager.current
var securityInput by rememberSaveable { mutableStateOf(securityConfig) }
val exportConfigLauncher = rememberLauncherForActivityResult(
ActivityResultContracts.StartActivityForResult()
) {
if (it.resultCode == Activity.RESULT_OK) {
it.data?.data?.let { uri -> onExport(uri, securityConfig) }
}
}
var showKeyGenerationDialog by rememberSaveable { mutableStateOf(false) }
PrivateKeyRegenerateDialog(
showKeyGenerationDialog = showKeyGenerationDialog,
@ -98,6 +121,32 @@ fun SecurityConfigItemList(
},
onDismiss = { showKeyGenerationDialog = false }
)
var showEditSecurityConfigDialog by rememberSaveable { mutableStateOf(false) }
if (showEditSecurityConfigDialog) {
AlertDialog(
title = { Text(text = stringResource(R.string.export_keys)) },
text = { Text(text = stringResource(R.string.export_keys_confirmation)) },
onDismissRequest = { showEditSecurityConfigDialog = false },
confirmButton = {
TextButton(
onClick = {
showEditSecurityConfigDialog = false
val intent = Intent(Intent.ACTION_CREATE_DOCUMENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
type = "application/*"
putExtra(
Intent.EXTRA_TITLE,
"${user?.shortName}_keys_${System.currentTimeMillis()}.json"
)
}
exportConfigLauncher.launch(intent)
},
) {
Text(stringResource(R.string.okay))
}
},
)
}
LazyColumn(
modifier = Modifier.fillMaxSize()
@ -145,7 +194,7 @@ fun SecurityConfigItemList(
item {
NodeActionButton(
modifier = Modifier.padding(16.dp),
modifier = Modifier.padding(horizontal = 8.dp),
title = stringResource(R.string.regenerate_private_key),
enabled = enabled,
icon = Icons.TwoTone.Warning,
@ -155,6 +204,18 @@ fun SecurityConfigItemList(
)
}
item {
NodeActionButton(
modifier = Modifier.padding(horizontal = 8.dp),
title = stringResource(R.string.export_keys),
enabled = enabled,
icon = Icons.TwoTone.Warning,
onClick = {
showEditSecurityConfigDialog = true
}
)
}
item {
EditListPreference(
title = stringResource(R.string.admin_key),

View file

@ -683,4 +683,6 @@
<string name="compromised_keys">Compromised keys detected, select OK to regenerate.</string>
<string name="regenerate_private_key">Regenerate Private Key</string>
<string name="regenerate_keys_confirmation">Are you sure you want to regenerate your Private Key?</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>
</resources>