Secure Export/Import with device credential auth
This commit is contained in:
parent
727c8c5d3c
commit
5ae7ea0fc7
@ -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)
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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))
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user