TOTP/HOTP Authentication

Time-based and HMAC-based One-Time Password authentication

Radiator Server supports TOTP (Time-based One-Time Password) and HOTP (HMAC-based One-Time Password) authentication methods, commonly used for two-factor authentication (2FA) and multi-factor authentication (MFA) scenarios.

Overview

  • TOTP (RFC 6238): Generates time-based one-time passwords that change every N seconds (typically 30 seconds)
  • HOTP (RFC 4226): Generates counter-based one-time passwords that change after each successful authentication

Both methods use HMAC-SHA1 with a shared secret to generate numeric codes of configurable length (typically 6-8 digits).

Always use TOTP/HOTP as part of a multi-part authentication scheme. Alone it is not sufficient security due to the amount of digits passed. Usually TOTP is preferred over HOTP if system clocks can be kept in synchronization.

Secret Encoding

Important: TOTP/HOTP secrets MUST be provided as either:

  • Base32-encoded strings (RFC 4648) - standard format used by authenticator apps
  • Hex-encoded strings with 0x prefix - convenient for binary secrets

The base32 encoding is standard for TOTP/HOTP implementations and is compatible with common authenticator apps like Google Authenticator, Microsoft Authenticator, and Authy.

Secret Format Detection

By default, the server uses hexadecimal format for secret decoding (with automatic 0x prefix stripping if present). The radiator-client tool uses auto-detection (secrets with 0x prefix are hex, otherwise base32).

You can configure the secret format in the totp or hotp action block:

authentication {
    backend {
        name "USERS";
        query "LOAD_USER";
    }

    # Configure secret format in the action
    totp {
        secret_type "base32";  # or "hex" or "auto"
        resync_window 1;
    }
}

Valid secret type values:

  • "hex" - Hexadecimal format (default, strips 0x prefix if present)
  • "auto" - Auto-detect: 0x prefix -> hex, otherwise base32
  • "base32" - Base32 format (RFC 4648)

Note: Secrets are stored as raw strings during backend mapping and decoded lazily during authentication using the configured secret_type.

Example Secret Formats

Hex (160-bit, preferred):     0x3132333435363738393031323334353637383930
Hex (160-bit, no prefix):     3132333435363738393031323334353637383930
Base32 (80-bit):              JBSWY3DPEHPK3PXP
Base32 (160-bit):             GEZDGNBVGY3TQOJQGEZDGNBVGY3TQOJQ
Invalid:                      mysecretpassword (plain text not supported)

Note: The hex and base32 examples above represent the same binary value ("12345678901234567890" in ASCII).

With the default hex format:

  • Secrets with 0x or 0X prefix: prefix is stripped before decoding
  • Secrets without prefix: decoded as hex directly
  • To use base32, configure secret_type "base32" in the totp/hotp action block

Base32 uses characters: A-Z and 2-7 (case-insensitive, no padding). Hex uses: 0-9 and a-f/A-F (case-insensitive).

radiator-client Secret Format

The radiator-client tool uses auto-detection for all secret inputs:

  • Secrets with 0x prefix are decoded as hexadecimal
  • All other secrets are decoded as base32

This matches the server's default behavior, ensuring compatibility.

Secret Length Recommendations

RFC 4226 recommends 160-bit (20-byte) secrets, which encode to 32 base32 characters or 40 hex digits:

  • 160 bits (32 chars base32, 40 hex): RFC recommended, maximum security for SHA-1 HMAC
  • 128 bits (26 chars base32, 32 hex): Strong security, modern standard
  • 80 bits (16 chars base32, 20 hex): Google Authenticator default, minimum recommended

The radiator-client --totp-generate-only and --hotp-generate-only commands generate 160-bit secrets following the RFC recommendation.

Configuration

TOTP Configuration

Basic TOTP authentication validates the entire password field as a TOTP code:

authentication {
    backend {
        name "USERS";
        query "LOAD_USER";
    }

    # Validate TOTP in password field
    totp {
        # secret_type "hex"; # Default
        # timestep 30;  # Default
        # resync_window 1 0;  # Default; Check 1 timestep to the past and none to the future
    }
}

Backend mapping for TOTP:

