Radiator Server Documentation — v10.33.1

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 a Yubico OTP validation server, such as YubiCloud at api.yubico.com/wsapi/2.0/verify or a self-hosted validation server such as YK-VAL. Radiator constructs a signed HMAC-SHA1 request, sends it over HTTPS, and verifies the HMAC-signed response.

This backend uses the Yubico OTP validation protocol. Do not point it directly at a YK-KSM decryption endpoint; that is a different protocol.

The validation server is only responsible for YubiKey OTP validity: it confirms that the OTP is well-formed, belongs to a known token for that validation service, and has not been replayed. Radiator remains the AAA decision-maker. It is still responsible for user lookup, account state, authorization policy, and the final accept or reject decision.

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 then continues the surrounding AAA logic.

This path requires no local key storage and offloads replay tracking to the Yubico cloud infrastructure. When users submit <password><otp> in one PAP field, configure range on the backend action in the handler pipeline to extract only the OTP before Radiator sends the validation request.

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 YubiCloud (api.yubico.com/wsapi/2.0/verify) or a self-hosted YubiKey validation server such as YK-VAL. YK-KSM can provide key storage and decryption behind the validation server, but it is not itself the validation endpoint that Radiator calls. These validation services 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
ProductionYubiCloud or self-hosted YK-VALFull lifecycle management; YK-KSM can be used behind YK-VAL
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 100
    }
}
FieldRequiredDescription
urlYesBase URL of the validation server
usernameYesClient ID issued by Yubico (or your validation server)
secretYesBase64-encoded API key for HMAC request signing
timeoutYesRequest timeout (e.g. 5s, 500ms)
connectionsNoMax concurrent HTTP connections (default: 100)
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 validation server on the same LAN: 1s-2s - lower latency expected; a tighter timeout detects hung servers faster.

AAA Policy

No backend query is needed to validate the OTP itself. The OTP is extracted directly from the PAP response.

In production, Radiator usually still has local AAA work to do around the YubiKey check, such as loading the user record, checking account status, and applying authorization policy. Treat YubiCloud as the OTP validator, not as the master of your user database.

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

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

Password + OTP (Two-Factor)

For cloud validation with a combined PAP value, configure the range on the backend action in the handler pipeline.

backends {
    yubikey "YUBIKEY_CLOUD_OTP" {
        url "https://api.yubico.com/wsapi/2.0/verify";
        username "12345";
        secret "base64encodedsecret=";
        timeout 5s;
    }
}

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

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

                pap {
                    range -44 0 exclusive;
                }

                backend {
                    name "YUBIKEY_CLOUD_OTP";
                    range -44 0;
                }
            }
        }
    }
}

The FIND_USER query must populate user.password for pap to work. If your client cannot do an interactive Access-Challenge round-trip, this combined PAP field is the practical way to collect both factors in one request.

This pattern also shows the responsibility split clearly: Radiator uses its own backend to validate the account password and any local user state, while the YubiKey cloud backend validates only the OTP portion.

For example, the local USERS lookup can come from a JSON file and return more than just the password. It can also load group membership and account-expiration metadata that Radiator uses in the surrounding AAA policy:

backends {
    jsonfile "USERS" {
        filename "users.json";

        query "FIND_USER" {
            mapping {
                user.password = doc | jsonpath("$.users['%{aaa.identity}'].password");
                user.group = doc | jsonpath("$.users['%{aaa.identity}'].groups[*]");
                vars.account_expires_at = doc | jsonpath("$.users['%{aaa.identity}'].expires_at");
            }
        }
    }
}

After the OTP is validated, Radiator can still evaluate local policy such as group-based authorization or account-expiration checks using the values loaded by FIND_USER.

Using a Self-Hosted Validation Server

Replace the YubiCloud URL with the address of a self-hosted validation server, such as YK-VAL or a compatible implementation. If you deploy YK-KSM, place it behind the validation server rather than pointing Radiator directly at the YK-KSM endpoint:

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 = ?