feat: self-host TweetNaCl, add server-side PoW captcha (PHP), activate categoriesService
This commit is contained in:
3
docs/pow-server/.htaccess
Normal file
3
docs/pow-server/.htaccess
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
RewriteEngine On
|
||||||
|
RewriteCond %{REQUEST_FILENAME} !-f
|
||||||
|
RewriteRule ^ index.php [L]
|
||||||
43
docs/pow-server/README.md
Normal file
43
docs/pow-server/README.md
Normal 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
|
||||||
20
docs/pow-server/challenge.php
Normal file
20
docs/pow-server/challenge.php
Normal 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,
|
||||||
|
]);
|
||||||
4
docs/pow-server/config.php
Normal file
4
docs/pow-server/config.php
Normal 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
25
docs/pow-server/index.php
Normal 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']);
|
||||||
|
}
|
||||||
47
docs/pow-server/verify.php
Normal file
47
docs/pow-server/verify.php
Normal 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]);
|
||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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 }))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
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
1
js/vendor/nacl-util.min.js
vendored
Normal 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});
|
||||||
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user