From 5ae7ea0fc7f7e1ec7f2d1b6fe841f2ec0137dc6f Mon Sep 17 00:00:00 2001 From: Pavel Baksy Date: Fri, 6 Mar 2026 19:40:17 +0100 Subject: [PATCH] Secure Export/Import with device credential auth --- .../cz/bugsy/passwordzebra/MainActivity.kt | 4 +- .../ui/screens/ExportImportScreen.kt | 98 ++++++------------- .../ui/screens/SettingsScreen.kt | 24 ++++- app/src/main/res/values-cs/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + 5 files changed, 57 insertions(+), 73 deletions(-) diff --git a/app/src/main/java/cz/bugsy/passwordzebra/MainActivity.kt b/app/src/main/java/cz/bugsy/passwordzebra/MainActivity.kt index fde4b18..baff493 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/MainActivity.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/MainActivity.kt @@ -2,13 +2,13 @@ package cz.bugsy.passwordzebra import android.os.Bundle import android.view.WindowManager.LayoutParams.FLAG_SECURE -import androidx.activity.ComponentActivity +import androidx.appcompat.app.AppCompatActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import cz.bugsy.passwordzebra.ui.navigation.AppNavigation import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme -class MainActivity : ComponentActivity() { +class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) window.addFlags(FLAG_SECURE) diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt index 080fd32..0601985 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/ExportImportScreen.kt @@ -1,38 +1,32 @@ package cz.bugsy.passwordzebra.ui.screens -import android.app.Activity -import android.content.Intent import android.graphics.Bitmap import android.net.Uri import android.util.Base64 import org.json.JSONArray import androidx.activity.compose.rememberLauncherForActivityResult -import androidx.activity.result.contract.ActivityResultContracts +import androidx.biometric.BiometricManager +import androidx.biometric.BiometricPrompt import androidx.compose.foundation.Image import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold import androidx.compose.material3.Tab import androidx.compose.material3.TabRow import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -47,8 +41,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.PasswordVisualTransformation import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.fragment.app.FragmentActivity import com.google.zxing.BarcodeFormat import com.google.zxing.MultiFormatWriter import com.journeyapps.barcodescanner.ScanContract @@ -56,17 +51,36 @@ import com.journeyapps.barcodescanner.ScanOptions import cz.bugsy.passwordzebra.R import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel +private val AUTH = BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + @OptIn(ExperimentalMaterial3Api::class) @Composable fun ExportImportScreen( viewModel: DeterministicViewModel, onNavigateBack: () -> Unit, ) { + val context = LocalContext.current var selectedTab by rememberSaveable { mutableIntStateOf(0) } - var showConfirmDialog by remember { mutableStateOf(false) } - var pendingImportSecret by remember { mutableStateOf(null) } - var confirmPassword by remember { mutableStateOf("") } - var confirmError by remember { mutableStateOf(false) } + + fun launchImportAuth(secret: ByteArray) { + val activity = context as? FragmentActivity ?: return + val executor = ContextCompat.getMainExecutor(context) + val prompt = BiometricPrompt( + activity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + viewModel.importDeviceSecret(secret) + } + }, + ) + BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.det_import_confirm_title)) + .setSubtitle(context.getString(R.string.det_import_biometric_subtitle)) + .setAllowedAuthenticators(AUTH) + .build() + .let { prompt.authenticate(it) } + } val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> val rawValue = result.contents ?: return@rememberLauncherForActivityResult @@ -77,8 +91,6 @@ fun ExportImportScreen( Base64.decode(secretParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING) } catch (_: Exception) { null } if (decoded != null && decoded.size == 32) { - pendingImportSecret = decoded - // Parse v2 services if present val servicesParam = uri.getQueryParameter("services") if (servicesParam != null) { try { @@ -92,7 +104,7 @@ fun ExportImportScreen( } } catch (_: Exception) { /* ignore malformed services */ } } - showConfirmDialog = true + launchImportAuth(decoded) } } } @@ -136,60 +148,6 @@ fun ExportImportScreen( } } } - - if (showConfirmDialog) { - AlertDialog( - onDismissRequest = { - showConfirmDialog = false - pendingImportSecret = null - confirmPassword = "" - confirmError = false - }, - title = { Text(stringResource(R.string.det_import_confirm_title)) }, - text = { - Column { - Text(stringResource(R.string.det_import_confirm_body)) - Spacer(modifier = Modifier.height(8.dp)) - OutlinedTextField( - value = confirmPassword, - onValueChange = { confirmPassword = it; confirmError = false }, - label = { Text(stringResource(R.string.det_master_password)) }, - visualTransformation = PasswordVisualTransformation(), - isError = confirmError, - supportingText = if (confirmError) { - { Text(stringResource(R.string.det_import_wrong_password)) } - } else null, - ) - } - }, - confirmButton = { - TextButton(onClick = { - // We just require any non-empty master password as confirmation - if (confirmPassword.isNotEmpty()) { - pendingImportSecret?.let { viewModel.importDeviceSecret(it) } - showConfirmDialog = false - pendingImportSecret = null - confirmPassword = "" - confirmError = false - } else { - confirmError = true - } - }) { - Text(stringResource(R.string.det_import_confirm)) - } - }, - dismissButton = { - TextButton(onClick = { - showConfirmDialog = false - pendingImportSecret = null - confirmPassword = "" - confirmError = false - }) { - Text(stringResource(R.string.cancel)) - } - }, - ) - } } @Composable diff --git a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/SettingsScreen.kt b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/SettingsScreen.kt index ea4047a..58b915b 100644 --- a/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/cz/bugsy/passwordzebra/ui/screens/SettingsScreen.kt @@ -44,6 +44,9 @@ import androidx.fragment.app.FragmentActivity import cz.bugsy.passwordzebra.R import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel +private val AUTH = BiometricManager.Authenticators.BIOMETRIC_STRONG or + BiometricManager.Authenticators.DEVICE_CREDENTIAL + @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( @@ -62,6 +65,25 @@ fun SettingsScreen( else -> context.getString(R.string.det_timeout_minutes, minutes) } + fun launchExportImportAuth() { + val activity = context as? FragmentActivity ?: return + val executor = ContextCompat.getMainExecutor(context) + val prompt = BiometricPrompt( + activity, executor, + object : BiometricPrompt.AuthenticationCallback() { + override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { + onNavigateToExportImport() + } + }, + ) + BiometricPrompt.PromptInfo.Builder() + .setTitle(context.getString(R.string.det_export_import_title)) + .setSubtitle(context.getString(R.string.det_export_locked_body)) + .setAllowedAuthenticators(AUTH) + .build() + .let { prompt.authenticate(it) } + } + Scaffold( topBar = { TopAppBar( @@ -170,7 +192,7 @@ fun SettingsScreen( // Export / Import link Button( - onClick = onNavigateToExportImport, + onClick = { launchExportImportAuth() }, modifier = Modifier.fillMaxWidth(), ) { Text(stringResource(R.string.det_export_import_title)) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index c55725d..ed02aa2 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -72,4 +72,6 @@ Odemknout biometrikou Odemknout trezor Ověřte se pro přístup do trezoru + Ověřte se pro zobrazení QR zálohy vašeho device secret. + Potvrzení nahrazení device secret diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5f9b9dc..9808f9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -71,4 +71,6 @@ Unlock with Biometrics Unlock Vault Authenticate to access your vault + Authenticate to view your device secret QR backup. + Confirm replacing your device secret