turnley.dev

PHP and MySQL code for faucet operators

Tools & Calculators

SEC-004

Two-factor authentication in plain PHP, no library

TOTP is HMAC-SHA1 over a time counter, truncated to six digits. About forty lines, and it works with every authenticator app.

Time-based one-time passwords look like something that needs a package. They do not. The algorithm is a counter of thirty second intervals since the epoch, an HMAC-SHA1 of that counter with a shared secret, and a documented way of pulling six digits out of the result.

The secret is shared with the user's app as base32, usually through an otpauth URI in a QR code. The app derives the same code from the same clock, so nothing travels between you after setup.

Accept one interval either side of now. Phone clocks drift, and a user whose device is twenty seconds fast will otherwise be permanently locked out with no way to diagnose it.

PHP
function base32_decode(string $b32): string
{
    $map = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $b32 = rtrim(strtoupper($b32), '=');
    $bits = '';
    for ($i = 0, $n = strlen($b32); $i < $n; $i++) {
        $pos = strpos($map, $b32[$i]);
        if ($pos === false) {
            continue;
        }
        $bits .= str_pad(decbin($pos), 5, '0', STR_PAD_LEFT);
    }
    $out = '';
    foreach (str_split($bits, 8) as $byte) {
        if (strlen($byte) === 8) {
            $out .= chr(bindec($byte));
        }
    }
    return $out;
}

function totp_code(string $secretBase32, ?int $counter = null): string
{
    $counter = $counter ?? (int) floor(time() / 30);
    $binCounter = pack('N*', 0, $counter);              // 64-bit big endian
    $hash = hash_hmac('sha1', $binCounter, base32_decode($secretBase32), true);

    $offset = ord($hash[19]) & 0x0F;                    // dynamic truncation
    $value  = ((ord($hash[$offset])     & 0x7F) << 24)
            | ((ord($hash[$offset + 1]) & 0xFF) << 16)
            | ((ord($hash[$offset + 2]) & 0xFF) << 8)
            |  (ord($hash[$offset + 3]) & 0xFF);

    return str_pad((string) ($value % 1000000), 6, '0', STR_PAD_LEFT);
}

function totp_verify(string $secretBase32, string $entered, int $window = 1): bool
{
    $entered = preg_replace('~\D~', '', $entered) ?? '';
    if (strlen($entered) !== 6) {
        return false;
    }
    $now = (int) floor(time() / 30);
    for ($i = -$window; $i <= $window; $i++) {
        if (hash_equals(totp_code($secretBase32, $now + $i), $entered)) {
            return true;
        }
    }
    return false;
}

Using it

Hand the user an otpauth URI to scan:

otpauth://totp/YourSite:username?secret=BASE32SECRET&issuer=YourSite

Store the secret encrypted at rest if you can, and record the last accepted counter per user so a code cannot be replayed inside its own thirty second window.

Issue recovery codes at setup. Without them, a lost phone means a manual identity check.

What bites people

A clock that is wrong on the server breaks every code at once and looks like a code bug. Check the server time first when nothing verifies.

The window is a trade: each step of window is thirty more seconds during which an intercepted code still works. One is the normal answer.

Also in Auth and Security