feat: self-host TweetNaCl, add server-side PoW captcha (PHP), activate categoriesService

This commit is contained in:
2026-02-06 14:04:24 +01:00
parent 1aa723728e
commit ebb5b2f86d
13 changed files with 210 additions and 20 deletions

View File

@@ -0,0 +1,3 @@
RewriteEngine On
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]

43
docs/pow-server/README.md Normal file
View File

@@ -0,0 +1,43 @@
# PoW Captcha Server
PHP-basierter Proof-of-Work Captcha Server für dgray.io.
## Setup
1. Subdomain `pow.dgray.io` auf den Server zeigen
2. Dateien in das Web-Root kopieren
3. Secret setzen:
```bash
# In .env oder Apache/Nginx config:
SetEnv POW_SECRET $(openssl rand -hex 32)
```
Oder direkt in `config.php` den Wert von `POW_SECRET` ändern.
4. Testen:
```bash
curl https://pow.dgray.io/challenge
```
## Endpoints
### GET /challenge
Gibt eine signierte Challenge zurück.
### POST /verify
Prüft die Lösung. Body (JSON):
```json
{
"challenge": "...",
"difficulty": 4,
"nonce": 12345,
"signature": "...",
"timestamp": 1700000000000
}
```
## Sicherheit
- HMAC-SHA256 signierte Challenges (nicht fälschbar)
- TTL: 2 Minuten
- CORS: nur `https://dgray.io`
- `hash_equals()` gegen Timing-Attacks

View File

@@ -0,0 +1,20 @@
<?php
require __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$challenge = bin2hex(random_bytes(16));
$timestamp = (int)(microtime(true) * 1000);
$signature = hash_hmac('sha256', "$challenge:$timestamp:" . POW_DIFFICULTY, POW_SECRET);
echo json_encode([
'challenge' => $challenge,
'difficulty' => POW_DIFFICULTY,
'timestamp' => $timestamp,
'signature' => $signature,
'expires_in' => POW_TTL_SECONDS,
]);

View File

@@ -0,0 +1,4 @@
<?php
define('POW_SECRET', getenv('POW_SECRET') ?: 'CHANGE_ME_TO_A_RANDOM_64_CHAR_HEX_STRING');
define('POW_DIFFICULTY', 4);
define('POW_TTL_SECONDS', 120);

25
docs/pow-server/index.php Normal file
View File

@@ -0,0 +1,25 @@
<?php
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: https://dgray.io');
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type');
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(204);
exit;
}
$uri = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
$uri = rtrim($uri, '/');
switch ($uri) {
case '/challenge':
require __DIR__ . '/challenge.php';
break;
case '/verify':
require __DIR__ . '/verify.php';
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Not found']);
}

View File

@@ -0,0 +1,47 @@
<?php
require __DIR__ . '/config.php';
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
http_response_code(405);
echo json_encode(['error' => 'Method not allowed']);
exit;
}
$input = json_decode(file_get_contents('php://input'), true);
$challenge = $input['challenge'] ?? null;
$difficulty = $input['difficulty'] ?? POW_DIFFICULTY;
$nonce = $input['nonce'] ?? null;
$signature = $input['signature'] ?? null;
$timestamp = $input['timestamp'] ?? null;
if (!$challenge || $nonce === null || !$signature || !$timestamp) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Missing fields']);
exit;
}
$now = (int)(microtime(true) * 1000);
if ($now - $timestamp > POW_TTL_SECONDS * 1000) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Challenge expired']);
exit;
}
$expected = hash_hmac('sha256', "$challenge:$timestamp:$difficulty", POW_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid signature']);
exit;
}
$hash = hash('sha256', $challenge . $nonce);
$prefix = str_repeat('0', $difficulty);
if (strpos($hash, $prefix) !== 0) {
http_response_code(400);
echo json_encode(['ok' => false, 'error' => 'Invalid proof']);
exit;
}
echo json_encode(['ok' => true]);

View File