mapping {
    hmac-otp.secret = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].totp_secret");
    hmac-otp.digits = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].totp_digits");
    hmac-otp.timestep = doc | jsonpath("$.users[?(@.username == '%{aaa.identity}')].totp_timestep");
}

Configuration parameters:

  • hmac-otp.secret: Shared secret in hex (default) or base32 format (required)
  • hmac-otp.digits: Number of digits in the OTP code (default: 6, typically 6-8)
  • hmac-otp.timestep: Time window in seconds (default: 30)
  • hmac-otp.timestep.origin: Unix timestamp origin (default: 0, rarely needs to be changed)

Action configuration (in totp block):

  • secret_type: Secret format - "hex" (default), "base32", or "auto"
  • resync_window: Timestep tolerance for clock drift
    • Single value: resync_window 1; - checks 1 timestep before and 1 after current (symmetric window)
    • Two values: resync_window 2 1; - checks 2 timesteps before and 1 after current (asymmetric window)
    • With default 30-second timestep: resync_window 1 allows +/-30 seconds of drift
    • Default: 1 0 (check 1 timestep before, 0 after current time - with default 30s timestep this allows codes from 30s in the past)

HOTP Configuration

Basic HOTP authentication:

authentication {
    backend {
        name "USERS";
        query "LOAD_USER";
    }

    # Validate HOTP in password field
    hotp {
        # secret_type "hex";  # Default: hex format
        # resync_window 10;  # Default: check current counter + 10 ahead
    }
}

Backend mapping for HOTP:

mapping {
    hmac-otp.secret = hotp_secret;
    hmac-otp.counter = hotp_counter;
    hmac-otp.digits = hotp_digits;
}

Configuration parameters:

  • hmac-otp.secret: Shared secret in hex (default) or base32 format (required)
  • hmac-otp.counter: Current counter value (required for HOTP)
  • hmac-otp.digits: Number of digits in the OTP code (default: 6)

Action configuration (in hotp block):

  • secret_type: Secret format - "hex" (default), "base32", or "auto"
  • resync_window: Counter lookahead window (forward-only checking)
    • Single value: resync_window 10; - checks current counter + 10 ahead. 10 is also the defaul value.

Important: After successful HOTP authentication, the counter must be persisted so that users can continue logging in and so that replay attacks are mitigated.

Counter Persistence Example

authentication {
    backend {
        name "HOTP_USERS";
        query "FIND_USER";
    }

    hotp {
        resync_window 10;
    }
}

post-authentication {
    # Update counter in database after successful authentication
    backend {
        name "HOTP_USERS";
        query "UPDATE_HOTP_COUNTER";
    }
}

SQLite backend query example:

query "UPDATE_HOTP_COUNTER" {
    statement "UPDATE users SET hotp_counter = ? WHERE username = ?";
    bindings {
        hmac-otp.counter;  # Updated counter value
        user.username;
    }
}

Two-Factor Authentication (2FA)

The recommended 2FA approach combines password hashing with TOTP and implements replay protection by tracking timesteps. This prevents password database compromise and TOTP replay attacks. See the Password Hashing article for details on secure password storage using Argon2 and other algorithms.

backends {
    sqlite "TOTP_USERS" {
        filename "users.sqlite";

        # Read user data including password, TOTP configuration and last used timestep
        query "FIND_USER" {
            statement "SELECT username, password, totp_secret, totp_digits, totp_timestep, totp_last_timestep FROM users WHERE username = ?";
            bindings {
                aaa.identity;
            }
            mapping {
                user.username = username;
                user.password = password;
                hmac-otp.secret = totp_secret;
                hmac-otp.digits = totp_digits;
                hmac-otp.timestep = totp_timestep;
                hmac-otp.timestep.last = totp_last_timestep;
            }
        }

        # Store the timestep of the successful authentication for replay protection
        query "UPDATE_TOTP_TIMESTEP" {
            statement "UPDATE users SET totp_last_timestep = ? WHERE username = ?";
            bindings {
                hmac-otp.timestep.last;
                user.username;
            }
        }
    }
}

