Skip to content

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; &lt;= 0 falls back to the TS default 6.</summary>
    public int Digits { get; init; }

    /// <summary>Period in seconds; &lt;= 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 →