diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt index f097eb04b..8571cc0a9 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/RadioConfigViewModel.kt @@ -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()) { diff --git a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/SecurityConfigItemList.kt b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/SecurityConfigItemList.kt index a7a305c2d..326078d5e 100644 --- a/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/SecurityConfigItemList.kt +++ b/app/src/main/java/com/geeksville/mesh/ui/radioconfig/components/SecurityConfigItemList.kt @@ -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), diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e95e1bb89..c44df9263 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -683,4 +683,6 @@ Compromised keys detected, select OK to regenerate. Regenerate Private Key Are you sure you want to regenerate your Private Key? + Export Keys + Exports public and private keys to a file. Please store somewhere securely.