Skip to content

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

//! otp-code-generator — TOTP (RFC 6238) / HOTP (RFC 4226) code generator.
//!
//! Language: Rust (edition 2021, standard library only — no crates.io deps)
//! Source:   CosmoDev polyglot showcase port of the OTP Code Generator tool,
//!           ported from cli/otp-code-generator/otp-code-generator.go (the live
//!           Go CLI twin — the authoritative reference) and src/lib/otp.ts
//!           (canonical TypeScript, which wraps the `otpauth` dependency).
//! License:  display source — part of CosmoDev's polyglot tool pages.
//!
//! Design goals:
//!   - Pure + deterministic; never panics (generate returns Option<String>).
//!   - Functionally equivalent to the Go/TS reference on the SHA1 path (the
//!     default and the algorithm every RFC 6238/4226 test vector uses): same
//!     inputs -> same outputs.
//!   - Self-contained: std only. SHA1 + HMAC are hand-rolled from raw bytes —
//!     no external crates (the project is dependency-free for display ports).
//!
//! Implements RFC 4226 (HOTP: HMAC the 8-byte counter, dynamic-truncate, mod
//! 10^digits) and RFC 6238 (TOTP: counter = timestamp_ms / 1000 / period, then
//! HOTP). `generate` mirrors Generate() in the Go twin (defaults SHA1, 6 digits,
//! 30-second period); `validate` accepts the current period and +/-1 adjacent
//! periods (matching otpauth's default window=1). Secrets are base32 (RFC 4648);
//! whitespace/case tolerated, '=' padding stripped — exactly like
//! secretFrom()/decodeSecret() in the TS/Go.
//!
//! Scope: this port hand-rolls SHA1 (the default hash). SHA256/SHA512 — which
//! the Go twin pulls from crypto/sha256/512 — follow the same HMAC construction
//! and are omitted only to keep the dependency-free display port focused; the
//! SHA1 path covers every published RFC 6238/4226 test vector.

/// OTP options. Mirrors the Go twin's Options struct + the TS TotpOptions
/// (minus the injectable timestamp, passed to generate() explicitly). Only SHA1
/// is implemented in this port (see module docs); a non-SHA1 algorithm yields
/// None from hotp/generate.
pub struct Options {
    pub secret: String,    // Base32 (RFC 4648); spaces/case tolerated
    pub algorithm: String, // 'SHA1' (only SHA1 is implemented in this port)
    pub digits: u32,
    pub period: u64,       // seconds
}

impl Default for Options {
    fn default() -> Self {
        Options {
            secret: String::new(),
            algorithm: String::from("SHA1"),
            digits: 6,
            period: 30,
        }
    }
}

/// Normalize + base32-decode the secret. Twin of decodeSecret() in the Go:
/// strip whitespace, uppercase, strip '=' padding, then base32-decode. Returns
/// None on any byte outside the base32 alphabet.
pub fn decode_secret(secret: &str) -> Option<Vec<u8>> {
    let upper: String = secret
        .chars()
        .filter(|c| !c.is_whitespace())
        .map(|c| c.to_ascii_uppercase())
        .collect();
    let trimmed = upper.trim_end_matches('=');
    base32_decode(trimmed)
}

/// RFC 4648 base32 decoder (NoPadding). Hand-rolled to mirror Go's
/// base32.StdEncoding.WithPadding(base32.NoPadding); None on a bad char.
fn base32_decode(input: &str) -> Option<Vec<u8>> {
    const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";
    let bytes = input.as_bytes();
    if bytes.is_empty() {
        return Some(Vec::new());
    }
    let mut out = Vec::with_capacity(bytes.len() * 5 / 8);
    let mut bits = 0u32;
    let mut value: u32 = 0;
    for &b in bytes {
        let idx = ALPHA.iter().position(|&c| c == b)? as u32;
        value = (value << 5) | idx;
        bits += 5;
        if bits >= 8 {
            bits -= 8;
            out.push(((value >> bits) & 0xFF) as u8);
        }
    }
    Some(out)
}

/// Apply the TS defaults (the `??` coalescing in src/lib/otp.ts config()). A
/// zero digits/period and an empty algorithm fall back to the otpauth defaults
/// — 6, 30, SHA1 — exactly like the Go twin's withDefaults().
fn with_defaults(opts: &Options) -> (u32, u64, String) {
    let digits = if opts.digits == 0 { 6 } else { opts.digits };
    let period = if opts.period == 0 { 30 } else { opts.period };
    let algo = if opts.algorithm.is_empty() {
        String::from("SHA1")
    } else {
        opts.algorithm.to_ascii_uppercase()
    };
    (digits, period, algo)
}

