Skip to content

OTP Code Generator — Java 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 Java implementation — the same logic the interactive tool runs, in a shareable, citable form.

// otp-code-generator — Java 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.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;

final class OtpCodeGenerator {
    // Package-private: the polyglot display filename is java.java, so the
    // top-level class cannot be public (JLS §7.6 filename rule).
    private OtpCodeGenerator() {}

    /** TOTP options — mirrors {@code Options} in the Go twin. */
    public static final class Options {
        /** Base32 (RFC 4648); spaces/case tolerated. */
        public final String secret;
        /** Digits; <= 0 falls back to the TS default 6. */
        public final int digits;
        /** Period in seconds; <= 0 falls back to the TS default 30. */
        public final int period;

        public Options(String secret, int digits, int period) {
            this.secret = secret;
            this.digits = digits;
            this.period = period;
        }
    }

    /**
     * TOTP at {@code timestampMs} (ms since epoch) — {@code Generate()} in the Go
     * twin (RFC 6238). Null on an invalid secret, like generateTotp() in the TS lib.
     */
    public static String generate(Options opts, long timestampMs) {
        int digits = opts.digits > 0 ? opts.digits : 6;   // withDefaults in the Go twin
        int period = opts.period > 0 ? opts.period : 30;
        byte[] key = decodeSecret(opts.secret);
        if (key == null) return null;

        long counter = Long.divideUnsigned(timestampMs, 1000) / period; // RFC 6238 §4.2
        byte[] msg = new byte[8];
        for (int i = 0; i < 8; i++) msg[i] = (byte) (counter >>> (8 * (7 - i))); // RFC 4226 §5.2

        byte[] sum;
        try {
            Mac mac = Mac.getInstance("HmacSHA1"); // RFC 2104 — the Go twin's crypto/hmac
            mac.init(new SecretKeySpec(key, "HmacSHA1"));
            sum = mac.doFinal(msg);
        } catch (Exception e) {
            return null; // HmacSHA1 ships with every JDK; unreachable in practice
        }

        int off = sum[sum.length - 1] & 0x0f; // dynamic truncation, RFC 4226 §5.4
        int bin = ((sum[off] & 0x7f) << 24) | ((sum[off + 1] & 0xff) << 16)
                | ((sum[off + 2] & 0xff) << 8) | (sum[off + 3] & 0xff);

        long mod = 1;
        for (int i = 0; i < digits; i++) mod *= 10;
        return String.format("%0" + digits + "d", Integer.toUnsignedLong(bin) % mod);
    }

    /**
     * {@code 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).
     */
    public static boolean validate(String token, Options opts, long timestampMs) {
        int period = opts.period > 0 ? opts.period : 30;
        long window = (long) period * 1000;
        for (long ts : new long[] {timestampMs, timestampMs - window, timestampMs + window}) {
            String candidate = generate(opts, ts);
            if (candidate != null
                    && MessageDigest.isEqual(candidate.getBytes(StandardCharsets.US_ASCII),
                                             token.getBytes(StandardCharsets.US_ASCII)))
                return true;
        }
        return false;
    }

    /**
     * decodeSecret() in the Go twin: strip whitespace, uppercase, strip trailing
     * '=' padding, then RFC 4648 decode. Null when the secret is invalid.
     */
    private static byte[] decodeSecret(String secret) {
        StringBuilder sb = new StringBuilder();
        for (char c : secret.toCharArray())
            if (!Character.isWhitespace(c)) sb.append(Character.toUpperCase(c));
        String s = sb.toString();
        int end = s.length();
        while (end > 0 && s.charAt(end - 1) == '=') end--;
        s = s.substring(0, end);

        ByteArrayOutputStream out = new ByteArrayOutputStream();
        int acc = 0, bits = 0;
        for (int i = 0; i < s.length(); i++) {
            char c = s.charAt(i);
            int v = (c >= 'A' && c <= 'Z') ? c - 'A'
                  : (c >= '2' && c <= '7') ? c - '2' + 26 : -1;
            if (v < 0) return null;
            acc = (acc << 5) | v;
            bits += 5;
            if (bits >= 8) {
                bits -= 8;
                out.write((acc >> bits) & 0xff);
            }
        }
        return out.toByteArray();
    }

    public static void main(String[] args) {
        // RFC 6238 appendix-B vectors (SHA1, 8 digits, 30s):
        // secret = base32("12345678901234567890").
        Options o8 = new Options("GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", 8, 30);
        long[] ts = {59_000L, 1_111_111_109_000L, 1_234_567_890_000L, 2_000_000_000_000L};
        String[] want = {"94287082", "07081804", "89005924", "69279037"};
        for (int i = 0; i < ts.length; i++)
            System.out.printf("ts=%-13d token=%s want=%s%n", ts[i], generate(o8, ts[i]), want[i]);

        // Tool defaults (6 digits) + whitespace/case-tolerant secret entry.
        Options o6 = new Options("gezd gnbv gy3t qojq gezd gnbv gy3t qojq", 0, 0);
        String token = generate(o6, 59_000L);
        System.out.printf("6-digit=%s validate(now)=%b validate(+1)=%b validate(wrong)=%b%n",
                token, validate(token, o6, 59_000L), validate(token, o6, 89_000L),
                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 →