YubiKey Authentication

Yubico OTP validation with Radiator - cloud and offline paths

What is YubiKey?

A YubiKey is a hardware security token manufactured by Yubico. When the user presses the button, the token generates a one-time password (OTP) and types it as keyboard input. Each OTP is unique and expires after a single use, which prevents replay attacks even if the OTP is intercepted in transit.

The Yubico OTP format is a 44-character modhex-encoded string. Modhex is Yubico's custom encoding for hexadecimal using just letters. The first 12 characters are the public UID — a stable identifier for the token. The remaining 32 characters are AES-128 encrypted payload that contains:

  • A 6-byte private UID (secret identity check)
  • A 16-bit usage counter (power-on count)
  • A 24-bit timestamp
  • An 8-bit session counter (button-press count within the current session)
  • A CRC16 checksum

Radiator supports two validation paths explained below.

Cloud Validation (yubikey HTTP backend)

The yubikey HTTP backend forwards the OTP to an external Yubico KSM-compatible validation server (such as api.yubico.com). Radiator constructs a signed HMAC-SHA1 request, sends it over HTTPS, and verifies the HMAC-signed response.

Flow:

  1. User sends RADIUS Access-Request with the OTP as the PAP password.
  2. Radiator sends a signed GET request to the validation server.
  3. The server verifies the OTP, checks its replay database, and returns a signed response.
  4. Radiator verifies the response HMAC and maps OK to accept.

This path requires no local key storage and offloads replay tracking to the Yubico cloud infrastructure.

Offline Validation (yubikey action)

The yubikey pipeline action decrypts and validates the OTP locally inside Radiator without any network call. This requires the AES-128 key for the token to be stored in a Radiator-accessible backend (database, file, etc.).

Flow:

  1. User sends RADIUS Access-Request with the OTP as the PAP password.
  2. A backend query loads yubikey.secret (and optionally public UID and replay counters) into the authentication context.
  3. The yubikey action decrypts the OTP, verifies CRC16, checks the private UID, and compares usage and session counters against stored values.
  4. On success, updated counters are persisted to the backend.

This path gives the fastest response time and works without internet access.

Choosing a Validation Backend

For production deployments, use the Yubico cloud service (api.yubico.com) or a self-hosted YubiKey validation server (YK-VAL + YK-KSM) as the validation backend. These provide token lifecycle management — provisioning, revocation, and key rotation — that Radiator does not include.

Radiator's offline yubikey action performs OTP decryption and replay counter checks but does not provide tools for provisioning tokens, revoking compromised keys, or managing token expiration. It is suitable for small deployments and testing where keys are managed manually.

DeploymentRecommended backendNotes
ProductionYubico cloud or self-hosted YK-VAL/YK-KSMFull lifecycle management
Small / labOffline yubikey action with local databaseManual key management

Cloud Validation Configuration

Backend Configuration

backends {
    yubikey "YUBIKEY_CLOUD" {
        url "https://api.yubico.com/wsapi/2.0/verify";
        username "12345";                          # Yubico client ID
        secret "base64encodedsecret=";             # Yubico API key (base64)
        timeout 5s;
        # connections 64;                          # optional, default 1024
    }
}
FieldRequiredDescription
urlYesBase URL of the validation server
usernameYesClient ID issued by Yubico (or your own KSM)
secretYesBase64-encoded API key for HMAC request signing
timeoutYesRequest timeout (e.g. 5s, 500ms)
connectionsNoMax concurrent HTTP connections (default: 1024)
tlsNoCustom TLS configuration block
Choosing a timeout value

Cloud validation goes over the public internet and is subject to variable latency. If the Yubico server does not respond within the configured timeout, Radiator treats the request as a backend error and the pipeline stops. Wrap the backend call in a try action to handle timeout errors explicitly:

try {
    backend {
        name "YUBIKEY_CLOUD";
    }
} catch {
    reject "YubiKey cloud validation failed: %{aaa.caught_error}";
}

