Skip to content

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

# otp-code-generator — Ruby 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`). HMAC-SHA1 comes
# from the OpenSSL stdlib; 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.
require 'openssl'

module Otp
  DEFAULT_DIGITS = 6
  DEFAULT_PERIOD = 30 # seconds
  BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' # RFC 4648

  module_function

  # TOTP at timestamp_ms (ms since epoch) — Generate() in the Go twin
  # (RFC 6238). nil when the secret is invalid, like generateTotp() in the
  # TS lib.
  def generate(secret, timestamp_ms, digits: DEFAULT_DIGITS, period: DEFAULT_PERIOD)
    digits = DEFAULT_DIGITS if digits <= 0 # withDefaults in the Go twin
    period = DEFAULT_PERIOD if period <= 0
    key = decode_secret(secret)
    return nil unless key

    counter = timestamp_ms / 1000 / period             # RFC 6238 §4.2
    msg = [counter].pack('Q>')                         # 8-byte big-endian, RFC 4226 §5.2
    mac = OpenSSL::HMAC.digest('SHA1', key, msg)       # RFC 2104 — the Go twin's crypto/hmac

    offset = mac.getbyte(-1) & 0x0f                    # dynamic truncation, RFC 4226 §5.4
    bin = ((mac.getbyte(offset) & 0x7f) << 24) | (mac.getbyte(offset + 1) << 16) |
          (mac.getbyte(offset + 2) << 8) | mac.getbyte(offset + 3)

    format('%0*d', digits, bin % 10**digits)
  end

  # Validate() in the Go twin: the current period and ±1 adjacent periods
  # (otpauth's default window=1). OpenSSL.secure_compare is the constant-time
  # compare (the Go twin's hmac.Equal).
  def validate(token, secret, timestamp_ms, digits: DEFAULT_DIGITS, period: DEFAULT_PERIOD)
    period = DEFAULT_PERIOD if period <= 0
    window = period * 1000
    [timestamp_ms, timestamp_ms - window, timestamp_ms + window].any? do |ts|
      candidate = generate(secret, ts, digits: digits, period: period)
      candidate && OpenSSL.secure_compare(candidate, token)
    end
  end

  # decodeSecret() in the Go twin: strip whitespace, uppercase, strip trailing
  # '=' padding, then RFC 4648 decode. nil when the secret is invalid.
  def decode_secret(secret)
    norm = secret.gsub(/\s+/, '').upcase.sub(/=+\z/, '')
    bytes = []
    acc = 0
    bits = 0
    norm.each_char do |ch|
      v = BASE32_ALPHABET.index(ch)
      return nil unless v

      acc = (acc << 5) | v
      bits += 5
      if bits >= 8
        bits -= 8
        bytes << ((acc >> bits) & 0xff)
      end
    end
    bytes.pack('C*')
  end
end

# RFC 6238 appendix-B vectors (SHA1, 8 digits, 30s):
# secret = base32("12345678901234567890").
secret8 = 'GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ'
[
  [59_000, '94287082'],
  [1_111_111_109_000, '07081804'],
  [1_234_567_890_000, '89005924'],
  [2_000_000_000_000, '69279037']
].each do |ts, want|
  puts "ts=#{ts} token=#{Otp.generate(secret8, ts, digits: 8)} want=#{want}"
end

# Tool defaults (6 digits) + whitespace/case-tolerant secret entry.
secret6 = 'gezd gnbv gy3t qojq gezd gnbv gy3t qojq'
token = Otp.generate(secret6, 59_000)
puts "6-digit=#{token} validate(now)=#{Otp.validate(token, secret6, 59_000)} " \
     "validate(+1)=#{Otp.validate(token, secret6, 89_000)} " \
     "validate(wrong)=#{Otp.validate('123789', secret6, 59_000)}"

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 →