aaa {
    policy "DEFAULT" {
        handler "AUTHENTICATION" {
            authentication {
                backend {
                    name "TOTP_USERS";
                    query "FIND_USER";
                }

                # Validate password (all but last 8 characters)
                pap {
                    range -8 0 exclusive;
                }

                # Validate TOTP (last 8 characters)
                totp {
                    range -8 0;
                    secret_type "hex";
                    resync_window 1;
                }
            }

            post-authentication {
                # Update the last used timestep to prevent replay attacks
                backend {
                    name "TOTP_USERS";
                    query "UPDATE_TOTP_TIMESTEP";
                }
            }
        }
    }
}

SQLite schema with password hashing and replay protection:

CREATE TABLE IF NOT EXISTS users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL,
    totp_secret TEXT NOT NULL,
    totp_digits INTEGER DEFAULT 8,
    totp_timestep INTEGER DEFAULT 30,
    totp_last_timestep INTEGER DEFAULT NULL
);

-- Password uses argon2 hash (see Password Hashing article), TOTP secret in hex format
-- totp_last_timestep can be NULL initially; replay protection activates after first successful auth
-- totp_origin defaults to 0 (Unix epoch) in server, no need to store it
INSERT INTO users (username, password, totp_secret, totp_digits, totp_timestep)
VALUES ('alice', '{argon2}$argon2id$v=19$m=16384,t=2,p=1$YzZweHZaQmp5WWlMZjhaS3AzcGJBQT09$TtLQfZS6umGIXA5JeIoEEkJgnJ5JcLbBEOi0hYcmTho', '3132333435363738393031323334353637383930', 8, 30);

Testing with radiator-client:

# Generate TOTP code and combine with password
TOTP_CODE=$(radiator-client --totp-secret "0x3132333435363738393031323334353637383930" --totp-digits 8 --totp-generate-only)

# Authenticate with password + TOTP
radiator-client \
    --server 127.0.0.1 \
    --port 1812 \
    --secret mysecret \
    --user alice \
    --password "AliceSecure789${TOTP_CODE}" \
    --type auth

# Immediate replay will be rejected with "Old TOTP replayed" error
radiator-client \
    --server 127.0.0.1 \
    --port 1812 \
    --secret mysecret \
    --user alice \
    --password "AliceSecure789${TOTP_CODE}" \
    --type auth

Key Security Features:

  1. Password Hashing: Passwords stored with Argon2 or other secure algorithms (see Password Hashing)
  2. Replay Protection: Tracking totp_last_timestep prevents reuse of codes from the same or earlier timesteps
  3. Time-Sync Tolerance: resync_window 1 allows +/-1 timestep (with default 30s timestep = +/-30s) while maintaining security
  4. 8-Digit Codes: Increased entropy compared to standard 6-digit codes

How Replay Protection Works:

  • After successful TOTP validation at timestep T, the database stores T
  • Any subsequent authentication with a timestep <= T is rejected as "Old TOTP replayed"
  • This prevents replay attacks within the same time window and from past windows
  • Users can authenticate again with a code from a future time window

Basic Password + TOTP/HOTP

Use the range parameter to split the password field into password and OTP components:

authentication {
    backend {
        name "USERS";
        query "LOAD_USER";
    }

    # Validate password (all but last 6 characters)
    pap {
        range -6 0 exclusive;
    }

    # Validate HOTP (last 6 characters)
    hotp {
        range -6 0;
        resync_window 10;
    }
}

Range syntax:

  • range -6 0: Extract last 6 characters (inclusive)
  • range -6 0 exclusive: Extract all but last 6 characters
  • Negative indices count from the end of the string

Example with 8-digit OTP:

pap {
    range -8 0 exclusive;  # Password part
}

hotp {
    range -8 0;            # 8-digit OTP part
}

Testing with radiator-client

The radiator-client tool provides built-in support for TOTP/HOTP testing.

Generate TOTP Code

# Generate current TOTP code
radiator-client --totp-secret JBSWY3DPEHPK3PXP --totp-generate-only

# Generate with custom digits
radiator-client --totp-secret JBSWY3DPEHPK3PXP --totp-digits 8 --totp-generate-only

# Generate pseudo random secret
radiator-client --totp-generate-only

Generate HOTP Code