// ---------------------------------------------------------------------------
// SHA-1 (FIPS 180-4) + HMAC-SHA1 (RFC 2104), hand-rolled from std bytes.
// No external crates — the dependency-free display port.
// ---------------------------------------------------------------------------

const SHA1_BLOCK: usize = 64;

/// Compute SHA-1 of `msg`. Pure, allocation-light, std only.
fn sha1(msg: &[u8]) -> [u8; 20] {
    // Initial hash values — FIPS 180-4 section 5.3.1.
    let mut h: [u32; 5] = [0x67452301, 0xEFCDAB89, 0x98BADCFE, 0x10325476, 0xC3D2E1F0];

    // Padding: append 0x80, zero-fill to (len % 64 == 56), then the 64-bit
    // big-endian bit length — FIPS 180-4 section 5.1.1.
    let bit_len = (msg.len() as u64) * 8;
    let mut padded = Vec::with_capacity(msg.len() + SHA1_BLOCK);
    padded.extend_from_slice(msg);
    padded.push(0x80);
    while padded.len() % SHA1_BLOCK != 56 {
        padded.push(0);
    }
    padded.extend_from_slice(&bit_len.to_be_bytes());

    // Round constants (section 4.1.1) + per-block compression (section 6.1.2).
    const K: [u32; 4] = [0x5A827999, 0x6ED9EBA1, 0x8F1BBCDC, 0xCA62C1D6];
    for chunk in padded.chunks(SHA1_BLOCK) {
        let mut w = [0u32; 80];
        for i in 0..16 {
            w[i] = u32::from_be_bytes([
                chunk[i * 4],
                chunk[i * 4 + 1],
                chunk[i * 4 + 2],
                chunk[i * 4 + 3],
            ]);
        }
        for i in 16..80 {
            w[i] = (w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16]).rotate_left(1);
        }

        let [mut a, mut b, mut c, mut d, mut e] = h;
        for i in 0..80 {
            let (f, k) = match i {
                0..=19 => ((b & c) | ((!b) & d), K[0]),
                20..=39 => (b ^ c ^ d, K[1]),
                40..=59 => ((b & c) | (b & d) | (c & d), K[2]),
                _ => (b ^ c ^ d, K[3]),
            };
            let temp = a
                .rotate_left(5)
                .wrapping_add(f)
                .wrapping_add(e)
                .wrapping_add(k)
                .wrapping_add(w[i]);
            e = d;
            d = c;
            c = b.rotate_left(30);
            b = a;
            a = temp;
        }
        h[0] = h[0].wrapping_add(a);
        h[1] = h[1].wrapping_add(b);
        h[2] = h[2].wrapping_add(c);
        h[3] = h[3].wrapping_add(d);
        h[4] = h[4].wrapping_add(e);
    }

    let mut out = [0u8; 20];
    for (i, word) in h.iter().enumerate() {
        out[i * 4..i * 4 + 4].copy_from_slice(&word.to_be_bytes());
    }
    out
}

/// HMAC-SHA1 (RFC 2104) of `msg` under `key`. block_size = 64 (SHA-1). A key
/// longer than one block is first hashed; the key is then zero-padded to 64
/// bytes and XORed into the inner/outer pads.
fn hmac_sha1(key: &[u8], msg: &[u8]) -> [u8; 20] {
    let mut block = [0u8; SHA1_BLOCK];
    if key.len() > SHA1_BLOCK {
        let h = sha1(key);
        block[..20].copy_from_slice(&h);
    } else {
        block[..key.len()].copy_from_slice(key);
    }
    let mut ipad = [0u8; SHA1_BLOCK];
    let mut opad = [0u8; SHA1_BLOCK];
    for i in 0..SHA1_BLOCK {
        ipad[i] = block[i] ^ 0x36;
        opad[i] = block[i] ^ 0x5C;
    }

    // inner = H(ipad || msg); result = H(opad || inner).
    let mut inner_input = Vec::with_capacity(SHA1_BLOCK + msg.len());
    inner_input.extend_from_slice(&ipad);
    inner_input.extend_from_slice(msg);
    let inner = sha1(&inner_input);

    let mut outer = Vec::with_capacity(SHA1_BLOCK + 20);
    outer.extend_from_slice(&opad);
    outer.extend_from_slice(&inner);
    sha1(&outer)
}

/// RFC 4226 section 5.4 dynamic truncation + mod 10^digits, zero-padded. Twin
/// of the truncation tail of Generate() in the Go twin: mask the top bit of the
/// 4-byte big-endian window (equiv. to Go's &0x7f on byte[offset] << 24).
fn truncate(digest: &[u8], digits: u32) -> String {
    let offset = (digest[digest.len() - 1] & 0x0F) as usize;
    let bin = (((digest[offset] & 0x7F) as u32) << 24)
        | ((digest[offset + 1] as u32) << 16)
        | ((digest[offset + 2] as u32) << 8)
        | (digest[offset + 3] as u32);
    let modulus = 10u64.pow(digits);
    format!("{:0>1$}", (bin as u64) % modulus, digits as usize)
}