Recommended starting values:

  • Yubico cloud (api.yubico.com): 5s — allows for typical internet round-trips and brief Yubico API delays.
  • Self-hosted KSM on the same LAN: 1s2s — lower latency expected; a tighter timeout detects hung servers faster.

AAA Policy

No backend query is needed before calling the HTTP backend. The OTP is extracted directly from the PAP response.

aaa {
    policy "DEFAULT" {
        handler "AUTHENTICATION" {
            conditions all {
                radius.request.code == radius.ACCESS_REQUEST;
            }

            @execute {
                backend {
                    name "YUBIKEY_CLOUD";
                }
            }
        }
    }
}

Using a Self-Hosted Validation Server

Replace the Yubico cloud URL with the address of a self-hosted KSM-compatible server (such as YK-VAL or a compatible implementation):

backends {
    yubikey "YUBIKEY_INTERNAL" {
        url "https://ykval.internal.example.com/wsapi/2.0/verify";
        username "1";
        secret "base64encodedkey=";
        timeout 3s;
        tls {
            ca "INTERNAL_CA";
        }
    }
}

Offline Validation Configuration

Note: Offline validation is intended for testing and small deployments. See Choosing a Validation Backend for production recommendations.

Backend Schema

Store one row per token. The aes_key, usage_counter, and session_counter columns are required — the counter columns provide replay protection. Replay protection works the same way as for TOTP/HOTP: counters are loaded from the database at request time, checked against the OTP, and written back on success. There is no separate in-memory replay cache.

CREATE TABLE yubikeys (
    username        TEXT NOT NULL,
    public_uid      TEXT,           -- 12-character modhex, e.g. "vvccccriigjn"
    aes_key         BLOB NOT NULL,  -- 16-byte binary AES-128 key (store as BLOB, not hex string)
    usage_counter   INTEGER NOT NULL DEFAULT 0,
    session_counter INTEGER NOT NULL DEFAULT 0
);

Important: aes_key must be stored as a raw 16-byte BLOB, not as a hex text string. Use a SQL hex literal when inserting: X'9fafa61d1ccafd37a33d7a3703356cd5'.

Backend Configuration

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

        query "FIND_USER" {
            statement "SELECT aes_key, public_uid, usage_counter, session_counter FROM yubikeys WHERE username = ?";
            bindings {
                aaa.identity;
            }
            mapping {
                yubikey.secret  = aes_key;
                yubikey.public  = public_uid;
                yubikey.counter = usage_counter;
                yubikey.session = session_counter;
            }
        }

        statement "UPDATE_COUNTERS" {
            statement "UPDATE yubikeys SET usage_counter = ?, session_counter = ? WHERE username = ?";
            bindings {
                yubikey.counter;
                yubikey.session;
                aaa.identity;
            }
        }
    }
}

AAA Policy

aaa {
    policy "DEFAULT" {
        handler "AUTHENTICATION" {
            conditions all {
                radius.request.code == radius.ACCESS_REQUEST;
            }

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

                yubikey;

                backend {
                    name "USERS";
                    query "UPDATE_COUNTERS";
                }
            }
        }
    }
}

Password + OTP (Two-Factor)

YubiKey OTP is typically used as the second factor. Users enter their static password and OTP concatenated in the PAP password field, such as mypassword<otp>. The range parameter on the pap and yubikey actions splits the field:

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

    # Validate static password (everything except the last 44 characters)
    pap {
        range -44 0 exclusive;
    }

    # Validate YubiKey OTP (last 44 characters)
    yubikey {
        range -44 0;
    }

    backend {
        name "USERS";
        query "UPDATE_COUNTERS";
    }
}

The FIND_USER query must also populate user.password for pap to work:

SELECT password, aes_key, public_uid, usage_counter, session_counter
FROM yubikeys JOIN users USING (username)
WHERE username = ?