includes/modules/two_factor.php

<?php
declare(strict_types=1);

/*
|--------------------------------------------------------------------------
| Two-factor authentication (TOTP)
|--------------------------------------------------------------------------
|
| Local RFC 6238-compatible TOTP implementation.
| No third-party API is called: authenticator apps receive a standard
| otpauth:// URI or the Base32 secret for manual entry.
|
*/

require_once __DIR__ . '/../graphs/qrcode.php';

function brivaciaTwoFactorSettings(): array
{
    $settings = brivacia_setting('admin.two_factor', []);

    return is_array($settings) ? $settings : [];
}

function brivaciaTwoFactorEnabled(): bool
{
    $settings = brivaciaTwoFactorSettings();

    return
        !empty($settings['enabled'])
        && trim((string)($settings['secret'] ?? '')) !== '';
}

function brivaciaTwoFactorSecret(): string
{
    return strtoupper(trim((string)(brivaciaTwoFactorSettings()['secret'] ?? '')));
}

function brivaciaTwoFactorBackupCodeHashes(): array
{
    $codes = brivaciaTwoFactorSettings()['backup_codes'] ?? [];

    return is_array($codes) ? array_values($codes) : [];
}

function brivaciaTwoFactorLastCounter(): int
{
    return max(0, (int)(brivaciaTwoFactorSettings()['last_counter'] ?? 0));
}


function brivaciaTwoFactorSetupSettings(): array
{
    $settings = brivacia_setting('admin.two_factor_setup', []);

    return is_array($settings) ? $settings : [];
}

function brivaciaTwoFactorCreateSetup(): array
{
    $secret = brivaciaTwoFactorGenerateSecret();
    $token = bin2hex(random_bytes(16));
    $settings = brivacia_settings();

    $settings['admin']['two_factor_setup'] = [
        'token_hash' => hash('sha256', $token),
        'secret' => $secret,
        'expires_at' => time() + 600,
    ];

    brivacia_save_settings($settings);

    return [
        'token' => $token,
        'secret' => $secret,
        'otpauth_uri' => brivaciaTwoFactorOtpauthUri($secret),
    ];
}

function brivaciaTwoFactorPendingSecret(string $token): string
{
    $token = trim($token);
    $setup = brivaciaTwoFactorSetupSettings();
    $tokenHash = (string)($setup['token_hash'] ?? '');
    $secret = strtoupper(trim((string)($setup['secret'] ?? '')));
    $expiresAt = (int)($setup['expires_at'] ?? 0);

    if ($token === '' || $tokenHash === '' || $secret === '' || $expiresAt < time()) {
        brivaciaTwoFactorClearSetup();
        return '';
    }

    if (!hash_equals($tokenHash, hash('sha256', $token))) {
        return '';
    }

    return $secret;
}

function brivaciaTwoFactorClearSetup(): void
{
    $settings = brivacia_settings();
    $settings['admin']['two_factor_setup'] = [];

    brivacia_save_settings($settings);
}

function brivaciaTwoFactorBase32Encode(string $bytes): string
{
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $bits = '';
    $output = '';

    foreach (str_split($bytes) as $byte) {
        $bits .= str_pad(decbin(ord($byte)), 8, '0', STR_PAD_LEFT);
    }

    foreach (str_split($bits, 5) as $chunk) {
        if (strlen($chunk) < 5) {
            $chunk = str_pad($chunk, 5, '0', STR_PAD_RIGHT);
        }

        $output .= $alphabet[bindec($chunk)];
    }

    return $output;
}

function brivaciaTwoFactorBase32Decode(string $secret): string|false
{
    $alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
    $secret = strtoupper($secret);
    $secret = preg_replace('/[^A-Z2-7]/', '', $secret) ?? '';

    if ($secret === '') {
        return false;
    }

    $bits = '';

    foreach (str_split($secret) as $char) {
        $value = strpos($alphabet, $char);

        if ($value === false) {
            return false;
        }

        $bits .= str_pad(decbin($value), 5, '0', STR_PAD_LEFT);
    }

    $output = '';

    foreach (str_split($bits, 8) as $byte) {
        if (strlen($byte) === 8) {
            $output .= chr(bindec($byte));
        }
    }

    return $output !== '' ? $output : false;
}

function brivaciaTwoFactorGenerateSecret(): string
{
    return brivaciaTwoFactorBase32Encode(random_bytes(20));
}

function brivaciaTwoFactorFormatSecret(string $secret): string
{
    return trim(chunk_split(strtoupper($secret), 4, ' '));
}

function brivaciaTwoFactorNormalizeBackupCode(string $code): string
{
    return strtoupper(preg_replace('/[^A-Z2-7]/i', '', $code) ?? '');
}

function brivaciaTwoFactorGenerateBackupCodes(int $count = 10): array
{
    $codes = [];

    for ($i = 0; $i < $count; $i++) {
        $raw = brivaciaTwoFactorBase32Encode(random_bytes(5));
        $codes[] = substr($raw, 0, 5) . '-' . substr($raw, 5, 5);
    }

    return $codes;
}

function brivaciaTwoFactorHashBackupCodes(array $codes): array
{
    return array_map(
        fn (string $code): string => password_hash(
            brivaciaTwoFactorNormalizeBackupCode($code),
            PASSWORD_DEFAULT
        ),
        $codes
    );
}

function brivaciaTwoFactorOtpauthUri(string $secret): string
{
    $issuer = brivacia_setting('dashboard.instance_name', 'Brivacia');
    $issuer = trim((string)$issuer) ?: 'Brivacia';
    $account = brivaciaAuthUser() ?: 'admin';
    $label = rawurlencode($issuer . ':' . $account);

    return 'otpauth://totp/' . $label . '?' . http_build_query([
        'secret' => $secret,
        'issuer' => $issuer,
        'algorithm' => 'SHA1',
        'digits' => 6,
        'period' => 30,
    ], '', '&', PHP_QUERY_RFC3986);
}


function brivaciaTwoFactorCodeForCounter(string $secret, int $counter): ?string
{
    $key = brivaciaTwoFactorBase32Decode($secret);

    if ($key === false) {
        return null;
    }

    $binaryCounter = pack('N*', 0, $counter);
    $hash = hash_hmac('sha1', $binaryCounter, $key, true);
    $offset = ord($hash[19]) & 0x0f;

    $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 brivaciaTwoFactorVerifyTotp(
    string $secret,
    string $code,
    int $timestamp = 0,
    int $window = 1,
    ?int &$matchedCounter = null
): bool {
    $code = preg_replace('/\D/', '', $code) ?? '';

    if (!preg_match('/^\d{6}$/', $code)) {
        return false;
    }

    $timestamp = $timestamp > 0 ? $timestamp : time();
    $counter = intdiv($timestamp, 30);

    for ($offset = -$window; $offset <= $window; $offset++) {
        $testCounter = $counter + $offset;

        if ($testCounter < 0) {
            continue;
        }

        $expected = brivaciaTwoFactorCodeForCounter($secret, $testCounter);

        if ($expected !== null && hash_equals($expected, $code)) {
            $matchedCounter = $testCounter;
            return true;
        }
    }

    return false;
}

function brivaciaTwoFactorSave(array $twoFactor): void
{
    $settings = brivacia_settings();
    $settings['admin']['two_factor'] = $twoFactor;

    brivacia_save_settings($settings);
}

function brivaciaTwoFactorEnable(string $secret, array $backupCodes, int $lastCounter = 0): void
{
    $settings = brivacia_settings();

    $settings['admin']['two_factor'] = [
        'enabled' => true,
        'secret' => $secret,
        'backup_codes' => brivaciaTwoFactorHashBackupCodes($backupCodes),
        'last_counter' => max(0, $lastCounter),
    ];
    $settings['admin']['two_factor_setup'] = [];

    brivacia_save_settings($settings);
}

function brivaciaTwoFactorDisable(): void
{
    $settings = brivacia_settings();

    $settings['admin']['two_factor'] = [
        'enabled' => false,
        'secret' => '',
        'backup_codes' => [],
        'last_counter' => 0,
    ];
    $settings['admin']['two_factor_setup'] = [];

    brivacia_save_settings($settings);
}

function brivaciaTwoFactorVerifyBackupCode(string $code): bool
{
    $normalized = brivaciaTwoFactorNormalizeBackupCode($code);

    if ($normalized === '') {
        return false;
    }

    $hashes = brivaciaTwoFactorBackupCodeHashes();

    foreach ($hashes as $index => $hash) {
        if (password_verify($normalized, (string)$hash)) {
            unset($hashes[$index]);

            $settings = brivaciaTwoFactorSettings();
            $settings['backup_codes'] = array_values($hashes);
            brivaciaTwoFactorSave($settings);

            return true;
        }
    }

    return false;
}

function brivaciaTwoFactorVerifyLoginCode(string $code): bool
{
    if (!brivaciaTwoFactorEnabled()) {
        return true;
    }

    $secret = brivaciaTwoFactorSecret();
    $matchedCounter = null;

    if (brivaciaTwoFactorVerifyTotp($secret, $code, time(), 1, $matchedCounter)) {
        if ($matchedCounter !== null && $matchedCounter <= brivaciaTwoFactorLastCounter()) {
            return false;
        }

        $settings = brivaciaTwoFactorSettings();
        $settings['last_counter'] = max((int)$matchedCounter, brivaciaTwoFactorLastCounter());
        brivaciaTwoFactorSave($settings);

        return true;
    }

    return brivaciaTwoFactorVerifyBackupCode($code);
}

Contribute