# Generate HOTP code for counter 0
radiator-client --hotp-secret JBSWY3DPEHPK3PXP --hotp-counter 0 --hotp-generate-only

# Generate with custom digits
radiator-client --hotp-secret JBSWY3DPEHPK3PXP --hotp-counter 5 --hotp-digits 8 --hotp-generate-only

# Generate random secret (no counter needed)
radiator-client --hotp-generate-only

Test TOTP Authentication

# TOTP only (password field contains TOTP code)
radiator-client \
    --server 127.0.0.1 \
    --port 1812 \
    --secret mysecret \
    --user alice \
    --totp-secret JBSWY3DPEHPK3PXP \
    --type auth

Test HOTP Authentication

# HOTP only
radiator-client \
    --server 127.0.0.1 \
    --port 1812 \
    --secret mysecret \
    --user testuser \
    --hotp-secret OHW56UKCF6BMTXCDJ2TKPF23AO4ZMLML \
    --hotp-counter 0 \
    --hotp-digits 6 \
    --type auth

Test 2FA (Password + TOTP/HOTP)

The radiator-client automatically concatenates password and OTP when both are provided:

# Password + TOTP
radiator-client \
    --server 127.0.0.1 \
    --port 1812 \
    --secret mysecret \
    --user alice \
    --password alicepass123 \
    --totp-secret JBSWY3DPEHPK3PXP \
    --type auth

# Password + HOTP
radiator-client \
    --server 127.0.0.1 \
    --port 1812 \
    --secret mysecret \
    --user bob \
    --password b0bSecure! \
    --hotp-secret YLH3I7GKX6PKELIFE6PWGC4FVNB5YPPC \
    --hotp-counter 0 \
    --hotp-digits 6 \
    --type auth

Common Patterns

HOTP with SQLite Backend and Counter Persistence

backends {
    sqlite "HOTP_USERS" {
        filename "users.db";

        query "FIND_USER" {
            statement "SELECT username, hotp_secret, hotp_counter, hotp_digits FROM users WHERE username = ?";
            bindings {
                aaa.identity;
            }
            mapping {
                user.username = username;
                hmac-otp.secret = hotp_secret;
                hmac-otp.counter = hotp_counter;
                hmac-otp.digits = hotp_digits;
            }
        }

        query "UPDATE_HOTP_COUNTER" {
            statement "UPDATE users SET hotp_counter = ? WHERE username = ?";
            bindings {
                hmac-otp.counter;
                user.username;
            }
        }
    }
}

aaa {
    policy "DEFAULT" {
        handler "AUTHENTICATION" {
            authentication {
                backend {
                    name "HOTP_USERS";
                    query "FIND_USER";
                }
                hotp {
                    resync_window 10;
                }
            }

            post-authentication {
                backend {
                    name "HOTP_USERS";
                    query "UPDATE_HOTP_COUNTER";
                }
            }
        }
    }
}

Example SQLite schema:

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    hotp_secret TEXT NOT NULL,
    hotp_counter INTEGER DEFAULT 0,
    hotp_digits INTEGER DEFAULT 6
);

-- Using hex secrets (default format, no secret_type configuration needed)
INSERT INTO users (username, hotp_secret, hotp_counter, hotp_digits)
VALUES ('alice', '9bb65652855a09d0ae35c876e6c38b35fa34a0cd', 0, 6);

2FA with Password + HOTP

backends {
    sqlite "USERS_2FA" {
        filename "users.db";

        query "FIND_USER" {
            statement "SELECT username, password, hotp_secret, hotp_counter, hotp_digits FROM users WHERE username = ?";
            bindings {
                aaa.identity;
            }
            mapping {
                user.username = username;
                user.password = password;
                hmac-otp.secret = hotp_secret;
                hmac-otp.counter = hotp_counter;
                hmac-otp.digits = hotp_digits;
            }
        }

        query "UPDATE_HOTP_COUNTER" {
            statement "UPDATE users SET hotp_counter = ? WHERE username = ?";
            bindings {
                hmac-otp.counter;
                user.username;
            }
        }
    }
}

