1package com.peachpasswords.vault23import javax.crypto.Mac4import javax.crypto.spec.SecretKeySpec567891011object TOTPGenerator {12 const val PERIOD_SECONDS = 3013 private const val DIGITS = 614 private const val BASE32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"1516 data class Otp(val code: String, val remaining: Int)1718 fun generate(totpField: String, nowMillis: Long = System.currentTimeMillis()): Otp? {19 val parsed = parse(totpField) ?: return null20 val key = base32Decode(parsed.secret) ?: return null21 if (key.isEmpty()) return null22 val counter = (nowMillis / 1000L) / PERIOD_SECONDS23 val code = hotp(key, counter, parsed.algorithm)24 return Otp(code, remainingSeconds(nowMillis))25 }2627 28 fun generateCode(totpField: String, nowMillis: Long = System.currentTimeMillis()): String? =29 generate(totpField, nowMillis)?.code3031 fun remainingSeconds(nowMillis: Long = System.currentTimeMillis()): Int =32 PERIOD_SECONDS - ((nowMillis / 1000L) % PERIOD_SECONDS).toInt()3334 35 fun periodProgress(nowMillis: Long = System.currentTimeMillis()): Float =36 ((nowMillis / 1000L) % PERIOD_SECONDS).toFloat() / PERIOD_SECONDS3738 private data class Parsed(val secret: String, val algorithm: String)3940 private fun parse(field: String): Parsed? {41 val trimmed = field.trim()42 if (trimmed.isEmpty()) return null43 if (!trimmed.startsWith("otpauth://", ignoreCase = true)) {44 return Parsed(trimmed.replace(" ", ""), "SHA1")45 }46 47 val query = trimmed.substringAfter('?', "")48 val params = query.split('&').mapNotNull {49 val i = it.indexOf('='); if (i <= 0) null else it.substring(0, i) to it.substring(i + 1)50 }.toMap()51 val secret = params["secret"] ?: return null52 val alg = when (params["algorithm"]?.uppercase()) {53 "SHA256" -> "SHA256"; "SHA512" -> "SHA512"; else -> "SHA1"54 }55 return Parsed(secret.replace(" ", ""), alg)56 }5758 private fun hotp(key: ByteArray, counter: Long, algorithm: String): String {59 val msg = ByteArray(8)60 var c = counter61 for (i in 7 downTo 0) { msg[i] = (c and 0xFF).toByte(); c = c shr 8 }62 val macAlg = when (algorithm) { "SHA256" -> "HmacSHA256"; "SHA512" -> "HmacSHA512"; else -> "HmacSHA1" }63 val mac = Mac.getInstance(macAlg).apply { init(SecretKeySpec(key, macAlg)) }64 val hash = mac.doFinal(msg)65 val offset = (hash[hash.size - 1].toInt() and 0x0F)66 val binary = ((hash[offset].toInt() and 0x7F) shl 24) or67 ((hash[offset + 1].toInt() and 0xFF) shl 16) or68 ((hash[offset + 2].toInt() and 0xFF) shl 8) or69 (hash[offset + 3].toInt() and 0xFF)70 val otp = binary % 1_000_00071 return otp.toString().padStart(DIGITS, '0')72 }7374 private fun base32Decode(input: String): ByteArray? {75 val clean = input.uppercase().replace("=", "").replace(" ", "")76 if (clean.isEmpty()) return null77 val out = ArrayList<Byte>(clean.length * 5 / 8)78 var buffer = 079 var bits = 080 for (ch in clean) {81 val v = BASE32.indexOf(ch)82 if (v < 0) return null83 buffer = (buffer shl 5) or v84 bits += 585 if (bits >= 8) {86 bits -= 887 out.add(((buffer shr bits) and 0xFF).toByte())88 }89 }90 return out.toByteArray()91 }92}