Skip to content

OTP Code Generator — Kotlin source

Generate time-based one-time passwords (RFC 6238 TOTP) from a Base32 secret, with selectable algorithm, digit count, and period - updating live, entirely in your browser.

This is the Kotlin implementation — the same logic the interactive tool runs, in a shareable, citable form.

// otp-code-generator — Kotlin port: TOTP (RFC 6238) / HOTP (RFC 4226) generator.
//
// Display port of the CosmoDev OTP Code Generator tool — same contract as
// cli/otp-code-generator/otp-code-generator.go (the live Go twin) and
// src/lib/otp.ts (canonical TypeScript, which wraps `otpauth`). HmacSHA1 comes
// from the JDK (javax.crypto); base32 is hand-rolled. Only the SHA1 path is
// ported — the TS default and the algorithm every published RFC 6238/4226 test
// vector uses; SHA256/512 follow the same HMAC construction in the Go twin.
import java.io.ByteArrayOutputStream
import java.security.MessageDigest
import javax.crypto.Mac
import javax.crypto.spec.SecretKeySpec

/** TOTP options — mirrors `Options` in the Go twin. */
data class TotpOptions(
    val secret: String,   // Base32 (RFC 4648); spaces/case tolerated
    val digits: Int = 6,  // <= 0 falls back to 6 (withDefaults in the Go twin)
    val period: Int = 30, // seconds, <= 0 falls back to 30
)

object Otp {
    /**
     * TOTP at [timestampMs] (ms since epoch) — `Generate()` in the Go twin
     * (RFC 6238). Null on an invalid secret, like generateTotp() in the TS lib.
     */
    fun generate(opts: TotpOptions, timestampMs: Long): String? {
        val digits = if (opts.digits > 0) opts.digits else 6
        val period = if (opts.period > 0) opts.period else 30
        val key = decodeSecret(opts.secret) ?: return null

        // Unsigned division mirrors the uint64 cast in the Go twin (JDK static).
        val counter = java.lang.Long.divideUnsigned(timestampMs, 1000) / period // RFC 6238 §4.2
        val msg = ByteArray(8) // big-endian counter, RFC 4226 §5.2
        for (i in 0..7) msg[i] = (counter ushr (8 * (7 - i))).toByte()

        val mac = Mac.getInstance("HmacSHA1") // RFC 2104 — the Go twin's crypto/hmac
        mac.init(SecretKeySpec(key, "HmacSHA1"))
        val sum = mac.doFinal(msg)

        val off = sum.last().toInt() and 0x0f // dynamic truncation, RFC 4226 §5.4
        val bin = ((sum[off].toInt() and 0x7f) shl 24) or ((sum[off + 1].toInt() and 0xff) shl 16) or
            ((sum[off + 2].toInt() and 0xff) shl 8) or (sum[off + 3].toInt() and 0xff)

        var mod = 1UL
        repeat(digits) { mod *= 10u }
        return (bin.toULong() % mod).toString().padStart(digits, '0')
    }

    /**
     * `Validate()` in the Go twin: the current period and ±1 adjacent periods
     * (otpauth's default window=1). MessageDigest.isEqual is the constant-time
     * compare (the Go twin's hmac.Equal).
     */
    fun validate(token: String, opts: TotpOptions, timestampMs: Long): Boolean {
        val period = if (opts.period > 0) opts.period else 30
        val window = period * 1000L
        return longArrayOf(timestampMs, timestampMs - window, timestampMs + window).any { ts ->
            val candidate = generate(opts, ts)
            candidate != null && MessageDigest.isEqual(candidate.toByteArray(), token.toByteArray())
        }
    }

    /**
     * decodeSecret() in the Go twin: strip whitespace, uppercase, strip trailing
     * '=' padding, then RFC 4648 decode. Null when the secret is invalid.
     */
    private fun decodeSecret(secret: String): ByteArray? {
        val norm = secret.filterNot { it.isWhitespace() }.uppercase().trimEnd('=')
        val out = ByteArrayOutputStream()
        var acc = 0
        var bits = 0
        for (c in norm) {
            val v = when (c) {
                in 'A'..'Z' -> c - 'A'
                in '2'..'7' -> c - '2' + 26
                else -> return null
            }
            acc = (acc shl 5) or v
            bits += 5
            if (bits >= 8) {
                bits -= 8
                out.write((acc shr bits) and 0xff)
            }
        }
        return out.toByteArray()
    }
}

fun main() {
    // RFC 6238 appendix-B vectors (SHA1, 8 digits, 30s):
    // secret = base32("12345678901234567890").
    val o8 = TotpOptions("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", digits = 8)
    listOf(
        59_000L to "94287082",
        1_111_111_109_000L to "07081804",
        1_234_567_890_000L to "89005924",
        2_000_000_000_000L to "69279037",
    ).forEach { (ts, want) ->
        println("ts=${ts.toString().padStart(13)} token=${Otp.generate(o8, ts)} want=$want")
    }

    // Tool defaults (6 digits) + whitespace/case-tolerant secret entry.
    val o6 = TotpOptions("gezd gnbv gy3t qojq gezd gnbv gy3t qojq")
    val token = Otp.generate(o6, 59_000L)
    println(
        "6-digit=$token validate(now)=${Otp.validate(token!!, o6, 59_000L)} " +
            "validate(+1)=${Otp.validate(token, o6, 89_000L)} " +
            "validate(wrong)=${Otp.validate("123789", o6, 59_000L)}",
    )
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →