Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Project: TOTP Authenticator

Prerequisites: Lesson 1 (Hashing), Lesson 5 (HMAC). This project uses HMAC-SHA1 to generate time-based codes.

What is TOTP?

TOTP is the 6-digit code on your phone that changes every 30 seconds. It’s the most common form of two-factor authentication (2FA):

┌──────────────────────────────────────────────────────┐
│  Login flow with TOTP                                │
│                                                      │
│  1. You enter username + password                    │
│  2. Server says: "Enter your 2FA code"               │
│  3. You open Google Authenticator                    │
│  4. It shows: 847 293  (changes in 14 seconds)       │
│  5. You type 847293                                  │
│  6. Server verifies → access granted                 │
│                                                      │
│  If attacker steals your password:                   │
│    They still can't log in without the code.         │
│    The code changes every 30 seconds.                │
│    It's derived from a secret only you and the       │
│    server share.                                     │
└──────────────────────────────────────────────────────┘

How TOTP works

The algorithm is simple — just 5 steps:

Shared secret (base32)          Current time
"JBSWY3DPEHPK3PXP"             1714000000 (Unix timestamp)
        │                              │
        ▼                              ▼
   Decode base32                floor(time / 30)
   → raw bytes                  → 57133333 (time step)
        │                              │
        └──────────┬───────────────────┘
                   │
                   ▼
        HMAC-SHA1(secret, time_step as u64 big-endian)
                   │
                   ▼
           20-byte HMAC output
                   │
                   ▼
        Dynamic truncation (extract 4 bytes)
                   │
                   ▼
            31-bit integer
                   │
                   ▼
          integer mod 1,000,000
                   │
                   ▼
           6-digit code: 847293

Step 1: The shared secret

When you scan a QR code to set up 2FA, you’re receiving a shared secret — typically 20 bytes encoded in base32:

Base32: JBSWY3DPEHPK3PXP
Bytes:  [0x48, 0x65, 0x6c, 0x6c, 0x6f, 0x21, 0xde, 0xad, 0xbe, 0xef]

Both your phone and the server store this secret. It never travels over the network again.

Step 2: Time step

Divide the current Unix timestamp by 30 (the time window):

T = floor(unix_timestamp / 30)

Example:
  timestamp = 1714000000
  T = floor(1714000000 / 30) = 57133333

This means the code changes every 30 seconds. Everyone in the same 30-second window computes the same T.

Step 3: HMAC-SHA1

Compute HMAC-SHA1(secret, T) where T is an 8-byte big-endian integer:

T = 57133333
T as u64 big-endian = [0x00, 0x00, 0x00, 0x00, 0x03, 0x68, 0xA1, 0x55]

HMAC-SHA1(secret, T_bytes) → 20 bytes

Step 4: Dynamic truncation

Take the last nibble (4 bits) of the HMAC as an offset, then extract 4 bytes starting at that offset:

#![allow(unused)]
fn main() {
let offset = (hmac_result[19] & 0x0F) as usize;
let code = u32::from_be_bytes([
    hmac_result[offset] & 0x7F,  // mask high bit (ensure positive)
    hmac_result[offset + 1],
    hmac_result[offset + 2],
    hmac_result[offset + 3],
]);
}

Step 5: Modulo

code = truncated_value % 1_000_000
// Gives a 6-digit number (zero-padded)
// Example: 847293

That’s it. The entire TOTP algorithm is about 15 lines of code.

Try it before coding

# Install oathtool (TOTP reference implementation):
# macOS:
brew install oath-toolkit
# Linux:
sudo apt install oathtool

# Generate a TOTP code:
oathtool --totp -b "JBSWY3DPEHPK3PXP"
# Output: a 6-digit code (changes every 30 seconds)

# Wait 30 seconds, run again — different code:
sleep 30 && oathtool --totp -b "JBSWY3DPEHPK3PXP"

# Show the code for a specific time (for testing):
oathtool --totp -b "JBSWY3DPEHPK3PXP" --now "2024-04-25 12:00:00 UTC"
# Same thing in Python:
pip3 install pyotp
python3 -c "
import pyotp, time
totp = pyotp.TOTP('JBSWY3DPEHPK3PXP')
code = totp.now()
remaining = 30 - (int(time.time()) % 30)
print(f'Code: {code} (expires in {remaining}s)')
print(f'Valid? {totp.verify(code)}')
"
# See the current time step:
python3 -c "
import time
now = int(time.time())
step = now // 30
remaining = 30 - (now % 30)
print(f'Unix time:  {now}')
print(f'Time step:  {step}')
print(f'Next code in: {remaining}s')
"

The QR code (otpauth:// URI)

When a website shows a QR code for 2FA setup, it encodes a URI:

otpauth://totp/MyService:alice@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyService&digits=6&period=30
otpauth://totp/              ← type (TOTP vs HOTP)
MyService:alice@example.com  ← label shown in the app
?secret=JBSWY3DPEHPK3PXP   ← the shared secret (base32, often unpadded)
&issuer=MyService            ← company name
&digits=6                    ← code length (6 or 8)
&period=30                   ← time step in seconds
&algorithm=SHA1              ← hash algorithm

Most parameters are optional. A real URI often looks like:

otpauth://totp/ISSUER%3Ausername?secret=MSITKRCX7CVPGFFKHMSSNYL7YB&issuer=ISSUER

Only secret is required. Missing parameters use defaults:

Parameter   Default   Notes
───────────────────────────────────
secret      (required) base32, often without '=' padding
algorithm   SHA1       SHA1, SHA256, SHA512
digits      6          6 or 8
period      30         seconds
issuer      (optional) display name in the app

Your parser must handle missing fields with defaults:

#![allow(unused)]
fn main() {
let algorithm = params.get("algorithm").unwrap_or(&"SHA1".to_string());
let digits: u32 = params.get("digits").map(|d| d.parse().unwrap()).unwrap_or(6);
let period: u64 = params.get("period").map(|p| p.parse().unwrap()).unwrap_or(30);
}
# Generate a QR code from the URI:
# macOS: brew install qrencode
# Linux: sudo apt install qrencode

qrencode -o totp-qr.png \
  "otpauth://totp/MyApp:user@example.com?secret=JBSWY3DPEHPK3PXP&issuer=MyApp"
open totp-qr.png  # macOS
# Scan with Google Authenticator — it will start showing codes!

The RFC (6238)

TOTP is defined in RFC 6238:

Parameter    Default    Description
─────────────────────────────────────────
Algorithm    SHA-1      Hash for HMAC (SHA-1, SHA-256, SHA-512)
Digits       6          Length of code (6 or 8)
Period       30         Seconds per time step
T0           0          Unix epoch start

Most services use SHA-1 + 6 digits + 30 seconds.

Implementation guide

We’ll build this step by step. At each step, you can compile and test before moving on.

Step 0: Project setup

Create the binary and add dependencies:

# If adding to the tls crate, create the file:
touch tls/src/bin/p1-totp.rs

Add to tls/Cargo.toml:

[dependencies]
hmac = "0.12"
sha1 = "0.10"
data-encoding = "2"  # for base32 decoding
clap = { version = "4", features = ["derive"] }

Start with a skeleton:

use clap::{Parser, Subcommand};

#[derive(Parser)]
struct Cli {
    #[command(subcommand)]
    command: Command,
}

#[derive(Subcommand)]
enum Command {
    /// Generate a TOTP code
    Generate { secret: String },
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Generate { secret } => {
            println!("TODO: generate TOTP for {}", secret);
        }
    }
}
cargo run -p tls --bin p1-totp -- generate JBSWY3DPEHPK3PXP
# Should print the TODO message

Step 1: Decode the base32 secret

The shared secret comes as a base32 string. Decode it to raw bytes:

#![allow(unused)]
fn main() {
fn decode_secret(secret_base32: &str) -> Vec<u8> {
    // Most TOTP URIs strip the base32 padding ('=' signs).
    // Use BASE32_NOPAD to handle both padded and unpadded secrets.
    data_encoding::BASE32_NOPAD
        .decode(secret_base32.as_bytes())
        .expect("invalid base32 secret")
}
}

Why BASE32_NOPAD? Base32 normally requires padding to a multiple of 8 characters (e.g., JBSWY3DPEHPK3PXP=======). But most TOTP services strip the = padding from the otpauth:// URI. If you use strict BASE32, secrets like MSITKRCX7CVPGFFKHMSSNYL7YB (26 chars, not a multiple of 8) will fail to decode.

Secret from URI:  MSITKRCX7CVPGFFKHMSSNYL7YB        ← 26 chars, no padding
BASE32 expects:   MSITKRCX7CVPGFFKHMSSNYL7YB======  ← padded to 32
BASE32_NOPAD:     accepts both — use this one

Test it:

fn main() {
    // Padded secret (some services include padding):
    let secret = decode_secret("JBSWY3DPEHPK3PXP");
    println!("Secret bytes: {:?}", secret);
    println!("Length: {} bytes", secret.len());

    // Unpadded secret (most real URIs look like this):
    let secret2 = decode_secret("MSITKRCX7CVPGFFKHMSSNYL7YB");
    println!("Secret2 bytes: {:?}", secret2);
    println!("Length: {} bytes", secret2.len());
}
# Verify with Python (Python's b32decode also needs padding):
python3 -c "
import base64
# Python needs padding, so we add it:
secret = 'MSITKRCX7CVPGFFKHMSSNYL7YB'
padded = secret + '=' * (-len(secret) % 8)
print(list(base64.b32decode(padded)))
"

Step 2: Compute the time step

Get the current Unix timestamp and divide by 30:

#![allow(unused)]
fn main() {
fn current_time_step() -> u64 {
    std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs() / 30
}
}

Test it:

fn main() {
    let step = current_time_step();
    let remaining = 30 - (std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap()
        .as_secs() % 30);
    println!("Time step: {}", step);
    println!("Next code in: {}s", remaining);
}
# Compare with Python:
python3 -c "import time; print(int(time.time()) // 30)"
# Should match your Rust output

Step 3: HMAC-SHA1

Why HMAC and not encryption?

You might wonder: why not just encrypt(key, time_step) and use the ciphertext as the code?

Option A: Encryption
  encrypt(secret, time_step) → ciphertext
  Problem: ciphertext is REVERSIBLE. If an attacker gets the ciphertext
  AND the time_step (which is just the current time — public!), they
  can try to recover the secret key through chosen-plaintext attacks.

Option B: HMAC (what TOTP uses)
  HMAC(secret, time_step) → tag
  The tag is ONE-WAY. Even if you know the input (time_step) and the
  output (tag), you CANNOT recover the secret key.
  The attacker sees: time_step=57133333, code=847293
  They still can't compute the secret.

The key differences:

                Encryption              HMAC
────────────────────────────────────────────────
Reversible?     Yes (decrypt)           No (one-way)
Output size     Same as input           Fixed (20 bytes for SHA-1)
Goal            Hide data               Prove knowledge of secret
TOTP needs      Prove you have the key  ✓ (this is what we want)
                without revealing it

TOTP doesn’t need to hide the time step (it’s just the current time — everyone knows it). It needs to prove “I know the secret” by producing a value that only someone with the secret could compute. That’s exactly what HMAC does.

Also practical: HMAC output is always 20 bytes regardless of input size, which makes the truncation step simple. Encryption output would vary in size and need padding.

Now, compute the HMAC using the secret and time step (as big-endian u64):

#![allow(unused)]
fn main() {
use hmac::{Hmac, Mac};
use sha1::Sha1;

type HmacSha1 = Hmac<Sha1>;

fn hmac_sha1(secret: &[u8], time_step: u64) -> [u8; 20] {
    let mut mac = HmacSha1::new_from_slice(secret)
        .expect("HMAC accepts any key length");
    mac.update(&time_step.to_be_bytes());  // 8 bytes, big-endian
    let result = mac.finalize().into_bytes();

    let mut output = [0u8; 20];
    output.copy_from_slice(&result);
    output
}
}

Test it:

fn main() {
    let secret = decode_secret("JBSWY3DPEHPK3PXP");
    let step = current_time_step();
    let hmac = hmac_sha1(&secret, step);
    println!("HMAC: {}", hex::encode(hmac));  // add `hex` dep, or use {:02x} formatting
    println!("Length: {} bytes (always 20 for SHA-1)", hmac.len());
}

Why big-endian? The RFC specifies it. If you use little-endian, your codes won’t match any other TOTP implementation.

Step 4: Dynamic truncation

This is the clever part — extract a 4-byte chunk from the HMAC at a position determined by the last byte:

#![allow(unused)]
fn main() {
fn truncate(hmac_result: &[u8; 20]) -> u32 {
    // The last nibble (4 bits) determines the offset
    let offset = (hmac_result[19] & 0x0F) as usize;
    // offset is 0-15, and we read 4 bytes, so max index is 15+3=18 (within 20)

    // Extract 4 bytes at that offset, mask the high bit
    u32::from_be_bytes([
        hmac_result[offset] & 0x7F,  // & 0x7F clears the sign bit
        hmac_result[offset + 1],
        hmac_result[offset + 2],
        hmac_result[offset + 3],
    ])
}
}

Test it:

fn main() {
    let secret = decode_secret("JBSWY3DPEHPK3PXP");
    let step = current_time_step();
    let hmac = hmac_sha1(&secret, step);

    let offset = (hmac[19] & 0x0F) as usize;
    println!("Last byte: 0x{:02x}", hmac[19]);
    println!("Offset: {} (last nibble)", offset);

    let truncated = truncate(&hmac);
    println!("Truncated: {} (31-bit integer)", truncated);
}

Why & 0x7F? To ensure the result is positive (clear the sign bit). The RFC requires a 31-bit unsigned value.

Step 5: Modulo → 6-digit code

#![allow(unused)]
fn main() {
fn generate_totp(secret_base32: &str, time_step: u64) -> u32 {
    let secret = decode_secret(secret_base32);
    let hmac = hmac_sha1(&secret, time_step);
    let truncated = truncate(&hmac);
    truncated % 1_000_000  // 6 digits
}
}

Test it against the reference:

fn main() {
    let code = generate_totp("JBSWY3DPEHPK3PXP", current_time_step());
    println!("Code: {:06}", code);  // zero-pad to 6 digits
}
# Compare:
cargo run -p tls --bin p1-totp -- generate JBSWY3DPEHPK3PXP
oathtool --totp -b "JBSWY3DPEHPK3PXP"
# MUST be identical!

If they don’t match, check:

  1. Is the base32 decoding correct? (Step 1)
  2. Is the time step the same? (Step 2 — clocks might differ by a second across the boundary)
  3. Is the HMAC input big-endian? (Step 3)

Step 6: Wrap it in a nice CLI

fn totp_now(secret_base32: &str) -> u32 {
    generate_totp(secret_base32, current_time_step())
}

fn main() {
    let cli = Cli::parse();
    match cli.command {
        Command::Generate { secret } => {
            let code = totp_now(&secret);
            let remaining = 30 - (std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .unwrap()
                .as_secs() % 30);
            println!("{:06}  (expires in {}s)", code, remaining);
        }
    }
}

Step 7: Validation

Accept current window + previous (for clock skew):

#![allow(unused)]
fn main() {
fn verify_totp(secret: &str, code: u32) -> bool {
    let current_step = current_time_step();
    // Check current and previous window
    for step in [current_step, current_step - 1] {
        if generate_totp(secret, step) == code {
            return true;
        }
    }
    false
}
}

Add to CLI:

#![allow(unused)]
fn main() {
Command::Verify { secret, code } => {
    if verify_totp(&secret, code) {
        println!("Valid!");
    } else {
        println!("Invalid!");
    }
}
}

Test:

# Get current code:
CODE=$(oathtool --totp -b "JBSWY3DPEHPK3PXP")
echo "Code: $CODE"

# Verify immediately:
cargo run -p tls --bin p1-totp -- verify JBSWY3DPEHPK3PXP $CODE
# Valid!

