OTP Code Generator — Zig 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 Zig implementation — the same logic the interactive tool runs, in a shareable, citable form.
// otp-code-generator — Zig 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 std.crypto; 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.
const std = @import("std");
/// TOTP options — mirrors `Options` in the Go twin (zero value + defaults).
pub const TotpOptions = struct {
secret: []const u8, // Base32 (RFC 4648); spaces/case tolerated
digits: u8 = 6, // <= 0 falls back to 6 (withDefaults in the Go twin)
period: u64 = 30, // seconds, <= 0 falls back to 30
};
/// decodeSecret() in the Go twin: strip whitespace, uppercase, drop '='
/// padding, then RFC 4648 decode (A-Z2-7, 5 bits per char, msb-first).
/// Returns the decoded byte count, or null when a char is invalid.
pub fn decodeSecret(secret: []const u8, out: []u8) ?usize {
var acc: u32 = 0;
var bits: u32 = 0;
var n: usize = 0;
for (secret) |c0| {
const c = std.ascii.toUpper(c0);
if (c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '=') continue;
const v: u32 = switch (c) {
'A'...'Z' => c - 'A',
'2'...'7' => c - '2' + 26,
else => return null,
};
acc = (acc << 5) | v;
bits += 5;
if (bits >= 8) {
bits -= 8;
if (n >= out.len) return null;
out[n] = @intCast((acc >> @intCast(bits)) & 0xff);
n += 1;
}
}
return n;
}
/// TOTP at `timestamp_ms` (ms since epoch) — `Generate()` in the Go twin
/// (RFC 6238). Writes into `token` and returns it as a slice, or null on an
/// invalid secret / a too-small buffer (like generateTotp() -> null in TS).
pub fn generate(opts: TotpOptions, timestamp_ms: i64, token: []u8) ?[]u8 {
const digits = if (opts.digits > 0) opts.digits else 6; // withDefaults
const period = if (opts.period > 0) opts.period else 30;
var key_buf: [64]u8 = undefined;
const key_len = decodeSecret(opts.secret, &key_buf) orelse return null;
const key = key_buf[0..key_len];
// counter = floor(ts / 1000 / period) — RFC 6238 §4.2, unsigned like the Go twin.
const counter: u64 = @as(u64, @bitCast(timestamp_ms)) / 1000 / period;
var msg: [8]u8 = undefined;
std.mem.writeInt(u64, &msg, counter, .big); // RFC 4226 §5.2
var mac: [20]u8 = undefined;
std.crypto.auth.hmac.HmacSha1.create(&mac, &msg, key); // RFC 2104
// Dynamic truncation — RFC 4226 §5.4.
const off: usize = @intCast(mac[19] & 0x0f);
const bin: u32 = (@as(u32, mac[off] & 0x7f) << 24) | (@as(u32, mac[off + 1]) << 16) |
(@as(u32, mac[off + 2]) << 8) | @as(u32, mac[off + 3]);
var mod: u64 = 1;
var i: u8 = 0;
while (i < digits) : (i += 1) mod *= 10;
var code: u64 = @as(u64, bin) % mod;
if (token.len < digits) return null;
var j: usize = digits;
while (j > 0) {
j -= 1;
token[j] = '0' + @as(u8, @intCast(code % 10));
code /= 10;
}
return token[0..digits];
}
/// `Validate()` in the Go twin: the current period and ±1 adjacent periods
/// (otpauth's default window=1). Plain eql compare — the Go twin uses the
/// constant-time hmac.Equal for this compare.
pub fn validate(token: []const u8, opts: TotpOptions, timestamp_ms: i64) bool {
const period: i64 = @intCast(if (opts.period > 0) opts.period else 30);
const window: i64 = period * 1000;
var buf: [16]u8 = undefined;
for ([_]i64{ timestamp_ms, timestamp_ms - window, timestamp_ms + window }) |ts| {
if (generate(opts, ts, &buf)) |candidate| {
if (std.mem.eql(u8, candidate, token)) return true;
}
}
return false;
}
pub fn main() !void {
const stdout = std.io.getStdOut().writer();
// RFC 6238 appendix-B vectors (SHA1, 8 digits, 30s):
// secret = base32("12345678901234567890").
const vectors = [_]struct { ts: i64, want: []const u8 }{
.{ .ts = 59_000, .want = "94287082" },
.{ .ts = 1_111_111_109_000, .want = "07081804" },
.{ .ts = 1_234_567_890_000, .want = "89005924" },
.{ .ts = 2_000_000_000_000, .want = "69279037" },
};
var token: [16]u8 = undefined;
for (vectors) |v| {
const got = generate(.{ .secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", .digits = 8 }, v.ts, &token) orelse "invalid";
try stdout.print("ts={d:<13} token={s} want={s}\n", .{ v.ts, got, v.want });
}
// Tool defaults (6 digits) + whitespace/case-tolerant secret entry.
const o6 = TotpOptions{ .secret = "gezd gnbv gy3t qojq gezd gnbv gy3t qojq" };
const token6 = generate(o6, 59_000, &token) orelse "invalid";
try stdout.print("6-digit={s} validate(now)={} validate(+1)={} validate(wrong)={}\n", .{
token6,
validate(token6, o6, 59_000),
validate(token6, o6, 89_000),
validate("123789", o6, 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 →