WebOrbiton
v1.3.0

AtheraStudio SyncDrop

217 lines · 7.1 KB
  1. <?php
  2. declare(strict_types=1);
  3. ​
  4. defined('SD_BOOT') || exit;
  5. ​
  6. const SD_POW_BITS = 16;
  7. const SD_POW_MAX_BITS = 22;
  8. const SD_POW_TTL = 900;
  9. const SD_POW_MIN_AGE = 2;
  10. const SD_FAIL_WINDOW = 900;
  11. const SD_FAIL_LIMIT = 5;
  12. const SD_GLOBAL_LIMIT = 30;
  13. const SD_GLOBAL_WINDOW = 3600;
  14. ​
  15. function sd_secret_key(string $purpose): string
  16. {
  17. $env = sd_env();
  18. $base = (string)($env['SD_SECRET'] ?? '');
  19. if ($base === '') {
  20. $file = sd_data('state') . '/bootstrap.key';
  21. if (!is_file($file)) {
  22. @file_put_contents($file, bin2hex(random_bytes(32)), LOCK_EX);
  23. @chmod($file, 0600);
  24. }
  25. $base = trim((string)@file_get_contents($file));
  26. }
  27. return hash_hmac('sha256', $purpose, $base, true);
  28. }
  29. ​
  30. function sd_b64u(string $raw): string
  31. {
  32. return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
  33. }
  34. ​
  35. function sd_unb64u(string $text): string
  36. {
  37. return (string)base64_decode(strtr($text, '-_', '+/'), true);
  38. }
  39. ​
  40. function sd_ip_key(): string
  41. {
  42. $ip = sd_ip();
  43. if (str_contains($ip, ':')) {
  44. $packed = @inet_pton($ip);
  45. if ($packed !== false) $ip = bin2hex(substr($packed, 0, 8));
  46. }
  47. return substr(hash_hmac('sha256', $ip, sd_secret_key('ip')), 0, 24);
  48. }
  49. ​
  50. function sd_pow_bits(): int
  51. {
  52. $fails = sd_fail_count(sd_ip_key());
  53. return min(SD_POW_MAX_BITS, SD_POW_BITS + intdiv($fails, 2) * 2);
  54. }
  55. ​
  56. function sd_pow_issue(string $form): string
  57. {
  58. $payload = sd_b64u(json_encode([
  59. 'f' => $form,
  60. 'b' => sd_pow_bits(),
  61. 't' => time(),
  62. 'r' => bin2hex(random_bytes(12)),
  63. 'i' => sd_ip_key(),
  64. ]));
  65. return $payload . '.' . sd_b64u(hash_hmac('sha256', $payload, sd_secret_key('pow'), true));
  66. }
  67. ​
  68. function sd_zero_bits(string $raw): int
  69. {
  70. $bits = 0;
  71. foreach (str_split($raw) as $ch) {
  72. $byte = ord($ch);
  73. if ($byte === 0) {
  74. $bits += 8;
  75. continue;
  76. }
  77. for ($mask = 0x80; ($byte & $mask) === 0; $mask >>= 1) $bits++;
  78. break;
  79. }
  80. return $bits;
  81. }
  82. ​
  83. function sd_pow_verify(string $form): bool
  84. {
  85. $challenge = (string)($_POST['_pow'] ?? '');
  86. $nonce = (string)($_POST['_nonce'] ?? '');
  87. $parts = explode('.', $challenge);
  88. 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])) {
  89. sd_threat('pow_forged');
  90. return false;
  91. }
  92. $data = json_decode(sd_unb64u($parts[0]), true);
  93. if (!is_array($data) || ($data['f'] ?? '') !== $form) {
  94. sd_threat('pow_forged');
  95. return false;
  96. }
  97. if (($data['i'] ?? '') !== sd_ip_key()) return false;
  98. $age = time() - (int)($data['t'] ?? 0);
  99. if ($age > SD_POW_TTL) return false;
  100. $bits = (int)($data['b'] ?? 0);
  101. if ($age < SD_POW_MIN_AGE || $bits < SD_POW_BITS || sd_zero_bits(hash('sha256', $challenge . ':' . $nonce, true)) < $bits) {
  102. sd_threat('pow_invalid');
  103. return false;
  104. }
  105. $rid = (string)($data['r'] ?? '');
  106. $fresh = sd_state('pow', static function (array &$used) use ($rid): bool {
  107. $now = time();
  108. foreach ($used as $k => $exp) {
  109. if ($exp < $now) unset($used[$k]);
  110. }
  111. if (isset($used[$rid])) return false;
  112. $used[$rid] = $now + SD_POW_TTL;
  113. return true;
  114. });
  115. if (!$fresh) sd_threat('pow_invalid');
  116. return $fresh;
  117. }
  118. ​
  119. function sd_honeypot_ok(): bool
  120. {
  121. if (trim((string)($_POST['website'] ?? '')) === '') return true;
  122. sd_threat('honeypot');
  123. return false;
  124. }
  125. ​
  126. function sd_fail_count(string $key): int
  127. {
  128. $limits = sd_read_state('limits');
  129. $now = time();
  130. return count(array_filter($limits['ip'][$key]['f'] ?? [], static fn($t) => $now - (int)$t < SD_FAIL_WINDOW));
  131. }
  132. ​
  133. function sd_locked(): int
  134. {
  135. $limits = sd_read_state('limits');
  136. $now = time();
  137. $until = max((int)($limits['ip'][sd_ip_key()]['until'] ?? 0), (int)($limits['global']['until'] ?? 0));
  138. return $until > $now ? $until - $now : 0;
  139. }
  140. ​
  141. function sd_fail(string $reason): void
  142. {
  143. $key = sd_ip_key();
  144. $locked = sd_state('limits', static function (array &$limits) use ($key): string {
  145. $now = time();
  146. $entry = $limits['ip'][$key] ?? ['f' => [], 'until' => 0, 'n' => 0];
  147. $entry['f'] = array_values(array_filter($entry['f'], static fn($t) => $now - (int)$t < SD_FAIL_WINDOW));
  148. $entry['f'][] = $now;
  149. $result = '';
  150. if (count($entry['f']) >= SD_FAIL_LIMIT) {
  151. $entry['n'] = (int)($entry['n'] ?? 0) + 1;
  152. $entry['until'] = $now + min(86400, SD_FAIL_WINDOW * (2 ** ($entry['n'] - 1)));
  153. $entry['f'] = [];
  154. $result = 'ip';
  155. }
  156. $limits['ip'][$key] = $entry;
  157. $global = $limits['global'] ?? ['f' => [], 'until' => 0];
  158. $global['f'] = array_values(array_filter($global['f'], static fn($t) => $now - (int)$t < SD_GLOBAL_WINDOW));
  159. $global['f'][] = $now;
  160. if (count($global['f']) >= SD_GLOBAL_LIMIT && (int)$global['until'] < $now) {
  161. $global['until'] = $now + SD_GLOBAL_WINDOW;
  162. $global['f'] = [];
  163. $result = 'global';
  164. }
  165. $limits['global'] = $global;
  166. foreach ($limits['ip'] as $k => $e) {
  167. if (empty($e['f']) && (int)($e['until'] ?? 0) < $now - 86400) unset($limits['ip'][$k]);
  168. }
  169. return $result;
  170. });
  171. sd_audit('login_failed', ['reason' => $reason]);
  172. if ($locked !== '') {
  173. sd_audit('lockout', ['scope' => $locked]);
  174. sd_alert('alert_lockout_subject', 'alert_lockout_body', ['scope' => $locked]);
  175. }
  176. if (!sd_threat('fail_' . $reason) && $locked === 'ip') sd_threat('lockout');
  177. }
  178. ​
  179. function sd_fail_reset(): void
  180. {
  181. $key = sd_ip_key();
  182. sd_state('limits', static function (array &$limits) use ($key): void {
  183. unset($limits['ip'][$key]);
  184. });
  185. }
  186. ​
  187. function sd_alert(string $subjectKey, string $bodyKey, array $vars = [], ?string $forceTo = null): bool
  188. {
  189. $env = sd_env();
  190. if (!$env || ($forceTo === null && empty($env['SD_ALERTS']))) return false;
  191. $to = $forceTo ?? (string)($env['SD_EMAIL'] ?? '');
  192. if (!filter_var($to, FILTER_VALIDATE_EMAIL) || preg_match('/[\r\n]/', $to)) return false;
  193. $saved = (string)($env['SD_HOST'] ?? '');
  194. $host = preg_match('/^[a-z0-9.-]{1,253}(:\d{1,5})?$/', $saved) ? $saved : (sd_host() ?: 'localhost');
  195. $domain = preg_replace('/:\d+$/', '', $host);
  196. $vars += [
  197. 'ip' => sd_ip(),
  198. 'ua' => sd_ua() ?: '-',
  199. 'time' => gmdate('Y-m-d H:i:s') . ' UTC',
  200. 'host' => $host,
  201. 'user' => (string)($env['SD_USER'] ?? ''),
  202. ];
  203. $subject = SD_NAME . ': ' . t($subjectKey, $vars);
  204. $body = t($bodyKey, $vars) . "\n\n" . t('alert_footer', $vars) . "\n";
  205. $headers = [
  206. 'From: ' . SD_NAME . ' <syncdrop@' . $domain . '>',
  207. 'Content-Type: text/plain; charset=UTF-8',
  208. 'Content-Transfer-Encoding: 8bit',
  209. 'X-Mailer: SyncDrop',
  210. 'Auto-Submitted: auto-generated',
  211. ];
  212. $encoded = '=?UTF-8?B?' . base64_encode($subject) . '?=';
  213. $ok = @mail($to, $encoded, $body, implode("\r\n", $headers));
  214. sd_audit($ok ? 'alert_sent' : 'alert_failed', ['type' => $subjectKey]);
  215. return $ok;
  216. }
  217. ​