# Wait 60 seconds (two windows), verify again:
sleep 60
cargo run -p tls --bin p1-totp -- verify JBSWY3DPEHPK3PXP $CODE
# Invalid! (code has expired beyond the ±1 window)

You now have a working TOTP authenticator. The exercises below extend it further.

Exercises

Exercise 1: Generate and verify

Implement generate_totp and totp_now. Verify your output matches oathtool:

# Your program:
cargo run -p tls --bin p1-totp -- generate JBSWY3DPEHPK3PXP

# Reference:
oathtool --totp -b "JBSWY3DPEHPK3PXP"

# Both should show the same 6-digit code.

Exercise 2: Live display with countdown

Build a CLI that shows the current code with a countdown timer:

$ cargo run -p tls --bin p1-totp -- watch JBSWY3DPEHPK3PXP

  Code: 847293  [████████░░░░░░] 14s remaining

Hint: use \r (carriage return) to overwrite the line. print!("\r Code: {:06} [{}>{}] {}s remaining", code, "█".repeat(filled), "░".repeat(30-filled), remaining).

Exercise 3: Validate a code

Implement verify_totp with a ±1 window. Test:

cargo run -p tls --bin p1-totp -- verify JBSWY3DPEHPK3PXP 847293
# Valid! (if the code is current)

cargo run -p tls --bin p1-totp -- verify JBSWY3DPEHPK3PXP 000000
# Invalid!

Exercise 4: SHA-256 and SHA-512 variants

Extend to support different hash algorithms:

cargo run -p tls --bin p1-totp -- generate --algo sha256 JBSWY3DPEHPK3PXP

Compare: oathtool --totp=sha256 -b "JBSWY3DPEHPK3PXP"

Exercise 5: Generate QR code

Generate an otpauth:// URI and render as a QR code in the terminal (using the qrcode crate):

cargo run -p tls --bin p1-totp -- setup --issuer MyApp --account alice@example.com
# Displays QR code in terminal
# Scan with Google Authenticator
# Verify: codes from your CLI match codes in the app

This is the full setup flow — you’ve built a Google Authenticator clone.

Exercise 6: Import from QR code image

Build a command that reads a QR code image (PNG/JPG), extracts the otpauth:// URI, parses the secret and parameters, and immediately generates a code:

# Someone sends you a QR code screenshot:
cargo run -p tls --bin p1-totp -- import qr-screenshot.png
# Parsed: otpauth://totp/GitHub:alice?secret=JBSWY3DPEHPK3PXP&issuer=GitHub&algorithm=SHA1
# Account: GitHub:alice
# Algorithm: SHA1
# Current code: 847293 (expires in 18s)

Use the image crate to load the image and rqrr to decode the QR code:

[dependencies]
image = "0.25"
rqrr = "0.8"
#![allow(unused)]
fn main() {
use image::GenericImageView;

fn decode_qr(path: &str) -> String {
    let img = image::open(path).unwrap().to_luma8();
    let mut prepared = rqrr::PreparedImage::prepare(img);
    let grids = prepared.detect_grids();
    let (_meta, content) = grids[0].decode().unwrap();
    content  // "otpauth://totp/...?secret=...&algorithm=..."
}

fn parse_otpauth_uri(uri: &str) -> (String, String, String) {
    // Parse: otpauth://totp/Label?secret=XXX&algorithm=SHA1&digits=6&period=30
    // Return: (label, secret, algorithm)
    todo!()
}
}

This is how password managers import TOTP — they scan the QR screenshot and extract the secret.

Test it: take a screenshot of a QR code from any 2FA setup page (or generate one with Exercise 5), save as PNG, and import it.

Note on SHA-1 in TOTP

You might wonder: isn’t SHA-1 broken (Lesson 1)? SHA-1 has known collision attacks — you can find two inputs with the same hash. But HMAC-SHA1 is still secure because HMAC doesn’t rely on collision resistance. It relies on the hash being a pseudorandom function, which SHA-1 still is.

That said, new deployments should prefer SHA-256. The otpauth:// URI supports algorithm=SHA256 and algorithm=SHA512. Most services still default to SHA-1 for compatibility with older authenticator apps.