Работающий TOTP 2fa
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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"
|
||||
Reference in New Issue
Block a user