Работающий 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

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