Skip to content

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

// Package otp is the Go twin of CosmoDev's src/lib/otp.ts (dual source: the
// web lib is TypeScript, the CLI lib is Go — kept in lock-step). Pure +
// deterministic, never panics. The table-driven tests in otp_test.go share
// vectors with src/lib/otp.test.ts so the two implementations are held to the
// same contract.
//
// This is a hand-rolled RFC 6238 (TOTP) / RFC 4226 (HOTP) implementation —
// stdlib only (crypto/hmac, crypto/sha1/256/512, encoding/base32,
// encoding/binary). Because both this code and the TS lib's otpauth dependency
// implement the same RFC 6238 algorithm, they produce identical tokens for
// identical inputs by construction: same secret → same HMAC key; same counter
// → same HMAC input; same hash → same digest; same dynamic-truncation → same
// 31-bit int; same mod 10^digits → same zero-padded token.
package otp

import (
	"crypto/hmac"
	"crypto/sha1"
	"crypto/sha256"
	"crypto/sha512"
	"encoding/base32"
	"encoding/binary"
	"fmt"
	"hash"
	"strings"
)

// Options configures TOTP generation/validation. The zero value (with Secret
// set) matches the TS default: SHA1, 6 digits, 30-second period — mirroring
// otpauth's defaults (algorithm ?? 'SHA1', digits ?? 6, period ?? 30).
type Options struct {
	Secret    string // Base32 (RFC 4648); spaces/case tolerated
	Algorithm string // 'SHA1' | 'SHA256' | 'SHA512' (default 'SHA1')
	Digits    int    // default 6
	Period    int    // seconds, default 30
}

// withDefaults returns opts with zero values replaced by the TS defaults —
// mirrors the `??` coalescing in config() of src/lib/otp.ts.
func (o Options) withDefaults() Options {
	out := o
	if out.Algorithm == "" {
		out.Algorithm = "SHA1"
	}
	if out.Digits == 0 {
		out.Digits = 6
	}
	if out.Period == 0 {
		out.Period = 30
	}
	return out
}

// decodeSecret normalizes and base32-decodes the secret. Mirrors secretFrom()
// in src/lib/otp.ts: strip whitespace, uppercase, then base32-decode. Tolerates
// missing or extra '=' padding (otpauth is lenient here). Returns an error for
// any byte outside the base32 alphabet.
func decodeSecret(secret string) ([]byte, error) {
	// strip all whitespace (any run → nothing), uppercase — exactly like the TS.
	s := strings.ToUpper(strings.Join(strings.Fields(secret), ""))
	// tolerate/strip '=' padding so both padded and unpadded inputs decode.
	s = strings.TrimRight(s, "=")
	key, err := base32.StdEncoding.WithPadding(base32.NoPadding).DecodeString(s)
	if err != nil {
		return nil, fmt.Errorf("invalid base32 secret: %w", err)
	}
	return key, nil
}

// newHasher returns a constructor for the named algorithm's hash.Hash.
// Algorithm is case-insensitive; empty falls back to SHA1 (the TS default).
// An explicitly unknown name returns an error.
func newHasher(algo string) (func() hash.Hash, error) {
	switch strings.ToUpper(algo) {
	case "SHA1", "":
		return sha1.New, nil
	case "SHA256":
		return sha256.New, nil
	case "SHA512":
		return sha512.New, nil
	default:
		return nil, fmt.Errorf("unknown algorithm: %q", algo)
	}
}

// pow10 returns 10^n as a uint64 (no float involved, so no rounding subtleties).
func pow10(n int) uint64 {
	var r uint64 = 1
	for i := 0; i < n; i++ {
		r *= 10
	}
	return r
}

// Generate produces a TOTP token for opts.Secret at timestampMs (milliseconds
// since the Unix epoch). It is the Go twin of generateTotp() in
// src/lib/otp.ts and must produce identical output for identical inputs (both
// implement RFC 6238). Returns an error if the secret is invalid base32 or the
// algorithm is unknown.
func Generate(opts Options, timestampMs int64) (string, error) {
	opts = opts.withDefaults()
	key, err := decodeSecret(opts.Secret)
	if err != nil {
		return "", err
	}
	hasher, err := newHasher(opts.Algorithm)
	if err != nil {
		return "", err
	}

	// counter = floor(timestampMs / 1000 / period) — RFC 6238 §4.2.
	counter := uint64(timestampMs) / 1000 / uint64(opts.Period)

	// pack counter as 8-byte big-endian — RFC 4226 §5.2.
	var msg [8]byte
	binary.BigEndian.PutUint64(msg[:], counter)

	// HMAC the counter with the key — RFC 4226 §5.3.
	mac := hmac.New(hasher, key)
	mac.Write(msg[:])
	sum := mac.Sum(nil)

	// Dynamic truncation — RFC 4226 §5.4.
	// offset = low nibble of the last byte (0–15); offset+3 ≤ 18, always in
	// bounds for SHA1 (20 bytes), SHA256 (32), SHA512 (64).
	offset := int(sum[len(sum)-1] & 0x0f)
	bin := (uint32(sum[offset]&0x7f) << 24) |
		(uint32(sum[offset+1]) << 16) |
		(uint32(sum[offset+2]) << 8) |
		uint32(sum[offset+3])

	// Token = bin mod 10^digits, zero-padded to `digits` width — RFC 4226 §5.3.
	code := uint64(bin) % pow10(opts.Digits)
	return fmt.Sprintf("%0*d", opts.Digits, code), nil
}

// Validate checks whether token is a valid TOTP for opts.Secret at timestampMs,
// accepting the current period and ±1 adjacent periods (matching otpauth's
// default window=1 in TOTP.validate). Returns false — never an error — for an
// invalid secret, mirroring validateTotp() in src/lib/otp.ts.
func Validate(token string, opts Options, timestampMs int64) bool {
	opts = opts.withDefaults()
	periodMs := int64(opts.Period) * 1000
	for _, ts := range []int64{timestampMs, timestampMs - periodMs, timestampMs + periodMs} {
		got, err := Generate(opts, ts)
		if err != nil {
			return false
		}
		if hmac.Equal([]byte(got), []byte(token)) {
			return true
		}
	}
	return false
}

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 →