Files
2026-10-20 13:16:38 +09:00

124 lines
4.1 KiB
PHP

<?php
declare(strict_types=1);
/*
* Two-factor authentication for admins: TOTP (RFC 6238, SHA-1, 6 digits, 30 s),
* compatible with Google/Microsoft Authenticator. The shared secret is stored
* encrypted (AES-256-GCM) with security.app_key, which lives outside the
* document root. Recovery codes are stored as HMACs keyed with the same key.
*/
const TOTP_PERIOD = 30;
const TOTP_DIGITS = 6;
const TOTP_WINDOW = 1; // accept one step before/after for clock drift
const OTP_MAX_FAILS = 5; // wrong codes within LOGIN_WINDOW_MIN before the login is dropped
const RECOVERY_CODE_COUNT = 10;
const BASE32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function app_key(): string
{
$key = base64_decode((string)(security_config()['app_key'] ?? ''), true);
if ($key === false || strlen($key) !== 32) {
throw new RuntimeException('security.app_key belum diisi (32 byte base64); dibutuhkan untuk OTP.');
}
return $key;
}
function base32_encode(string $bin): string
{
$bits = '';
foreach (str_split($bin) as $ch) {
$bits .= str_pad(decbin(ord($ch)), 8, '0', STR_PAD_LEFT);
}
$out = '';
foreach (str_split($bits, 5) as $chunk) {
$out .= BASE32_ALPHABET[bindec(str_pad($chunk, 5, '0'))];
}
return $out;
}
function base32_decode(string $b32): string
{
$bits = '';
foreach (str_split(strtoupper(preg_replace('/[^A-Za-z2-7]/', '', $b32))) as $ch) {
$bits .= str_pad(decbin(strpos(BASE32_ALPHABET, $ch)), 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_new_secret(): string
{
return base32_encode(random_bytes(20)); // 160-bit, as recommended by RFC 4226
}
function totp_code(string $secret, int $step): string
{
$hash = hash_hmac('sha1', pack('J', $step), base32_decode($secret), true);
$offset = ord($hash[19]) & 0x0f;
$bin = ((ord($hash[$offset]) & 0x7f) << 24) | (ord($hash[$offset + 1]) << 16)
| (ord($hash[$offset + 2]) << 8) | ord($hash[$offset + 3]);
return str_pad((string)($bin % (10 ** TOTP_DIGITS)), TOTP_DIGITS, '0', STR_PAD_LEFT);
}
/** The matching time step, or null. Steps at or before $lastStep are refused (no replay). */
function totp_verify(string $secret, string $code, ?int $lastStep): ?int
{
$code = preg_replace('/\D/', '', $code);
if (strlen($code) !== TOTP_DIGITS) {
return null;
}
$now = intdiv(time(), TOTP_PERIOD);
for ($s = $now - TOTP_WINDOW; $s <= $now + TOTP_WINDOW; $s++) {
if (($lastStep === null || $s > $lastStep) && hash_equals(totp_code($secret, $s), $code)) {
return $s;
}
}
return null;
}
function totp_uri(string $secret, string $username): string
{
$label = rawurlencode('SERUBI Bank Papua:' . $username);
return "otpauth://totp/$label?secret=$secret&issuer=" . rawurlencode('SERUBI Bank Papua') . '&period=' . TOTP_PERIOD . '&digits=' . TOTP_DIGITS;
}
function secret_encrypt(string $plain): string
{
$iv = random_bytes(12);
$tag = '';
$ct = openssl_encrypt($plain, 'aes-256-gcm', app_key(), OPENSSL_RAW_DATA, $iv, $tag);
return base64_encode($iv . $tag . $ct);
}
function secret_decrypt(string $stored): string
{
$raw = (string)base64_decode($stored, true);
$plain = openssl_decrypt(substr($raw, 28), 'aes-256-gcm', app_key(), OPENSSL_RAW_DATA, substr($raw, 0, 12), substr($raw, 12, 16));
if ($plain === false) {
throw new RuntimeException('OTP secret tidak dapat dibuka (app_key berubah?).');
}
return $plain;
}
function recovery_hash(string $code): string
{
return hash_hmac('sha256', strtoupper(preg_replace('/[^A-Za-z0-9]/', '', $code)), app_key());
}
/** Fresh one-time codes like "K7QD-M2XP": [plain codes to show once, hashes to store]. */
function recovery_codes_new(): array
{
$plain = [];
for ($i = 0; $i < RECOVERY_CODE_COUNT; $i++) {
$c = substr(base32_encode(random_bytes(5)), 0, 8);
$plain[] = substr($c, 0, 4) . '-' . substr($c, 4);
}
return [$plain, array_map('recovery_hash', $plain)];
}