Skip to content

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

"""otp-code-generator — TOTP (RFC 6238) / HOTP (RFC 4226) code generator.

Language: Python (3.9+, standard library only)
Source:   CosmoDev polyglot showcase port of the OTP Code Generator tool, ported
          from cli/otp-code-generator/otp-code-generator.go (the live Go CLI
          twin — the authoritative reference) and src/lib/otp.ts (canonical
          TypeScript, which wraps the `otpauth` dependency).
License:  display source — part of CosmoDev's polyglot tool pages.

Design goals:
  - Pure + deterministic; never raises (generate returns Optional[str], None on
    a bad secret/algorithm).
  - Functionally equivalent to the Go/TS reference: same inputs -> same outputs
    (both implement RFC 6238, so tokens agree by construction).
  - Self-contained: stdlib only (hashlib/hmac, base64, struct).

Implements RFC 4226 (HOTP: HMAC the 8-byte counter, dynamic-truncate, mod
10^digits) and RFC 6238 (TOTP: counter = floor(timestamp_ms / 1000 / period),
then HOTP). `generate` mirrors Generate() in the Go twin (defaults SHA1, 6
digits, 30-second period); `validate` accepts the current period and +/-1
adjacent periods (matching otpauth's default window=1). Secrets are base32
(RFC 4648); whitespace/case tolerated, '=' padding stripped — exactly like
secretFrom()/decodeSecret() in the TS/Go. Python's // is floor division; every
operand here is non-negative, so it matches Go's integer division exactly.
"""

from __future__ import annotations

import base64
import hashlib
import hmac
import re
import struct
from dataclasses import dataclass
from typing import Callable, Optional

__all__ = ["generate", "validate", "hotp", "decode_secret"]


@dataclass
class Options:
    """Mirrors the Go twin's Options struct + the TS TotpOptions (minus the
    injectable timestamp, which is passed to generate() explicitly)."""
    secret: str                      # Base32 (RFC 4648); spaces/case tolerated
    algorithm: str = "SHA1"          # 'SHA1' | 'SHA256' | 'SHA512'
    digits: int = 6
    period: int = 30                 # seconds


def _with_defaults(opts: Options) -> Options:
    """Apply the TS defaults (the `??` coalescing in src/lib/otp.ts config()).
    A zero digits/period and an empty algorithm fall back to the otpauth
    defaults — 6, 30, and SHA1 — exactly like the Go twin's withDefaults()."""
    return Options(
        secret=opts.secret,
        algorithm=opts.algorithm or "SHA1",
        digits=opts.digits or 6,
        period=opts.period or 30,
    )


def decode_secret(secret: str) -> bytes:
    """Normalize + base32-decode the secret. Twin of decodeSecret() in the Go:
    strip whitespace, uppercase, strip '=' padding, then base32-decode
    (NoPadding mode tolerates missing/extra padding). Raises ValueError on any
    byte outside the base32 alphabet."""
    s = re.sub(r"\s+", "", secret).upper().rstrip("=")
    try:
        return base64.b32decode(s)  # NoPadding: python ignores missing '=' by default
    except Exception as exc:  # base32 binascii error -> a single ValueError type
        raise ValueError(f"invalid base32 secret: {exc}") from exc


def _new_hash(algo: str) -> Callable[[bytes, bytes], bytes]:
    """Return an hmac(key, msg) -> digest callable for the named algorithm.
    Case-insensitive; empty falls back to SHA1 (the TS default). Mirrors
    newHasher() in the Go twin."""
    table = {
        "SHA1": hashlib.sha1,
        "SHA256": hashlib.sha256,
        "SHA512": hashlib.sha512,
    }
    h = table.get((algo or "SHA1").upper())
    if h is None:
        raise ValueError(f"unknown algorithm: {algo!r}")
    return lambda key, msg: hmac.new(key, msg, h).digest()


def _truncate(digest: bytes, digits: int) -> str:
    """RFC 4226 section 5.4 dynamic truncation + mod 10^digits, zero-padded.
    Twin of the truncation tail of Generate() in the Go twin: mask the top bit
    of the 4-byte big-endian window (equiv. to Go's &0x7f on byte[offset])."""
    offset = digest[-1] & 0x0F
    bin_code = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
    return str(bin_code % (10 ** digits)).zfill(digits)


def hotp(opts: Options, counter: int) -> str:
    """RFC 4226 HOTP for opts.Secret at the given 8-byte counter. Shared core:
    generate() builds the counter from the timestamp then calls this. Raises
    ValueError on a bad secret/algorithm. It is the Python twin of the HOTP step
    inside Generate() in the Go twin."""
    opts = _with_defaults(opts)
    key = decode_secret(opts.secret)
    digest = _new_hash(opts.algorithm)(key, struct.pack(">Q", counter))
    return _truncate(digest, opts.digits)


def generate(opts: Options, timestamp_ms: int) -> Optional[str]:
    """TOTP (RFC 6238) for opts.Secret at timestamp_ms (milliseconds since the
    Unix epoch). The Python twin of generateTotp() in src/lib/otp.ts / Generate()
    in the Go — defaults SHA1, 6 digits, 30-second period. Returns None on a bad
    secret/algorithm (mirrors the try/catch in generateTotp())."""
    try:
        opts = _with_defaults(opts)
        counter = timestamp_ms // 1000 // opts.period  # RFC 6238 section 4.2
        return hotp(opts, counter)
    except ValueError:
        return None


def validate(token: str, opts: Options, timestamp_ms: int) -> bool:
    """Check token against opts.Secret at timestamp_ms, accepting the current
    period and +/-1 adjacent periods (otpauth window=1). Never raises — mirrors
    validateTotp() in src/lib/otp.ts / Validate() in the Go twin."""
    opts = _with_defaults(opts)
    period_ms = opts.period * 1000
    for ts in (timestamp_ms, timestamp_ms - period_ms, timestamp_ms + period_ms):
        try:
            got = hotp(opts, ts // 1000 // opts.period)
        except ValueError:
            return False
        if hmac.compare_digest(got, token):
            return True
    return False


if __name__ == "__main__":
    # Showcase vectors — shared with the TS/Go/Rust/PHP/JS twins so every
    # implementation is held to one contract. RFC is the base32 of ASCII
    # "12345678901234567890" — the RFC 6238/4226 reference key.
    RFC = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ"
    assert hotp(Options(RFC), 0) == "755224"                                   # RFC 4226 c=0
    assert generate(Options(RFC, digits=8), 59_000) == "94287082"              # RFC 6238 T=59s
    assert generate(Options("JBSWY3DPEHPK3PXP"), 1_700_000_000_000) == "324550"  # Go-twin lock-step
    assert generate(Options("jbsw y3dp ehpk 3pxp"), 1_700_000_000_000) == "324550"  # spaces/lowercase
    assert generate(Options("!!!not-base32!!!"), 0) is None                    # invalid secret
    assert validate("324550", Options("JBSWY3DPEHPK3PXP"), 1_700_000_000_000)  # round-trip
    print("otp: all showcase vectors passed")

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 →