@@ -2,6 +2,7 @@ import { t, i18n } from '../../i18n.js'
import { router } from '../../router.js' import { router } from '../../router.js'
import { auth } from '../../services/auth.js' import { auth } from '../../services/auth.js'
import { directus } from '../../services/directus.js' import { directus } from '../../services/directus.js'
import { categoriesService } from '../../services/categories.js'
import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js' import { SUPPORTED_CURRENCIES, getDisplayCurrency } from '../../services/currency.js'
import { escapeHTML } from '../../utils/helpers.js' import { escapeHTML } from '../../utils/helpers.js'
import '../location-picker.js' import '../location-picker.js'
@@ -181,7 +182,7 @@ class PageCreate extends HTMLElement {
async loadCategories() { async loadCategories() {
try { try {
this.categories = await directus.getCategories() this.categories = await categoriesService.getAll()
} catch (e) { } catch (e) {
console.error('Failed to load categories:', e) console.error('Failed to load categories:', e)
this.categories = [] this.categories = []

View File

@@ -1,5 +1,5 @@
// PoW Captcha Web Component // PoW Captcha Web Component
import { generateChallenge, solveChallenge } from '../services/pow-captcha.js' import { generateChallenge, solveChallenge, verifySolution } from '../services/pow-captcha.js'
import { t, i18n } from '../i18n.js' import { t, i18n } from '../i18n.js'
export class PowCaptcha extends HTMLElement { export class PowCaptcha extends HTMLElement {
@@ -53,20 +53,22 @@ export class PowCaptcha extends HTMLElement {
this.render() this.render()
try { try {
// Generate challenge (in production, fetch from server) const { challenge, difficulty, timestamp, signature } = await generateChallenge()
const { challenge, difficulty, timestamp, signature } = generateChallenge()
// Solve challenge
const result = await solveChallenge(challenge, difficulty) const result = await solveChallenge(challenge, difficulty)
// Store solution for form submission const solution = {
this.solution = {
challenge, challenge,
difficulty,
nonce: result.nonce, nonce: result.nonce,
signature, signature,
timestamp timestamp
} }
const verification = await verifySolution(solution)
if (!verification.ok) throw new Error('Verification failed')
this.solution = { ...solution, token: verification.token }
this.solving = false this.solving = false
this.solved = true this.solved = true
this.dispatchEvent(new CustomEvent('solved', { detail: this.solution })) this.dispatchEvent(new CustomEvent('solved', { detail: this.solution }))

View File

@@ -14,10 +14,9 @@ class CryptoService {
} }
async init() { async init() {
// Dynamically import TweetNaCl from CDN
if (!window.nacl) { if (!window.nacl) {
await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl@1.0.3/nacl-fast.min.js') await this.loadScript('/js/vendor/nacl-fast.min.js')
await this.loadScript('https://cdn.jsdelivr.net/npm/tweetnacl-util@0.15.1/nacl-util.min.js') await this.loadScript('/js/vendor/nacl-util.min.js')
} }
this.nacl = window.nacl this.nacl = window.nacl

View File

@@ -1,22 +1,67 @@
// Proof-of-Work Captcha Service // Proof-of-Work Captcha Service
// Client must find nonce where SHA256(challenge + nonce) has N leading zeros // Client must find nonce where SHA256(challenge + nonce) has N leading zeros
// Server-first: tries /pow/challenge endpoint, falls back to local generation
const DIFFICULTY = 4 // Number of leading zeros required (4 = ~65k attempts avg) const POW_SERVER = 'https://pow.dgray.io'
const DIFFICULTY = 4
const SERVER_TIMEOUT_MS = 1500
// TODO: Replace with a server-side endpoint. Currently generates challenge function localGenerateChallenge() {
// client-side with a btoa() "signature" that provides no real security.
export function generateChallenge() {
const challenge = crypto.randomUUID() const challenge = crypto.randomUUID()
const timestamp = Date.now() const timestamp = Date.now()
return { return {
challenge, challenge,
difficulty: DIFFICULTY, difficulty: DIFFICULTY,
timestamp, timestamp,
signature: btoa(`${challenge}:${timestamp}:${DIFFICULTY}`) signature: btoa(`${challenge}:${timestamp}:${DIFFICULTY}`),
source: 'local'
}
}
export async function generateChallenge() {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS)
const response = await fetch(`${POW_SERVER}/challenge`, {
method: 'GET',
signal: controller.signal
})
clearTimeout(timeout)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
return { ...data, source: 'server' }
} catch {
return localGenerateChallenge()
}
}
export async function verifySolution(solution) {
try {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), SERVER_TIMEOUT_MS)
const response = await fetch(`${POW_SERVER}/verify`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(solution),
signal: controller.signal
})
clearTimeout(timeout)
if (!response.ok) throw new Error(`HTTP ${response.status}`)
const data = await response.json()
return { ok: data.ok === true, token: data.captcha_token || null }
} catch {
const hash = await sha256(`${solution.challenge}${solution.nonce}`)
const prefix = '0'.repeat(solution.difficulty || DIFFICULTY)
return { ok: hash.startsWith(prefix), token: null }
} }
} }
// Solve challenge (runs in browser)
export async function solveChallenge(challenge, difficulty, onProgress) { export async function solveChallenge(challenge, difficulty, onProgress) {
let nonce = 0 let nonce = 0
const prefix = '0'.repeat(difficulty) const prefix = '0'.repeat(difficulty)
@@ -35,19 +80,16 @@ export async function solveChallenge(challenge, difficulty, onProgress) {
nonce++ nonce++
// Report progress every 1000 attempts
if (onProgress && nonce % 1000 === 0) { if (onProgress && nonce % 1000 === 0) {
onProgress({ attempts: nonce, elapsed: Date.now() - startTime }) onProgress({ attempts: nonce, elapsed: Date.now() - startTime })
} }
// Yield to main thread every 100 attempts
if (nonce % 100 === 0) { if (nonce % 100 === 0) {
await new Promise(r => setTimeout(r, 0)) await new Promise(r => setTimeout(r, 0))
} }
} }
} }
// SHA256 helper
async function sha256(message) { async function sha256(message) {
const msgBuffer = new TextEncoder().encode(message) const msgBuffer = new TextEncoder().encode(message)
const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer) const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer)

1
js/vendor/nacl-fast.min.js vendored Normal file

File diff suppressed because one or more lines are too long

1
js/vendor/nacl-util.min.js vendored Normal file
View File

@@ -0,0 +1 @@
!function(e,n){"use strict";"undefined"!=typeof module&&module.exports?module.exports=n():(e.nacl||(e.nacl={}),e.nacl.util=n())}(this,function(){"use strict";var e={};function o(e){if(!/^(?:[A-Za-z0-9+\/]{2}[A-Za-z0-9+\/]{2})*(?:[A-Za-z0-9+\/]{2}==|[A-Za-z0-9+\/]{3}=)?$/.test(e))throw new TypeError("invalid encoding")}return e.decodeUTF8=function(e){if("string"!=typeof e)throw new TypeError("expected string");var n,r=unescape(encodeURIComponent(e)),t=new Uint8Array(r.length);for(n=0;n<r.length;n++)t[n]=r.charCodeAt(n);return t},e.encodeUTF8=function(e){var n,r=[];for(n=0;n<e.length;n++)r.push(String.fromCharCode(e[n]));return decodeURIComponent(escape(r.join("")))},"undefined"==typeof atob?void 0!==Buffer.from?(e.encodeBase64=function(e){return Buffer.from(e).toString("base64")},e.decodeBase64=function(e){return o(e),new Uint8Array(Array.prototype.slice.call(Buffer.from(e,"base64"),0))}):(e.encodeBase64=function(e){return new Buffer(e).toString("base64")},e.decodeBase64=function(e){return o(e),new Uint8Array(Array.prototype.slice.call(new Buffer(e,"base64"),0))}):(e.encodeBase64=function(e){var n,r=[],t=e.length;for(n=0;n<t;n++)r.push(String.fromCharCode(e[n]));return btoa(r.join(""))},e.decodeBase64=function(e){o(e);var n,r=atob(e),t=new Uint8Array(r.length);for(n=0;n<r.length;n++)t[n]=r.charCodeAt(n);return t}),e});

View File

@@ -1,4 +1,4 @@
const CACHE_NAME = 'dgray-v40'; const CACHE_NAME = 'dgray-v41';
const STATIC_ASSETS = [ const STATIC_ASSETS = [
'/', '/',
'/index.html', '/index.html',
@@ -57,6 +57,8 @@ const STATIC_ASSETS = [
// Vendor // Vendor
'/js/vendor/cropper.min.js', '/js/vendor/cropper.min.js',
'/js/vendor/nacl-fast.min.js',
'/js/vendor/nacl-util.min.js',
// Locales // Locales
'/locales/de.json', '/locales/de.json',