Open source

Read the code that locks your vault

This is the exact encryption core that runs on your device: the same file, compiled to WebAssembly. Pick a file to see what it does and where it sits in the flow.

totp.rs — potent-cipher-core
totp.rs

crates › core › src › totp.rs

//! One-time codes for two-factor sign-in, for the 2FA field of "login" items: TOTP (RFC 6238) over HOTP (RFC 4226), with
//! the RustCrypto HMAC. The secret is the site's shared key in base32 (RFC 4648), as sites show it or put it in their
//! `otpauth://` QR codes. Codes are made on the device; the secret is stored only inside the encrypted item.

use hmac::{Hmac, Mac};
use zeroize::Zeroizing;

use crate::error::{Error, Result};

/// The hash a site's TOTP uses (almost always SHA-1).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TotpAlgorithm {
    /// HMAC-SHA-1, the default.
    Sha1,
    /// HMAC-SHA-256.
    Sha256,
    /// HMAC-SHA-512.
    Sha512,
}

/// Decodes a base32 secret (RFC 4648 alphabet; spaces, dashes, padding and lower case are allowed, as sites write them).
pub fn totp_secret_from_base32(text: &str) -> Result<Zeroizing<Vec<u8>>> {
    let mut out = Zeroizing::new(Vec::with_capacity(text.len() * 5 / 8));
    let mut buffer: u32 = 0;
    let mut bits = 0u32;
    for c in text.chars() {
        let value = match c.to_ascii_uppercase() {
            ' ' | '-' | '=' => continue,
            c @ 'A'..='Z' => c as u32 - 'A' as u32,
            c @ '2'..='7' => c as u32 - '2' as u32 + 26,
            _ => return Err(Error::Format("totp secret")),
        };
        buffer = (buffer << 5) | value;
        bits += 5;
        if bits >= 8 {
            bits -= 8;
            out.push((buffer >> bits) as u8);
            buffer &= (1 << bits) - 1;
        }
    }
    if out.len() < 10 {
        return Err(Error::Format("totp secret too short"));
    }
    Ok(out)
}

fn hotp<M: Mac + hmac::digest::KeyInit>(secret: &[u8], counter: u64) -> Result<u32> {
    let mut mac = <M as hmac::digest::KeyInit>::new_from_slice(secret).map_err(|_| Error::InvalidKey)?;
    mac.update(&counter.to_be_bytes());
    let digest = mac.finalize().into_bytes();
    // Dynamic truncation (RFC 4226 section 5.3).
    let offset = (digest[digest.len() - 1] & 0x0f) as usize;
    Ok(u32::from_be_bytes([digest[offset] & 0x7f, digest[offset + 1], digest[offset + 2], digest[offset + 3]]))
}

/// The code for `unix_seconds`: `digits` long (6 to 8), a new one every `period` seconds (15 to 120).
pub fn totp_code(secret: &[u8], unix_seconds: u64, period: u32, digits: u32, algorithm: TotpAlgorithm) -> Result<String> {
    if !(6..=8).contains(&digits) || !(15..=120).contains(&period) {
        return Err(Error::Format("totp settings"));
    }
    let counter = unix_seconds / u64::from(period);
    let value = match algorithm {
        TotpAlgorithm::Sha1 => hotp::<Hmac<sha1::Sha1>>(secret, counter)?,
        TotpAlgorithm::Sha256 => hotp::<Hmac<sha2::Sha256>>(secret, counter)?,
        TotpAlgorithm::Sha512 => hotp::<Hmac<sha2::Sha512>>(secret, counter)?,
    };
    let code = value % 10u32.pow(digits);
    Ok(format!("{code:0width$}", width = digits as usize))
}

#[cfg(test)]
mod tests {
    use super::*;

    // RFC 6238 appendix B: the ASCII seeds, 8 digits, 30-second steps.
    const SEED1: &[u8] = b"12345678901234567890";
    const SEED256: &[u8] = b"12345678901234567890123456789012";
    const SEED512: &[u8] = b"1234567890123456789012345678901234567890123456789012345678901234";

    #[test]
    fn matches_the_rfc_6238_vectors() {
        let cases: &[(u64, &str, &str, &str)] = &[
            (59, "94287082", "46119246", "90693936"),
            (1111111109, "07081804", "68084774", "25091201"),
            (1111111111, "14050471", "67062674", "99943326"),
            (1234567890, "89005924", "91819424", "93441116"),
            (2000000000, "69279037", "90698825", "38618901"),
            (20000000000, "65353130", "77737706", "47863826"),
        ];
        for (time, sha1, sha256, sha512) in cases {
            assert_eq!(totp_code(SEED1, *time, 30, 8, TotpAlgorithm::Sha1).unwrap(), *sha1);
            assert_eq!(totp_code(SEED256, *time, 30, 8, TotpAlgorithm::Sha256).unwrap(), *sha256);
            assert_eq!(totp_code(SEED512, *time, 30, 8, TotpAlgorithm::Sha512).unwrap(), *sha512);
        }
    }

    #[test]
    fn six_digits_keep_leading_zeros() {
        // RFC 4226 appendix D, count 0 = 755224 (time 0 with a 30 s step is counter 0).
        assert_eq!(totp_code(SEED1, 0, 30, 6, TotpAlgorithm::Sha1).unwrap(), "755224");
        assert_eq!(totp_code(SEED1, 1111111109, 30, 6, TotpAlgorithm::Sha1).unwrap(), "081804");
    }

    #[test]
    fn reads_base32_as_sites_write_it() {
        // "12345678901234567890" in base32.
        let secret = totp_secret_from_base32("gezd gnbv-gy3t qojq gezd gnbv gy3t qojq").unwrap();
        assert_eq!(secret.as_slice(), SEED1);
        assert!(totp_secret_from_base32("GEZDGNBVGY3TQOJQ====").is_ok());
        assert!(totp_secret_from_base32("not base32!").is_err());
        assert!(totp_secret_from_base32("GEZD").is_err());
    }

    #[test]
    fn refuses_odd_settings() {
        assert!(totp_code(SEED1, 0, 30, 4, TotpAlgorithm::Sha1).is_err());
        assert!(totp_code(SEED1, 0, 5, 6, TotpAlgorithm::Sha1).is_err());
    }
}
Last updated Nov 10, 2026, 3:36 PM UTCcommit f8df724120 linesrust

Try it with the real code

Type something and a passphrase. Your browser runs the core above and shows the .pcipher bytes it made. Nothing is sent anywhere.

Header (73 bytes, readable)

Magic · 7··
Version · 1··
Mode · 1··
KDF · 1··
Argon2id params · 12··
Salt · 16··
Key id · 8··
Chunk size · 4··
Metadata length · 4··
Nonce prefix · 19··

Encrypted body (looks random)

··