TOTPGenerator.kt

3.8 KB · raw

1package com.peachpasswords.vault23import javax.crypto.Mac4import javax.crypto.spec.SecretKeySpec56/**7 * RFC 6238 TOTP / RFC 4226 HOTP. Port of the browser totp.ts behaviour:8 * 6 digits, 30-second period, SHA1 by default. The `totp` field on an entry9 * may hold either a raw base32 secret or a full `otpauth://` URI.10 */11object 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    /** Just the code, or null if the secret is unparseable. */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    /** Fraction of the current period elapsed, 0f → 1f. */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        // otpauth://totp/label?secret=XXX&algorithm=SHA1&digits=6&period=3047        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}