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 →