OTP Code Generator — C# 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 C# implementation — the same logic the interactive tool runs, in a shareable, citable form.
// otp-code-generator — C# 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 BCL (System.Security.Cryptography); 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.
using System;
using System.Collections.Generic;
using System.Security.Cryptography;
using System.Text;
namespace OtpCodeGenerator;
/// <summary>TOTP options — mirrors <c>Options</c> in the Go twin.</summary>
public sealed class Options
{
/// <summary>Base32 (RFC 4648); spaces/case tolerated.</summary>
public required string Secret { get; init; }
/// <summary>Digits; <= 0 falls back to the TS default 6.</summary>
public int Digits { get; init; }
/// <summary>Period in seconds; <= 0 falls back to the TS default 30.</summary>
public int Period { get; init; }
}
public static class Otp
{
/// <summary>
/// TOTP at <paramref name="timestampMs"/> (ms since epoch) — Generate() in the
/// Go twin (RFC 6238). Null on an invalid secret, like generateTotp() in the TS lib.
/// </summary>
public static string? Generate(Options opts, long timestampMs)
{
int digits = opts.Digits > 0 ? opts.Digits : 6; // withDefaults in the Go twin
int period = opts.Period > 0 ? opts.Period : 30;
byte[]? key = DecodeSecret(opts.Secret);
if (key is null) return null;
ulong counter = (ulong)timestampMs / 1000 / (ulong)period; // RFC 6238 §4.2
byte[] msg = new byte[8];
for (int i = 0; i < 8; i++) msg[i] = (byte)(counter >> (8 * (7 - i))); // RFC 4226 §5.2
using var hmac = new HMACSHA1(key); // RFC 2104 — the Go twin's crypto/hmac
byte[] sum = hmac.ComputeHash(msg);
int off = sum[^1] & 0x0f; // dynamic truncation, RFC 4226 §5.4
uint bin = ((uint)(sum[off] & 0x7f) << 24) | ((uint)sum[off + 1] << 16) |
((uint)sum[off + 2] << 8) | sum[off + 3];
ulong mod = 1;
for (int i = 0; i < digits; i++) mod *= 10;
return (bin % mod).ToString().PadLeft(digits, '0');
}
/// <summary>
/// Validate() in the Go twin: the current period and ±1 adjacent periods
/// (otpauth's default window=1). FixedTimeEquals is the constant-time
/// compare (the Go twin's hmac.Equal).
/// </summary>
public static bool Validate(string token, Options opts, long timestampMs)
{
int period = opts.Period > 0 ? opts.Period : 30;
long window = (long)period * 1000;
foreach (long ts in new[] { timestampMs, timestampMs - window, timestampMs + window })
{
string? candidate = Generate(opts, ts);
if (candidate is not null &&
CryptographicOperations.FixedTimeEquals(
Encoding.ASCII.GetBytes(candidate), Encoding.ASCII.GetBytes(token)))
return true;
}
return false;
}
/// <summary>
/// decodeSecret() in the Go twin: strip whitespace, uppercase, strip trailing
/// '=' padding, then RFC 4648 decode. Null when the secret is invalid.
/// </summary>
internal static byte[]? DecodeSecret(string secret)
{
StringBuilder norm = new();
foreach (char c in secret)
if (!char.IsWhiteSpace(c)) norm.Append(char.ToUpperInvariant(c));
string s = norm.ToString().TrimEnd('=');
List<byte> bytes = new();
int acc = 0, bits = 0;
foreach (char c in s)
{
int v = c is >= 'A' and <= 'Z' ? c - 'A'
: c is >= '2' and <= '7' ? c - '2' + 26
: -1;
if (v < 0) return null;
acc = (acc << 5) | v;
if ((bits += 5) >= 8)
{
bits -= 8;
bytes.Add((byte)(acc >> bits));
}
}
return bytes.ToArray();
}
}
public static class Program
{
public static void Main()
{
// RFC 6238 appendix-B vectors (SHA1, 8 digits, 30s):
// secret = base32("12345678901234567890").
var o8 = new Options { Secret = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ", Digits = 8 };
(long ts, string want)[] vectors =
{
(59_000L, "94287082"),
(1_111_111_109_000L, "07081804"),
(1_234_567_890_000L, "89005924"),
(2_000_000_000_000L, "69279037"),
};
foreach (var (ts, want) in vectors)
Console.WriteLine($"ts={ts,-13} token={Otp.Generate(o8, ts)} want={want}");
// Tool defaults (6 digits) + whitespace/case-tolerant secret entry.
var o6 = new Options { Secret = "gezd gnbv gy3t qojq gezd gnbv gy3t qojq" };
string token = Otp.Generate(o6, 59_000L)!;
Console.WriteLine($"6-digit={token} validate(now)={Otp.Validate(token, o6, 59_000L)} " +
$"validate(+1)={Otp.Validate(token, o6, 89_000L)} " +
$"validate(wrong)={Otp.Validate("123789", o6, 59_000L)}");
}
}
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 →