Secure Export/Import with device credential auth

This commit is contained in:
pavelb 2026-03-06 19:40:17 +01:00
parent c821cdc37b
commit 15e075b293
5 changed files with 57 additions and 73 deletions

View File

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

View File

@ -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<ByteArray?>(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

View File

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

View File

@ -72,4 +72,6 @@
<string name="det_unlock_biometric">Odemknout biometrikou</string>
<string name="det_biometric_prompt_title">Odemknout trezor</string>
<string name="det_biometric_prompt_subtitle">Ověřte se pro přístup do trezoru</string>
<string name="det_export_locked_body">Ověřte se pro zobrazení QR zálohy vašeho device secret.</string>
<string name="det_import_biometric_subtitle">Potvrzení nahrazení device secret</string>
</resources>

View File

@ -71,4 +71,6 @@
<string name="det_unlock_biometric">Unlock with Biometrics</string>
<string name="det_biometric_prompt_title">Unlock Vault</string>
<string name="det_biometric_prompt_subtitle">Authenticate to access your vault</string>
<string name="det_export_locked_body">Authenticate to view your device secret QR backup.</string>
<string name="det_import_biometric_subtitle">Confirm replacing your device secret</string>
</resources>