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 →