aaa {
    policy "DEFAULT" {
        handler "AUTHENTICATION" {
            authentication {
                backend {
                    name "USERS_2FA";
                    query "FIND_USER";
                }

                # Validate password (all but last 6 characters)
                pap {
                    range -6 0 exclusive;
                }

                # Validate HOTP (last 6 characters)
                hotp {
                    range -6 0;
                    resync_window 10;
                }
            }

            post-authentication {
                backend {
                    name "USERS_2FA";
                    query "UPDATE_HOTP_COUNTER";
                }
            }
        }
    }
}

Example SQLite schema for 2FA:

CREATE TABLE users (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    username TEXT NOT NULL UNIQUE,
    password TEXT NOT NULL,
    hotp_secret TEXT NOT NULL,
    hotp_counter INTEGER DEFAULT 0,
    hotp_digits INTEGER DEFAULT 6
);

-- Using hex secrets (default format, no secret_type configuration needed)
INSERT INTO users (username, password, hotp_secret, hotp_counter, hotp_digits)
VALUES ('alice', 'alicepass123', '9bb65652855a09d0ae35c876e6c38b35fa34a0cd', 0, 6);

Security Considerations

Secret Storage

  • Store base32-encoded secrets securely in your backend database
  • Use encrypted database columns for sensitive data
  • Never log or expose secrets in plain text

Clock Synchronization (TOTP)

  • Ensure server clocks are synchronized using NTP
  • Use the resync_window parameter to allow reasonable clock drift
    • Single parameter creates symmetric window: resync_window 1; checks 1 timestep before and 1 after
    • Two parameters create asymmetric window: resync_window 2 1; checks 2 timesteps before, 1 after
    • With default 30-second timestep: resync_window 1; allows +/-30 seconds
    • With default 30-second timestep: resync_window 2; allows +/-60 seconds
    • Default: 1 0 checks 1 timestep before, 0 after (with default 30s timestep, accepts codes from 30s in the past)
  • Monitor for excessive clock drift which may indicate attacks

Replay Protection

TOTP Replay Protection:

TOTP with timestep tracking provides strong replay protection when configured correctly:

  • Track hmac-otp.timestep.last in the backend database
  • After a successful authentication store the timestep.last to allow for timestep and resync_window parameters to work.

Example UPDATE query:

query "UPDATE_TOTP_TIMESTEP" {
    statement "UPDATE users SET totp_last_timestep = ? WHERE username = ?";
    bindings {
        hmac-otp.timestep.last;
        user.username;
    }
}

The server validates that the authenticated timestep is strictly greater than the last stored timestep, preventing any replay attempts.

HOTP Replay Protection:

HOTP counter persistence prevents replay attacks - each counter value can only be used once.

Digit Count Validation

The server strictly validates that OTP codes have exactly the expected number of digits. For example, a 9-digit code will be rejected if the configuration expects 8 digits, even if the numeric value would otherwise match.

Troubleshooting

Authentication Fails with Valid TOTP

  1. Check clock synchronization on both client and server
  2. Verify resync_window is sufficient for clock drift (default is 1 0 which accepts codes from 30s in the past; use resync_window 1; for symmetric +/-30s, or resync_window 2; for +/-60s)
  3. Confirm secret format matches configuration (hex by default, or base32 if secret_type "base32" is set)
  4. Check timestep matches authenticator app settings (usually 30 seconds)
  5. Verify digits matches authenticator app configuration

Authentication Fails with Valid HOTP

  1. Verify counter value is correct and up-to-date
  2. Increase resync_window if counter may be ahead (default is 10; use larger value like resync_window 20; if needed)
  3. Confirm secret format matches configuration (hex by default, or base32 if secret_type "base32" is set)
  4. Check that counter is being persisted after successful authentication
  5. Verify digits matches expected OTP length

Digit Count Mismatch

Error: "Invalid TOTP/HOTP digit count: expected X digits, got Y digits"

  • Ensure client generates OTP with correct number of digits
  • Check hmac-otp.digits configuration matches authenticator app
  • Verify leading zeros are preserved (e.g., "012345" not "12345")

2FA Password Splitting Issues

  1. Verify range parameters are correct for your OTP length
  2. Check that password + OTP concatenation matches expected format
  3. Use radiator-client with both --password and --totp-secret/--hotp-secret for testing