Работающий TOTP 2fa
This commit is contained in:
@@ -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