v1.3.0
AtheraStudio SyncDrop
- <?php
- declare(strict_types=1);
-
- defined('SD_BOOT') || exit;
-
- const SD_POW_BITS = 16;
- const SD_POW_MAX_BITS = 22;
- const SD_POW_TTL = 900;
- const SD_POW_MIN_AGE = 2;
- const SD_FAIL_WINDOW = 900;
- const SD_FAIL_LIMIT = 5;
- const SD_GLOBAL_LIMIT = 30;
- const SD_GLOBAL_WINDOW = 3600;
-
- function sd_secret_key(string $purpose): string
- {
- $env = sd_env();
- $base = (string)($env['SD_SECRET'] ?? '');
- if ($base === '') {
- $file = sd_data('state') . '/bootstrap.key';
- if (!is_file($file)) {
- @file_put_contents($file, bin2hex(random_bytes(32)), LOCK_EX);
- @chmod($file, 0600);
- }
- $base = trim((string)@file_get_contents($file));
- }
- return hash_hmac('sha256', $purpose, $base, true);
- }
-
- function sd_b64u(string $raw): string
- {
- return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
- }
-
- function sd_unb64u(string $text): string
- {
- return (string)base64_decode(strtr($text, '-_', '+/'), true);
- }
-
- function sd_ip_key(): string
- {
- $ip = sd_ip();
- if (str_contains($ip, ':')) {
- $packed = @inet_pton($ip);
- if ($packed !== false) $ip = bin2hex(substr($packed, 0, 8));
- }
- return substr(hash_hmac('sha256', $ip, sd_secret_key('ip')), 0, 24);
- }
-
- function sd_pow_bits(): int
- {
- $fails = sd_fail_count(sd_ip_key());
- return min(SD_POW_MAX_BITS, SD_POW_BITS + intdiv($fails, 2) * 2);
- }
-
- function sd_pow_issue(string $form): string
- {
- $payload = sd_b64u(json_encode([
- 'f' => $form,
- 'b' => sd_pow_bits(),
- 't' => time(),
- 'r' => bin2hex(random_bytes(12)),
- 'i' => sd_ip_key(),
- ]));
- return $payload . '.' . sd_b64u(hash_hmac('sha256', $payload, sd_secret_key('pow'), true));
- }
-
- function sd_zero_bits(string $raw): int
- {
- $bits = 0;
- foreach (str_split($raw) as $ch) {
- $byte = ord($ch);
- if ($byte === 0) {
- $bits += 8;
- continue;
- }
- for ($mask = 0x80; ($byte & $mask) === 0; $mask >>= 1) $bits++;
- break;
- }
- return $bits;
- }
-
- function sd_pow_verify(string $form): bool
- {
- $challenge = (string)($_POST['_pow'] ?? '');
- $nonce = (string)($_POST['_nonce'] ?? '');
- $parts = explode('.', $challenge);
- if (!preg_match('/^\d{1,12}$/', $nonce) || strlen($challenge) > 600 || count($parts) !== 2 || !hash_equals(sd_b64u(hash_hmac('sha256', $parts[0], sd_secret_key('pow'), true)), $parts[1])) {
- sd_threat('pow_forged');
- return false;
- }
- $data = json_decode(sd_unb64u($parts[0]), true);
- if (!is_array($data) || ($data['f'] ?? '') !== $form) {
- sd_threat('pow_forged');
- return false;
- }
- if (($data['i'] ?? '') !== sd_ip_key()) return false;
- $age = time() - (int)($data['t'] ?? 0);
- if ($age > SD_POW_TTL) return false;
- $bits = (int)($data['b'] ?? 0);
- if ($age < SD_POW_MIN_AGE || $bits < SD_POW_BITS || sd_zero_bits(hash('sha256', $challenge . ':' . $nonce, true)) < $bits) {
- sd_threat('pow_invalid');
- return false;
- }
- $rid = (string)($data['r'] ?? '');
- $fresh = sd_state('pow', static function (array &$used) use ($rid): bool {
- $now = time();
- foreach ($used as $k => $exp) {
- if ($exp < $now) unset($used[$k]);
- }
- if (isset($used[$rid])) return false;
- $used[$rid] = $now + SD_POW_TTL;
- return true;
- });
- if (!$fresh) sd_threat('pow_invalid');
- return $fresh;
- }
-
- function sd_honeypot_ok(): bool
- {
- if (trim((string)($_POST['website'] ?? '')) === '') return true;
- sd_threat('honeypot');
- return false;
- }
-
- function sd_fail_count(string $key): int
- {
- $limits = sd_read_state('limits');
- $now = time();
- return count(array_filter($limits['ip'][$key]['f'] ?? [], static fn($t) => $now - (int)$t < SD_FAIL_WINDOW));
- }
-
- function sd_locked(): int
- {
- $limits = sd_read_state('limits');
- $now = time();
- $until = max((int)($limits['ip'][sd_ip_key()]['until'] ?? 0), (int)($limits['global']['until'] ?? 0));
- return $until > $now ? $until - $now : 0;
- }
-
- function sd_fail(string $reason): void
- {
- $key = sd_ip_key();
- $locked = sd_state('limits', static function (array &$limits) use ($key): string {
- $now = time();
- $entry = $limits['ip'][$key] ?? ['f' => [], 'until' => 0, 'n' => 0];
- $entry['f'] = array_values(array_filter($entry['f'], static fn($t) => $now - (int)$t < SD_FAIL_WINDOW));
- $entry['f'][] = $now;
- $result = '';
- if (count($entry['f']) >= SD_FAIL_LIMIT) {
- $entry['n'] = (int)($entry['n'] ?? 0) + 1;
- $entry['until'] = $now + min(86400, SD_FAIL_WINDOW * (2 ** ($entry['n'] - 1)));
- $entry['f'] = [];
- $result = 'ip';
- }
- $limits['ip'][$key] = $entry;
- $global = $limits['global'] ?? ['f' => [], 'until' => 0];
- $global['f'] = array_values(array_filter($global['f'], static fn($t) => $now - (int)$t < SD_GLOBAL_WINDOW));
- $global['f'][] = $now;
- if (count($global['f']) >= SD_GLOBAL_LIMIT && (int)$global['until'] < $now) {
- $global['until'] = $now + SD_GLOBAL_WINDOW;
- $global['f'] = [];
- $result = 'global';
- }
- $limits['global'] = $global;
- foreach ($limits['ip'] as $k => $e) {
- if (empty($e['f']) && (int)($e['until'] ?? 0) < $now - 86400) unset($limits['ip'][$k]);
- }
- return $result;
- });
- sd_audit('login_failed', ['reason' => $reason]);
- if ($locked !== '') {
- sd_audit('lockout', ['scope' => $locked]);
- sd_alert('alert_lockout_subject', 'alert_lockout_body', ['scope' => $locked]);
- }
- if (!sd_threat('fail_' . $reason) && $locked === 'ip') sd_threat('lockout');
- }
-
- function sd_fail_reset(): void
- {
- $key = sd_ip_key();
- sd_state('limits', static function (array &$limits) use ($key): void {
- unset($limits['ip'][$key]);
- });
- }
-
- function sd_alert(string $subjectKey, string $bodyKey, array $vars = [], ?string $forceTo = null): bool
- {
- $env = sd_env();
- if (!$env || ($forceTo === null && empty($env['SD_ALERTS']))) return false;
- $to = $forceTo ?? (string)($env['SD_EMAIL'] ?? '');
- if (!filter_var($to, FILTER_VALIDATE_EMAIL) || preg_match('/[\r\n]/', $to)) return false;
- $saved = (string)($env['SD_HOST'] ?? '');
- $host = preg_match('/^[a-z0-9.-]{1,253}(:\d{1,5})?$/', $saved) ? $saved : (sd_host() ?: 'localhost');
- $domain = preg_replace('/:\d+$/', '', $host);
- $vars += [
- 'ip' => sd_ip(),
- 'ua' => sd_ua() ?: '-',
- 'time' => gmdate('Y-m-d H:i:s') . ' UTC',
- 'host' => $host,
- 'user' => (string)($env['SD_USER'] ?? ''),
- ];
- $subject = SD_NAME . ': ' . t($subjectKey, $vars);
- $body = t($bodyKey, $vars) . "\n\n" . t('alert_footer', $vars) . "\n";
- $headers = [
- 'From: ' . SD_NAME . ' <syncdrop@' . $domain . '>',
- 'Content-Type: text/plain; charset=UTF-8',
- 'Content-Transfer-Encoding: 8bit',
- 'X-Mailer: SyncDrop',
- 'Auto-Submitted: auto-generated',
- ];
- $encoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
- $ok = @mail($to, $encoded, $body, implode("\r\n", $headers));
- sd_audit($ok ? 'alert_sent' : 'alert_failed', ['type' => $subjectKey]);
- return $ok;
- }
-