From 3820a60d2c4f3d9bbeaa462681ba2dc0cba3a2e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9F=D1=8B=D1=82=D0=BA=D0=BE=D0=B2=20=D0=A0=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D0=BD?= Date: Sun, 17 May 2026 18:59:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B1=D0=BE=D1=82=D0=B0=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D0=B9=20TOTP=202fa?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 1 + .../domain/datatypes/StorageDomainRecords.kt | 3 + gradle/libs.versions.toml | 9 + ui/build.gradle.kts | 5 + .../wallenc/ui/elements/QrScannerDialog.kt | 145 +++++++ .../screens/storage/twofa/OtpAuthUriParser.kt | 61 +++ .../storage/twofa/TwoFaTokensScreen.kt | 393 ++++++++++++++++-- .../storage/twofa/TwoFaTokensViewModel.kt | 9 + ui/src/main/res/values/strings.xml | 15 + usecases/build.gradle.kts | 1 + .../usecases/ManageTwoFaTokensUseCase.kt | 28 ++ .../nullptroma/wallenc/usecases/TwoFaTotp.kt | 83 ++++ 12 files changed, 713 insertions(+), 40 deletions(-) create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/elements/QrScannerDialog.kt create mode 100644 ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt create mode 100644 usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8a81c59..407980f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + Unit, + onScanned: (String) -> Unit, +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val cameraProviderFuture = remember { ProcessCameraProvider.getInstance(context) } + val cameraExecutor = remember { Executors.newSingleThreadExecutor() } + val scanner = remember { + BarcodeScanning.getClient( + BarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build(), + ) + } + val consumed = remember { AtomicBoolean(false) } + var previewViewRef by remember { mutableStateOf(null) } + + DisposableEffect(Unit) { + onDispose { + runCatching { cameraProviderFuture.get().unbindAll() } + runCatching { scanner.close() } + cameraExecutor.shutdown() + } + } + + LaunchedEffect(previewViewRef) { + val previewView = previewViewRef ?: return@LaunchedEffect + val cameraProvider = cameraProviderFuture.get() + val preview = Preview.Builder().build().also { + it.surfaceProvider = previewView.surfaceProvider + } + val analysis = ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + .also { imageAnalysis -> + imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy -> + val mediaImage = imageProxy.image + if (mediaImage == null) { + imageProxy.close() + return@setAnalyzer + } + val input = InputImage.fromMediaImage(mediaImage, imageProxy.imageInfo.rotationDegrees) + scanner.process(input) + .addOnSuccessListener { barcodes -> + if (consumed.get()) return@addOnSuccessListener + val raw = barcodes.firstOrNull()?.rawValue?.trim().orEmpty() + if (raw.isNotBlank() && consumed.compareAndSet(false, true)) { + onScanned(raw) + } + } + .addOnCompleteListener { + imageProxy.close() + } + } + } + cameraProvider.unbindAll() + cameraProvider.bindToLifecycle( + lifecycleOwner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + } + + BasicAlertDialog(onDismissRequest = onDismiss) { + Card { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(R.string.two_fa_scan_qr_title), + style = MaterialTheme.typography.titleLarge, + ) + AndroidView( + modifier = Modifier + .fillMaxWidth() + .height(380.dp), + factory = { ctx -> + PreviewView(ctx).apply { + layoutParams = ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + previewViewRef = this + } + }, + ) + Spacer(modifier = Modifier.height(4.dp)) + Button( + modifier = Modifier.fillMaxWidth(), + onClick = onDismiss, + ) { + Text(text = stringResource(R.string.cancel)) + } + } + } + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt new file mode 100644 index 0000000..9c69eb2 --- /dev/null +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/OtpAuthUriParser.kt @@ -0,0 +1,61 @@ +package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa + +import android.net.Uri + +data class ParsedOtpAuthToken( + val issuer: String, + val account: String, + val secret: String, + val digits: Int, + val periodSeconds: Int, + val algorithm: String, +) + +fun parseOtpAuthTotpUri(raw: String): ParsedOtpAuthToken? { + val uri = runCatching { Uri.parse(raw.trim()) }.getOrNull() ?: return null + if (!uri.scheme.equals("otpauth", ignoreCase = true)) return null + if (!uri.host.equals("totp", ignoreCase = true)) return null + + val secret = uri.getQueryParameter("secret")?.trim().orEmpty() + if (secret.isBlank()) return null + + val label = uri.path.orEmpty().trim('/').trim() + val queryIssuer = uri.getQueryParameter("issuer")?.trim().orEmpty() + val (issuerFromLabel, accountFromLabel) = splitLabel(label) + val issuer = queryIssuer.ifBlank { issuerFromLabel }.ifBlank { "TOTP" } + val account = accountFromLabel.ifBlank { label.ifBlank { "account" } } + val digits = uri.getQueryParameter("digits")?.toIntOrNull()?.coerceIn(6, 8) ?: 6 + val periodSeconds = uri.getQueryParameter("period")?.toIntOrNull()?.coerceAtLeast(1) ?: 30 + val algorithm = normalizeAlgorithm(uri.getQueryParameter("algorithm") ?: "SHA1") + + return ParsedOtpAuthToken( + issuer = issuer, + account = account, + secret = secret, + digits = digits, + periodSeconds = periodSeconds, + algorithm = algorithm, + ) +} + +private fun splitLabel(label: String): Pair { + val idx = label.indexOf(':') + return if (idx >= 0) { + label.substring(0, idx).trim() to label.substring(idx + 1).trim() + } else { + "" to label.trim() + } +} + +private fun normalizeAlgorithm(input: String): String { + val normalized = input + .trim() + .uppercase() + .replace("HMAC", "") + .replace("-", "") + .replace("_", "") + return when (normalized) { + "SHA1", "SHA256", "SHA512" -> normalized + else -> "SHA1" + } +} diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt index 2ec226f..13aadf7 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensScreen.kt @@ -1,25 +1,37 @@ package com.github.nullptroma.wallenc.ui.screens.main.screens.storage.twofa +import android.Manifest +import android.content.pm.PackageManager import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn 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.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.QrCodeScanner import androidx.compose.material.icons.outlined.Lock import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -27,21 +39,46 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TextButton +import androidx.compose.material3.DropdownMenu import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.Alignment +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.offset +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.ui.graphics.Color +import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord import com.github.nullptroma.wallenc.ui.R +import com.github.nullptroma.wallenc.ui.elements.QrScannerDialog +import com.github.nullptroma.wallenc.usecases.TwoFaCodeState +import com.github.nullptroma.wallenc.usecases.buildTwoFaCodeState +import kotlinx.coroutines.delay +import kotlin.math.roundToInt +import androidx.compose.ui.unit.IntOffset @Composable fun TwoFaTokensScreen( @@ -49,6 +86,13 @@ fun TwoFaTokensScreen( viewModel: TwoFaTokensViewModel = hiltViewModel(), ) { val uiState by viewModel.state.collectAsStateWithLifecycle() + val clipboard = LocalClipboardManager.current + val nowMillis by produceState(initialValue = System.currentTimeMillis()) { + while (true) { + value = System.currentTimeMillis() + delay(1000) + } + } var editingToken by remember { mutableStateOf(null) } var creating by remember { mutableStateOf(false) } @@ -95,6 +139,7 @@ fun TwoFaTokensScreen( containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, ), ) { + val codeState = rememberTwoFaCode(item, nowMillis) Row( modifier = Modifier .fillMaxWidth() @@ -123,12 +168,6 @@ fun TwoFaTokensScreen( style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Spacer(modifier = Modifier.size(4.dp)) - Text( - text = maskedSecret(item.secret), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) } } Row { @@ -146,6 +185,84 @@ fun TwoFaTokensScreen( } } } + val codeProgress = if (codeState == null) 0f else { + val period = item.periodSeconds.coerceAtLeast(1) + val elapsed = (period - codeState.secondsUntilRefresh) + .coerceAtLeast(0) + .coerceAtMost(period) + elapsed.toFloat() / period.toFloat() + } + Row( + modifier = Modifier + .fillMaxWidth() + .padding(start = 14.dp, end = 14.dp, bottom = 10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = androidx.compose.ui.Alignment.Top, + ) { + Column( + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(R.string.two_fa_code_refresh_label), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = if (codeState != null) { + stringResource( + R.string.two_fa_code_refresh_seconds, + codeState.secondsUntilRefresh, + ) + } else { + stringResource(R.string.two_fa_code_invalid_secret) + }, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 13.sp), + color = if (codeState != null) { + MaterialTheme.colorScheme.onSurfaceVariant + } else { + Color.Red + }, + ) + } + Card( + onClick = { + codeState?.let { + clipboard.setText(AnnotatedString(it.code)) + } + }, + enabled = codeState != null, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surfaceContainer, + ), + ) { + Column( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = androidx.compose.ui.Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Default.ContentCopy, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = codeState?.code ?: stringResource(R.string.two_fa_code_unavailable), + style = MaterialTheme.typography.titleLarge, + fontFamily = FontFamily.Monospace, + textAlign = TextAlign.End, + ) + } + } + } + } + LinearProgressIndicator( + progress = { codeProgress }, + modifier = Modifier.fillMaxWidth(), + ) } } } @@ -158,7 +275,7 @@ fun TwoFaTokensScreen( startValue = null, isBusy = uiState.isMutating, onDismiss = { creating = false }, - onSave = { issuer, account, secret, notes -> + onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm -> creating = false viewModel.saveToken( existingId = null, @@ -166,6 +283,9 @@ fun TwoFaTokensScreen( account = account, secret = secret, notes = notes, + digits = digits, + periodSeconds = periodSeconds, + algorithm = algorithm, ) }, ) @@ -176,7 +296,7 @@ fun TwoFaTokensScreen( startValue = token, isBusy = uiState.isMutating, onDismiss = { editingToken = null }, - onSave = { issuer, account, secret, notes -> + onSave = { issuer, account, secret, notes, digits, periodSeconds, algorithm -> editingToken = null viewModel.saveToken( existingId = token.id, @@ -184,23 +304,47 @@ fun TwoFaTokensScreen( account = account, secret = secret, notes = notes, + digits = digits, + periodSeconds = periodSeconds, + algorithm = algorithm, ) }, ) } } +@OptIn(ExperimentalMaterial3Api::class) @Composable private fun TwoFaTokenEditDialog( startValue: TwoFaTokenRecord?, isBusy: Boolean, onDismiss: () -> Unit, - onSave: (String, String, String, String?) -> Unit, + onSave: (String, String, String, String?, Int, Int, String) -> Unit, ) { + val context = LocalContext.current var issuer by remember(startValue) { mutableStateOf(startValue?.issuer.orEmpty()) } var account by remember(startValue) { mutableStateOf(startValue?.account.orEmpty()) } var secret by remember(startValue) { mutableStateOf(startValue?.secret.orEmpty()) } var notes by remember(startValue) { mutableStateOf(startValue?.notes.orEmpty()) } + var digitsValue by remember(startValue) { mutableStateOf((startValue?.digits ?: 6).coerceIn(6, 8)) } + var periodSecondsValue by remember(startValue) { mutableStateOf((startValue?.periodSeconds ?: 30).coerceIn(10, 120)) } + var algorithmValue by remember(startValue) { mutableStateOf((startValue?.algorithm ?: "SHA1").uppercase()) } + var algorithmExpanded by remember { mutableStateOf(false) } + var showScanner by remember { mutableStateOf(false) } + var scanError by remember { mutableStateOf(null) } + var permissionDenied by remember { mutableStateOf(false) } + val dialogScrollState = rememberScrollState() + var scrollContainerHeightPx by remember { mutableStateOf(0) } + val cameraPermissionLauncher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + ) { granted -> + if (granted) { + showScanner = true + permissionDenied = false + } else { + permissionDenied = true + } + } AlertDialog( onDismissRequest = { if (!isBusy) onDismiss() }, @@ -214,40 +358,186 @@ private fun TwoFaTokenEditDialog( ) }, text = { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - if (isBusy) { - LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + Box( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 520.dp) + .onSizeChanged { scrollContainerHeightPx = it.height }, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(end = 10.dp) + .verticalScroll(dialogScrollState), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isBusy) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } + OutlinedTextField( + value = issuer, + onValueChange = { issuer = it }, + enabled = !isBusy, + label = { Text(stringResource(R.string.two_fa_field_issuer)) }, + ) + OutlinedTextField( + value = account, + onValueChange = { account = it }, + enabled = !isBusy, + label = { Text(stringResource(R.string.two_fa_field_account)) }, + ) + OutlinedTextField( + value = secret, + onValueChange = { secret = it }, + enabled = !isBusy, + label = { Text(stringResource(R.string.two_fa_field_secret)) }, + ) + Text( + text = stringResource(R.string.two_fa_field_algorithm), + style = MaterialTheme.typography.labelMedium, + ) + ExposedDropdownMenuBox( + expanded = algorithmExpanded, + onExpandedChange = { if (!isBusy) algorithmExpanded = !algorithmExpanded }, + ) { + OutlinedTextField( + value = algorithmValue, + onValueChange = {}, + readOnly = true, + enabled = !isBusy, + modifier = Modifier + .fillMaxWidth() + .menuAnchor(), + trailingIcon = { + ExposedDropdownMenuDefaults.TrailingIcon(expanded = algorithmExpanded) + }, + ) + DropdownMenu( + expanded = algorithmExpanded, + onDismissRequest = { algorithmExpanded = false }, + ) { + listOf("SHA1", "SHA256", "SHA512").forEach { algo -> + DropdownMenuItem( + text = { Text(algo) }, + onClick = { + algorithmValue = algo + algorithmExpanded = false + }, + ) + } + } + } + Text( + text = stringResource(R.string.two_fa_field_period_seconds_value, periodSecondsValue), + style = MaterialTheme.typography.labelMedium, + ) + Slider( + value = periodSecondsValue.toFloat(), + onValueChange = { periodSecondsValue = it.roundToInt().coerceIn(10, 120) }, + valueRange = 10f..120f, + steps = 109, + enabled = !isBusy, + ) + Text( + text = stringResource(R.string.two_fa_field_digits_value, digitsValue), + style = MaterialTheme.typography.labelMedium, + ) + Slider( + value = digitsValue.toFloat(), + onValueChange = { digitsValue = it.roundToInt().coerceIn(6, 8) }, + valueRange = 6f..8f, + steps = 1, + enabled = !isBusy, + ) + TextButton( + enabled = !isBusy, + onClick = { + val granted = ContextCompat.checkSelfPermission( + context, + Manifest.permission.CAMERA, + ) == PackageManager.PERMISSION_GRANTED + if (granted) { + showScanner = true + permissionDenied = false + } else { + cameraPermissionLauncher.launch(Manifest.permission.CAMERA) + } + }, + ) { + Icon( + imageVector = Icons.Default.QrCodeScanner, + contentDescription = null, + modifier = Modifier.padding(end = 8.dp), + ) + Text(stringResource(R.string.two_fa_scan_qr_action)) + } + if (permissionDenied) { + Text( + text = stringResource(R.string.two_fa_camera_permission_required), + color = Color.Red, + style = MaterialTheme.typography.bodySmall, + ) + } + scanError?.let { error -> + Text( + text = error, + color = Color.Red, + style = MaterialTheme.typography.bodySmall, + ) + } + OutlinedTextField( + value = notes, + onValueChange = { notes = it }, + enabled = !isBusy, + label = { Text(stringResource(R.string.two_fa_field_notes_optional)) }, + ) + } + val trackColor = MaterialTheme.colorScheme.outlineVariant + val thumbColor = MaterialTheme.colorScheme.primary + Box( + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxSize() + .padding(end = 2.dp, top = 4.dp, bottom = 4.dp), + contentAlignment = Alignment.TopEnd, + ) { + Box( + modifier = Modifier + .fillMaxHeight() + .size(width = 3.dp, height = 0.dp) + .background(trackColor), + ) + if (dialogScrollState.maxValue > 0 && scrollContainerHeightPx > 0) { + val viewport = scrollContainerHeightPx.toFloat() + val content = viewport + dialogScrollState.maxValue.toFloat() + val thumbHeightPx = (viewport * (viewport / content)).coerceAtLeast(24f) + val maxTravel = (viewport - thumbHeightPx).coerceAtLeast(0f) + val offsetY = if (dialogScrollState.maxValue == 0) 0f + else maxTravel * (dialogScrollState.value.toFloat() / dialogScrollState.maxValue.toFloat()) + Box( + modifier = Modifier + .offset { IntOffset(0, offsetY.roundToInt()) } + .size(width = 3.dp, height = (thumbHeightPx / context.resources.displayMetrics.density).dp) + .background(thumbColor), + ) + } } - OutlinedTextField( - value = issuer, - onValueChange = { issuer = it }, - enabled = !isBusy, - label = { Text(stringResource(R.string.two_fa_field_issuer)) }, - ) - OutlinedTextField( - value = account, - onValueChange = { account = it }, - enabled = !isBusy, - label = { Text(stringResource(R.string.two_fa_field_account)) }, - ) - OutlinedTextField( - value = secret, - onValueChange = { secret = it }, - enabled = !isBusy, - label = { Text(stringResource(R.string.two_fa_field_secret)) }, - ) - OutlinedTextField( - value = notes, - onValueChange = { notes = it }, - enabled = !isBusy, - label = { Text(stringResource(R.string.two_fa_field_notes_optional)) }, - ) } }, confirmButton = { TextButton( enabled = !isBusy && issuer.isNotBlank() && account.isNotBlank() && secret.isNotBlank(), - onClick = { onSave(issuer, account, secret, notes.ifBlank { null }) }, + onClick = { + onSave( + issuer, + account, + secret, + notes.ifBlank { null }, + digitsValue, + periodSecondsValue, + algorithmValue, + ) + }, ) { Text(stringResource(R.string.save)) } @@ -258,9 +548,32 @@ private fun TwoFaTokenEditDialog( } }, ) + + if (showScanner) { + QrScannerDialog( + onDismiss = { showScanner = false }, + onScanned = { raw -> + val parsed = parseOtpAuthTotpUri(raw) + if (parsed == null) { + scanError = context.getString(R.string.two_fa_scan_qr_invalid) + return@QrScannerDialog + } + issuer = parsed.issuer + account = parsed.account + secret = parsed.secret + digitsValue = parsed.digits.coerceIn(6, 8) + periodSecondsValue = parsed.periodSeconds.coerceIn(10, 120) + algorithmValue = parsed.algorithm.uppercase() + scanError = null + showScanner = false + }, + ) + } } -private fun maskedSecret(secret: String): String { - if (secret.isBlank()) return "—" - return "••••••••••••" +@Composable +private fun rememberTwoFaCode(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? { + return remember(token, nowMillis) { + buildTwoFaCodeState(token, nowMillis) + } } diff --git a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt index c2a960e..f2c0ee4 100644 --- a/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt +++ b/ui/src/main/java/com/github/nullptroma/wallenc/ui/screens/main/screens/storage/twofa/TwoFaTokensViewModel.kt @@ -83,6 +83,9 @@ class TwoFaTokensViewModel @Inject constructor( account: String, secret: String, notes: String?, + digits: Int = 6, + periodSeconds: Int = 30, + algorithm: String = "SHA1", ) { viewModelScope.launch { val storage = findStorageUseCase.find(storageUuid) ?: return@launch @@ -106,6 +109,9 @@ class TwoFaTokensViewModel @Inject constructor( account = account, secret = secret, notes = notes, + digits = digits, + periodSeconds = periodSeconds, + algorithm = algorithm, ) } else { manageTwoFaTokensUseCase.update( @@ -116,6 +122,9 @@ class TwoFaTokensViewModel @Inject constructor( account = account, secret = secret, notes = notes, + digits = digits, + periodSeconds = periodSeconds, + algorithm = algorithm, ), ) } diff --git a/ui/src/main/res/values/strings.xml b/ui/src/main/res/values/strings.xml index 46d79e0..4b9d5bb 100644 --- a/ui/src/main/res/values/strings.xml +++ b/ui/src/main/res/values/strings.xml @@ -215,6 +215,21 @@ Аккаунт Секрет Заметка (опционально) + Количество цифр кода (обычно 6 или 8) + Период обновления в секундах (обычно 30) + Алгоритм (SHA1, SHA256, SHA512) + Количество цифр: %1$d + Период обновления: %1$d с + ------ + Обновление через %1$d с + Обновление через + %1$d с + Неверный секрет или формат + Нажмите, чтобы скопировать код + Сканировать QR + Сканирование QR-кода TOTP + QR-код не содержит валидный otpauth://totp URI + Нужно разрешение на камеру для сканирования QR Создать секрет Редактировать секрет diff --git a/usecases/build.gradle.kts b/usecases/build.gradle.kts index 625ac0f..7cb6383 100644 --- a/usecases/build.gradle.kts +++ b/usecases/build.gradle.kts @@ -17,5 +17,6 @@ dependencies { implementation(project(":domain")) implementation(libs.kotlinx.coroutines.core) implementation(libs.kotlinx.serialization.json) + implementation(libs.java.otp) testImplementation(libs.junit) } diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt index 4cae690..adb3bfc 100644 --- a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/ManageTwoFaTokensUseCase.kt @@ -49,6 +49,9 @@ class ManageTwoFaTokensUseCase { account: String, secret: String, notes: String? = null, + digits: Int = 6, + periodSeconds: Int = 30, + algorithm: String = "SHA1", ): TwoFaTokenRecord = mutex.withLock { val storage = storageInfo as? IStorage ?: error("Storage is not writable") val next = TwoFaTokenRecord( @@ -57,6 +60,9 @@ class ManageTwoFaTokensUseCase { account = account.trim(), secret = secret.trim(), notes = notes?.trim().takeUnless { it.isNullOrBlank() }, + digits = digits.coerceIn(6, 8), + periodSeconds = periodSeconds.coerceAtLeast(1), + algorithm = normalizeAlgorithm(algorithm), ) val updated = readAll(storage).toMutableList().apply { add(next) } writeAll(storage, updated) @@ -108,12 +114,18 @@ class ManageTwoFaTokensUseCase { val account = obj["account"]?.jsonPrimitive?.contentOrNull ?: return null val secret = obj["secret"]?.jsonPrimitive?.contentOrNull ?: return null val notes = obj["notes"]?.jsonPrimitive?.contentOrNull + val digits = obj["digits"]?.jsonPrimitive?.contentOrNull?.toIntOrNull()?.coerceIn(6, 8) ?: 6 + val periodSeconds = obj["periodSeconds"]?.jsonPrimitive?.contentOrNull?.toIntOrNull()?.coerceAtLeast(1) ?: 30 + val algorithm = normalizeAlgorithm(obj["algorithm"]?.jsonPrimitive?.contentOrNull ?: "SHA1") return TwoFaTokenRecord( id = id, issuer = issuer, account = account, secret = secret, notes = notes, + digits = digits, + periodSeconds = periodSeconds, + algorithm = algorithm, ) } @@ -123,6 +135,22 @@ class ManageTwoFaTokensUseCase { put("account", JsonPrimitive(record.account)) put("secret", JsonPrimitive(record.secret)) record.notes?.let { put("notes", JsonPrimitive(it)) } + put("digits", JsonPrimitive(record.digits)) + put("periodSeconds", JsonPrimitive(record.periodSeconds)) + put("algorithm", JsonPrimitive(normalizeAlgorithm(record.algorithm))) + } + + private fun normalizeAlgorithm(algorithm: String): String { + val normalized = algorithm + .trim() + .uppercase() + .replace("HMAC", "") + .replace("-", "") + .replace("_", "") + return when (normalized) { + "SHA1", "SHA256", "SHA512" -> normalized + else -> "SHA1" + } } private fun domainFilePathEquals(left: String, right: String): Boolean = diff --git a/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt new file mode 100644 index 0000000..0b7e11a --- /dev/null +++ b/usecases/src/main/java/com/github/nullptroma/wallenc/usecases/TwoFaTotp.kt @@ -0,0 +1,83 @@ +package com.github.nullptroma.wallenc.usecases + +import com.eatthepath.otp.TimeBasedOneTimePasswordGenerator +import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord +import java.time.Duration +import java.time.Instant +import javax.crypto.spec.SecretKeySpec + +data class TwoFaCodeState( + val code: String, + val secondsUntilRefresh: Int, +) + +fun buildTwoFaCodeState(token: TwoFaTokenRecord, nowMillis: Long): TwoFaCodeState? { + val period = token.periodSeconds.coerceAtLeast(1) + val remaining = (period - ((nowMillis / 1000L) % period)).toInt().coerceAtLeast(0) + val code = generateTotpCode( + secret = token.secret, + nowMillis = nowMillis, + digits = token.digits.coerceIn(6, 8), + periodSeconds = token.periodSeconds.coerceAtLeast(1), + algorithm = token.algorithm, + ) ?: return null + return TwoFaCodeState( + code = code, + secondsUntilRefresh = remaining, + ) +} + +private fun generateTotpCode( + secret: String, + nowMillis: Long, + digits: Int, + periodSeconds: Int, + algorithm: String, +): String? { + val key = decodeBase32(secret) ?: return null + val macAlgorithm = when (algorithm.trim().uppercase().replace("-", "").replace("_", "")) { + "SHA256" -> "HmacSHA256" + "SHA512" -> "HmacSHA512" + else -> "HmacSHA1" + } + return runCatching { + val generator = TimeBasedOneTimePasswordGenerator( + Duration.ofSeconds(periodSeconds.toLong()), + digits, + macAlgorithm, + ) + val secretKey = SecretKeySpec(key, "RAW") + val otp = generator.generateOneTimePassword( + secretKey, + Instant.ofEpochMilli(nowMillis), + ) + otp.toString().padStart(digits, '0') + }.getOrNull() +} + +private fun decodeBase32(input: String): ByteArray? { + val clean = input + .trim() + .replace(" ", "") + .replace("-", "") + .replace("=", "") + .uppercase() + if (clean.isEmpty()) return null + + var buffer = 0 + var bitsLeft = 0 + val out = ArrayList(clean.length * 5 / 8) + for (ch in clean) { + val value = BASE32_ALPHABET.indexOf(ch) + if (value < 0) return null + buffer = (buffer shl 5) or value + bitsLeft += 5 + if (bitsLeft >= 8) { + out.add(((buffer shr (bitsLeft - 8)) and 0xFF).toByte()) + bitsLeft -= 8 + } + } + return out.toByteArray() +} + +private const val BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"