hotp

HOTP action for HMAC-based One-Time Password authentication

hotp

Validates HMAC-based One-Time Password (HOTP) codes per RFC 4226. HOTP generates counter-synchronized one-time codes using HMAC-SHA1 and a shared secret. The counter increments with each authentication attempt, making each code usable only once.

Context

Valid inside @execute blocks. Can be combined with other authentication methods like pap for two-factor authentication.

Basic Syntax

@execute {
    backend {
        name "USERS";
        query "FIND_USER";
    }

    # Validate HOTP code in password field
    hotp {
        resync_window 10;
        secret_type "hex";
    }
}

Two-Factor Authentication Example

@execute {
    backend {
        name "USERS";
        query "FIND_USER";
    }

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

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

Parameters

resync_window

Defines how many counter values ahead of the current counter to check. This accounts for missed authentication attempts or token button presses.

Syntax: resync_window <forward_count>;

  • Single parameter only (HOTP only checks forward, never backward)
  • Default: 10
  • Valid range: 0-65535

Example: resync_window 10; checks the current counter and the next 10 counter values.

A larger window increases tolerance for missed authentications but reduces security (more codes are valid simultaneously). A value of 10-20 is typical for hardware tokens.

secret_type

Specifies the encoding format of the shared secret retrieved from the backend:

  • "hex" - Hexadecimal encoding (default)
  • "base32" - Base32 encoding (RFC 4648)
  • "auto" - Automatically detects hex or base32

Default: "hex"

The secret must be set via hmac-otp.secret in the backend mapping.

min_secret_bits

Enforces a minimum secret length in bits. If the decoded secret is shorter than this value, authentication is rejected with a warning logged and counted.

Syntax: min_secret_bits <bits>;

  • Default: 128 (as required by RFC 4226)
  • Valid range: 0-65535
  • Common values: 128 (16 bytes), 160 (20 bytes, recommended by RFC 4226)

Example: min_secret_bits 160; requires at least a 20-byte (160-bit) secret.

RFC 4226 states: "The length of the shared secret MUST be at least 128 bits. This document RECOMMENDs a shared secret length of 160 bits."

To disable the minimum check (not recommended), use min_secret_bits 0;.

range

Extracts a substring from the password field for HOTP validation. Useful for two-factor authentication where password and HOTP code are concatenated.

Syntax: range <start> <end> [exclusive]

  • Negative indices count from the end (-6 means 6 characters from the end)
  • exclusive keyword excludes the specified range (extracts everything except the range)
  • Indices are 0-based

Examples:

# Extract last 6 characters
range -6 0;

# Extract first 6 characters
range 0 6;

# Extract ALL BUT last 6 characters (for password in 2FA)
range -6 0 exclusive;

Backend Mapping

The backend must populate HOTP-related context variables:

backends {
    sqlite "USERS" {
        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;    # Required
                hmac-otp.counter = hotp_counter;  # Required
                hmac-otp.digits = hotp_digits;    # Optional (default: 6)
            }
        }
    }
}

Required context variables:

  • hmac-otp.secret - Shared secret (hex or base32 encoded)
  • hmac-otp.counter - Current counter value

Optional context variables:

  • hmac-otp.digits - Number of digits in HOTP code (default: 6, typically 6-8)

Counter Persistence

Critical: After successful HOTP authentication, you must persist the updated counter to prevent replay attacks and allow continued authentication:

backends {
    sqlite "USERS" {
        # ... FIND_USER query ...

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

aaa {
    policy "DEFAULT" {
        handler "AUTHENTICATION" {
            @execute {
                backend {
                    name "USERS";
                    query "FIND_USER";
                }
                hotp {
                    resync_window 10;
                }
            }

            @final-execute {
                # Save updated counter after successful authentication
                backend {
                    name "USERS";
                    query "UPDATE_HOTP_COUNTER";
                }
            }
        }
    }
}

See Also