Secure Export/Import with device credential auth

This commit is contained in:
Pavel Baksy 2026-03-06 19:40:17 +01:00
parent 727c8c5d3c
commit 5ae7ea0fc7
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.os.Bundle
import android.view.WindowManager.LayoutParams.FLAG_SECURE import android.view.WindowManager.LayoutParams.FLAG_SECURE
import androidx.activity.ComponentActivity import androidx.appcompat.app.AppCompatActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import cz.bugsy.passwordzebra.ui.navigation.AppNavigation import cz.bugsy.passwordzebra.ui.navigation.AppNavigation
import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme import cz.bugsy.passwordzebra.ui.theme.PasswordZebraTheme
class MainActivity : ComponentActivity() { class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
window.addFlags(FLAG_SECURE) window.addFlags(FLAG_SECURE)

View File

@ -1,38 +1,32 @@
package cz.bugsy.passwordzebra.ui.screens package cz.bugsy.passwordzebra.ui.screens
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap import android.graphics.Bitmap
import android.net.Uri import android.net.Uri
import android.util.Base64 import android.util.Base64
import org.json.JSONArray import org.json.JSONArray
import androidx.activity.compose.rememberLauncherForActivityResult 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.Image
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Tab import androidx.compose.material3.Tab
import androidx.compose.material3.TabRow import androidx.compose.material3.TabRow
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -47,8 +41,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp 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.BarcodeFormat
import com.google.zxing.MultiFormatWriter import com.google.zxing.MultiFormatWriter
import com.journeyapps.barcodescanner.ScanContract import com.journeyapps.barcodescanner.ScanContract
@ -56,17 +51,36 @@ import com.journeyapps.barcodescanner.ScanOptions
import cz.bugsy.passwordzebra.R import cz.bugsy.passwordzebra.R
import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel
private val AUTH = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ExportImportScreen( fun ExportImportScreen(
viewModel: DeterministicViewModel, viewModel: DeterministicViewModel,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
) { ) {
val context = LocalContext.current
var selectedTab by rememberSaveable { mutableIntStateOf(0) } var selectedTab by rememberSaveable { mutableIntStateOf(0) }
var showConfirmDialog by remember { mutableStateOf(false) }
var pendingImportSecret by remember { mutableStateOf<ByteArray?>(null) } fun launchImportAuth(secret: ByteArray) {
var confirmPassword by remember { mutableStateOf("") } val activity = context as? FragmentActivity ?: return
var confirmError by remember { mutableStateOf(false) } 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 scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result ->
val rawValue = result.contents ?: return@rememberLauncherForActivityResult 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) Base64.decode(secretParam, Base64.URL_SAFE or Base64.NO_WRAP or Base64.NO_PADDING)
} catch (_: Exception) { null } } catch (_: Exception) { null }
if (decoded != null && decoded.size == 32) { if (decoded != null && decoded.size == 32) {
pendingImportSecret = decoded
// Parse v2 services if present
val servicesParam = uri.getQueryParameter("services") val servicesParam = uri.getQueryParameter("services")
if (servicesParam != null) { if (servicesParam != null) {
try { try {
@ -92,7 +104,7 @@ fun ExportImportScreen(
} }
} catch (_: Exception) { /* ignore malformed services */ } } 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 @Composable

View File

@ -44,6 +44,9 @@ import androidx.fragment.app.FragmentActivity
import cz.bugsy.passwordzebra.R import cz.bugsy.passwordzebra.R
import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel import cz.bugsy.passwordzebra.deterministic.DeterministicViewModel
private val AUTH = BiometricManager.Authenticators.BIOMETRIC_STRONG or
BiometricManager.Authenticators.DEVICE_CREDENTIAL
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen( fun SettingsScreen(
@ -62,6 +65,25 @@ fun SettingsScreen(
else -> context.getString(R.string.det_timeout_minutes, minutes) 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( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@ -170,7 +192,7 @@ fun SettingsScreen(
// Export / Import link // Export / Import link
Button( Button(
onClick = onNavigateToExportImport, onClick = { launchExportImportAuth() },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { ) {
Text(stringResource(R.string.det_export_import_title)) 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_unlock_biometric">Odemknout biometrikou</string>
<string name="det_biometric_prompt_title">Odemknout trezor</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_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> </resources>

View File

@ -71,4 +71,6 @@
<string name="det_unlock_biometric">Unlock with Biometrics</string> <string name="det_unlock_biometric">Unlock with Biometrics</string>
<string name="det_biometric_prompt_title">Unlock Vault</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_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> </resources>