/// RFC 4226 HOTP for `opts.secret` at the given 8-byte counter. Shared core:
/// generate() builds the counter from the timestamp then calls this. Returns
/// None on a bad secret or an unsupported (non-SHA1) algorithm. It is the Rust
/// twin of the HOTP step inside Generate() in the Go twin.
pub fn hotp(opts: &Options, counter: u64) -> Option<String> {
    let (digits, _period, algo) = with_defaults(opts);
    if algo != "SHA1" {
        return None; // this dependency-free port hand-rolls SHA1 only (see docs)
    }
    let key = decode_secret(&opts.secret)?;
    let msg = counter.to_be_bytes(); // 8-byte big-endian — RFC 4226 section 5.2
    let digest = hmac_sha1(&key, &msg);
    Some(truncate(&digest, digits))
}

/// TOTP (RFC 6238) for `opts.secret` at `timestamp_ms` (milliseconds since the
/// Unix epoch). The Rust twin of generateTotp() in src/lib/otp.ts / Generate()
/// in the Go — defaults SHA1, 6 digits, 30-second period. Returns None on a bad
/// secret/algorithm.
pub fn generate(opts: &Options, timestamp_ms: u64) -> Option<String> {
    let (_digits, period, _algo) = with_defaults(opts);
    let counter = timestamp_ms / 1000 / period; // RFC 6238 section 4.2
    hotp(opts, counter)
}

/// Check `token` against `opts.secret` at `timestamp_ms`, accepting the current
/// period and +/-1 adjacent periods (otpauth window=1). Mirrors validateTotp()
/// in the TS / Validate() in the Go twin. saturating_sub avoids underflow for a
/// non-physical near-zero timestamp (the Go twin casts the same value to uint64).
pub fn validate(token: &str, opts: &Options, timestamp_ms: u64) -> bool {
    let (_digits, period, _algo) = with_defaults(opts);
    let period_ms = period * 1000;
    for ts in [
        timestamp_ms,
        timestamp_ms.saturating_sub(period_ms),
        timestamp_ms.saturating_add(period_ms),
    ] {
        if let Some(got) = hotp(opts, ts / 1000 / period) {
            if token == got {
                // Constant-time compare would need a crate; == is fine for a
                // display port. (Go's Validate uses hmac.Equal.)
                return true;
            }
        }
    }
    false
}

// ---------- tests (showcase-only; the canonical suite lives in src/lib) ----------
#[cfg(test)]
mod tests {
    use super::*;

    // RFC = base32 of ASCII "12345678901234567890" — the RFC 6238/4226 key.
    const RFC: &str = "GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ";

    fn opts(secret: &str) -> Options {
        Options { secret: secret.to_string(), ..Default::default() }
    }

    // Showcase vectors — shared with the TS/Go/PHP/Python/JS twins so every
    // implementation is held to one contract.

    #[test]
    fn hotp_rfc4226() {
        // RFC 4226 Appendix D, counter 0/1 (6-digit, SHA1).
        assert_eq!(hotp(&opts(RFC), 0), Some("755224".to_string()));
        assert_eq!(hotp(&opts(RFC), 1), Some("287082".to_string()));
    }

    #[test]
    fn totp_rfc6238() {
        // RFC 6238 Appendix B, T=59s (8-digit, SHA1). counter = 59/30 = 1.
        let o8 = Options { secret: RFC.to_string(), digits: 8, ..Default::default() };
        assert_eq!(generate(&o8, 59_000), Some("94287082".to_string()));
    }

    #[test]
    fn go_twin_lock_step() {
        // Matches the Go twin / otpauth at the Go test's (secret, timestamp).
        assert_eq!(generate(&opts("JBSWY3DPEHPK3PXP"), 1_700_000_000_000), Some("324550".to_string()));
    }

    #[test]
    fn tolerates_spaces_and_lowercase() {
        assert_eq!(
            generate(&opts("jbsw y3dp ehpk 3pxp"), 1_700_000_000_000),
            Some("324550".to_string())
        );
    }

    #[test]
    fn invalid_secret_returns_none() {
        assert_eq!(generate(&opts("!!!not-base32!!!"), 0), None);
    }

    #[test]
    fn validate_round_trip() {
        assert!(validate("324550", &opts("JBSWY3DPEHPK3PXP"), 1_700_000_000_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 →