Работающий TOTP 2fa

This commit is contained in:
2026-05-17 18:59:54 +03:00
parent 845b3a1d76
commit 3820a60d2c
12 changed files with 713 additions and 40 deletions

View File

@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"

View File

@@ -6,6 +6,9 @@ data class TwoFaTokenRecord(
val account: String,
val secret: String,
val notes: String? = null,
val digits: Int = 6,
val periodSeconds: Int = 30,
val algorithm: String = "SHA1",
)
data class TextSecretEntryRecord(

View File

@@ -23,6 +23,9 @@ retrofit = "3.0.0"
okhttp = "5.3.2"
workRuntime = "2.10.0"
hiltWork = "1.3.0"
cameraX = "1.5.0"
mlkitBarcode = "17.3.0"
javaOtp = "0.4.0"
[libraries]
jackson-datatype-jsr310 = { module = "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" }
@@ -65,6 +68,12 @@ androidx-activity-compose = { group = "androidx.activity", name = "activity-comp
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-material-icons-extended = { group = "androidx.compose.material", name = "material-icons-extended" }
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "cameraX" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "cameraX" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "cameraX" }
androidx-camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "cameraX" }
mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" }
java-otp = { group = "com.eatthepath", name = "java-otp", version.ref = "javaOtp" }
androidx-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }

View File

@@ -72,6 +72,11 @@ dependencies {
implementation(libs.androidx.ui.tooling.preview)
implementation(libs.androidx.material3)
implementation(libs.androidx.material.icons.extended)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.androidx.camera.view)
implementation(libs.mlkit.barcode.scanning)
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -0,0 +1,145 @@
package com.github.nullptroma.wallenc.ui.elements
import android.annotation.SuppressLint
import android.view.ViewGroup
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.Preview
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.view.PreviewView
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BasicAlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.github.nullptroma.wallenc.ui.R
import com.google.mlkit.vision.barcode.BarcodeScannerOptions
import com.google.mlkit.vision.barcode.BarcodeScanning
import com.google.mlkit.vision.barcode.common.Barcode
import com.google.mlkit.vision.common.InputImage
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnsafeOptInUsageError")
@Composable
fun QrScannerDialog(
onDismiss: () -> 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<PreviewView?>(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))
}
}
}
}
}

View File

@@ -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<String, String> {
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"
}
}

View File

@@ -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<TwoFaTokenRecord?>(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<String?>(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,7 +358,19 @@ private fun TwoFaTokenEditDialog(
)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
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())
}
@@ -236,6 +392,99 @@ private fun TwoFaTokenEditDialog(
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 },
@@ -243,11 +492,52 @@ private fun TwoFaTokenEditDialog(
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),
)
}
}
}
},
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)
}
}

View File

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

View File

@@ -215,6 +215,21 @@
<string name="two_fa_field_account">Аккаунт</string>
<string name="two_fa_field_secret">Секрет</string>
<string name="two_fa_field_notes_optional">Заметка (опционально)</string>
<string name="two_fa_field_digits">Количество цифр кода (обычно 6 или 8)</string>
<string name="two_fa_field_period_seconds">Период обновления в секундах (обычно 30)</string>
<string name="two_fa_field_algorithm">Алгоритм (SHA1, SHA256, SHA512)</string>
<string name="two_fa_field_digits_value">Количество цифр: %1$d</string>
<string name="two_fa_field_period_seconds_value">Период обновления: %1$d с</string>
<string name="two_fa_code_unavailable">------</string>
<string name="two_fa_code_refresh_in">Обновление через %1$d с</string>
<string name="two_fa_code_refresh_label">Обновление через</string>
<string name="two_fa_code_refresh_seconds">%1$d с</string>
<string name="two_fa_code_invalid_secret">Неверный секрет или формат</string>
<string name="two_fa_copy_code_hint">Нажмите, чтобы скопировать код</string>
<string name="two_fa_scan_qr_action">Сканировать QR</string>
<string name="two_fa_scan_qr_title">Сканирование QR-кода TOTP</string>
<string name="two_fa_scan_qr_invalid">QR-код не содержит валидный otpauth://totp URI</string>
<string name="two_fa_camera_permission_required">Нужно разрешение на камеру для сканирования QR</string>
<string name="text_secret_create">Создать секрет</string>
<string name="text_secret_edit">Редактировать секрет</string>

View File

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

View File

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

View File

@@ -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<Byte>(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"