diff --git a/README.md b/README.md index f903710..f79831f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # xmrpay.link — Monero Invoice Generator -> Private. Self-hosted. No accounts. No backend for accounts. No bullshit. +> Private. Self-hosted. No accounts. No tracking. No bullshit. **[Live: xmrpay.link](https://xmrpay.link)** · **[Tor: mc6wfe...zyd.onion](http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion)** @@ -12,12 +12,28 @@ Enter your address, the amount, an optional description — and get a QR code, a shareable short link, and a PDF invoice. Done. -### Privacy & Transparency +### Architecture & Transparency -- **Client-side first:** All cryptographic operations (QR codes, payment verification, PDF generation) run in your browser. Your private keys never leave your device. -- **Minimal backend:** Optional short URLs, fiat rate caching, and proof storage use a small server component with **no account tracking**. You can self-host or use the public instance. -- **HMAC-signed short URLs:** Invoice hashes are cryptographically signed to detect server-side tampering. -- **Address privacy:** Payment proofs are verified client-side only; the server never stores your XMR address. +xmrpay.link uses a **minimal backend** for the following specific purposes: + +| Component | Where it runs | What the server sees | +|-----------|--------------|---------------------| +| QR code generation | Browser only | Nothing | +| PDF invoice | Browser only | Nothing | +| Payment (TX) verification | Browser only | Nothing | +| Fiat exchange rates | Server (CoinGecko proxy) | Your IP address | +| Short URL storage | Server | Invoice hash (address + amount + description), HMAC-signed | +| Payment proof storage | Server | TX hash + amount — **not** your XMR address | + +**Self-hosting** eliminates any trust in the public instance. +**No short links** (use the long `/#...` URL or QR code) = zero server involvement. + +### Security Model + +- **HMAC-signed short URLs:** Hashes are signed with a server-side secret. Clients verify the signature on load to detect tampering. +- **Address never stored:** Payment verification is cryptographic and runs client-side. The server never learns your XMR address. +- **Rate-limited APIs:** All write endpoints are rate-limited per IP. +- **Origin-restricted:** API endpoints reject cross-origin requests. --- diff --git a/api/_helpers.php b/api/_helpers.php new file mode 100644 index 0000000..6745d02 --- /dev/null +++ b/api/_helpers.php @@ -0,0 +1,83 @@ + 'Origin not allowed']); + exit; + } +} + +// ── HMAC secret ─────────────────────────────────────────────────────────────── +// Auto-generated on first run, stored outside webroot in data/secret.key +function get_hmac_secret(): string { + $secretFile = __DIR__ . '/../data/secret.key'; + if (file_exists($secretFile)) { + return trim(file_get_contents($secretFile)); + } + $secret = bin2hex(random_bytes(32)); + $dir = dirname($secretFile); + if (!is_dir($dir)) mkdir($dir, 0750, true); + file_put_contents($secretFile, $secret, LOCK_EX); + chmod($secretFile, 0600); + return $secret; +} + +// ── Rate limiting ───────────────────────────────────────────────────────────── +// Returns false when limit exceeded, true otherwise +function check_rate_limit(string $action, int $limit, int $window_seconds): bool { + $ip = $_SERVER['REMOTE_ADDR'] ?? 'unknown'; + $rateDir = __DIR__ . '/../data/rate/'; + if (!is_dir($rateDir)) @mkdir($rateDir, 0755, true); + $rateFile = $rateDir . $action . '_' . md5($ip) . '.json'; + $now = time(); + $times = []; + if (file_exists($rateFile)) { + $times = json_decode(file_get_contents($rateFile), true) ?: []; + $times = array_values(array_filter($times, fn($t) => $t > $now - $window_seconds)); + } + if (count($times) >= $limit) return false; + $times[] = $now; + file_put_contents($rateFile, json_encode($times), LOCK_EX); + return true; +} + +// ── Atomic JSON read/write ──────────────────────────────────────────────────── +// Returns [file_handle, data_array] — caller must call write_json_locked() to finish +function read_json_locked(string $file): array { + $dir = dirname($file); + if (!is_dir($dir)) mkdir($dir, 0750, true); + $fp = fopen($file, 'c+'); + flock($fp, LOCK_EX); + $size = filesize($file); + $data = ($size > 0) ? (json_decode(fread($fp, $size), true) ?: []) : []; + return [$fp, $data]; +} + +function write_json_locked($fp, array $data): void { + ftruncate($fp, 0); + rewind($fp); + fwrite($fp, json_encode($data, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); + flock($fp, LOCK_UN); + fclose($fp); +} diff --git a/api/check-short.php b/api/check-short.php index 021648a..615ffc7 100644 --- a/api/check-short.php +++ b/api/check-short.php @@ -1,15 +1,17 @@ $code, 'hash' => $hash, - 'signature' => $signature + 'signature' => $expected ]); diff --git a/api/node.php b/api/node.php index 8ffa6c5..fa658a0 100644 --- a/api/node.php +++ b/api/node.php @@ -1,4 +1,6 @@ $t > $now - 60); } -if (count($rateData) >= 1000) { +if (count($rateData) >= 60) { http_response_code(429); echo json_encode(['error' => 'Rate limit exceeded']); exit; diff --git a/api/shorten.php b/api/shorten.php index 8ebad14..b018cab 100644 --- a/api/shorten.php +++ b/api/shorten.php @@ -1,8 +1,8 @@ 'Rate limit exceeded']); + exit; } -// Secret for HMAC (derived from hostname to protect against server-side tampering) -$secret = hash('sha256', $_SERVER['HTTP_HOST'] . 'xmrpay.link'); +$dbFile = __DIR__ . '/../data/urls.json'; $input = json_decode(file_get_contents('php://input'), true); $hash = $input['hash'] ?? ''; -if (empty($hash) || strlen($hash) > 500) { +if (empty($hash) || strlen($hash) > 500 || !preg_match('/^[a-zA-Z0-9%+_=&.-]{1,500}$/', $hash)) { http_response_code(400); echo json_encode(['error' => 'Invalid data']); exit; } -// Load existing URLs -$urls = []; -if (file_exists($dbFile)) { - $urls = json_decode(file_get_contents($dbFile), true) ?: []; -} +$secret = get_hmac_secret(); + +[$fp, $urls] = read_json_locked($dbFile); // Check if this hash already exists foreach ($urls as $code => $data) { $stored_hash = is_array($data) ? $data['h'] : $data; if ($stored_hash === $hash) { + flock($fp, LOCK_UN); + fclose($fp); echo json_encode(['code' => $code]); exit; } } // Generate short code (6 chars) -function generateCode($length = 6) { +function generateCode(int $length = 6): string { $chars = 'abcdefghijkmnpqrstuvwxyz23456789'; $code = ''; for ($i = 0; $i < $length; $i++) { @@ -55,19 +55,14 @@ function generateCode($length = 6) { return $code; } -// Generate HMAC signature to detect server-side tampering -$signature = hash_hmac('sha256', $hash, $secret); - $code = generateCode(); while (isset($urls[$code])) { $code = generateCode(); } -// Store hash with signature -$urls[$code] = [ - 'h' => $hash, - 's' => $signature // HMAC signature for integrity verification -]; -file_put_contents($dbFile, json_encode($urls, JSON_UNESCAPED_UNICODE), LOCK_EX); +$signature = hash_hmac('sha256', $hash, $secret); +$urls[$code] = ['h' => $hash, 's' => $signature]; + +write_json_locked($fp, $urls); echo json_encode(['code' => $code]); diff --git a/api/verify.php b/api/verify.php index 4e747fa..c27b61c 100644 --- a/api/verify.php +++ b/api/verify.php @@ -1,30 +1,34 @@ 'Rate limit exceeded']); + exit; + } $code = $_GET['code'] ?? ''; if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) { echo json_encode(['verified' => false]); exit; } - + $proofs = file_exists($dbFile) ? (json_decode(file_get_contents($dbFile), true) ?: []) : []; if (isset($proofs[$code])) { echo json_encode(array_merge(['verified' => true], $proofs[$code])); } else { @@ -40,6 +44,14 @@ if ($_SERVER['REQUEST_METHOD'] !== 'POST') { exit; } +verify_origin(); + +if (!check_rate_limit('verify_post', 10, 3600)) { + http_response_code(429); + echo json_encode(['error' => 'Rate limit exceeded']); + exit; +} + $input = json_decode(file_get_contents('php://input'), true); if (!$input) { http_response_code(400); @@ -64,7 +76,7 @@ if (!preg_match('/^[0-9a-fA-F]{64}$/', $txHash)) { exit; } -// Verify the short URL code exists +// Verify the short URL code exists (read-only, no lock needed here) $urlsFile = __DIR__ . '/../data/urls.json'; if (!file_exists($urlsFile)) { http_response_code(404); @@ -78,7 +90,17 @@ if (!isset($urls[$code])) { exit; } -// Store proof +// Store proof with atomic lock +[$fp, $proofs] = read_json_locked($dbFile); + +// Don't overwrite an already-verified proof +if (isset($proofs[$code])) { + flock($fp, LOCK_UN); + fclose($fp); + echo json_encode(['ok' => true]); + exit; +} + $proofs[$code] = [ 'tx_hash' => strtolower($txHash), 'amount' => $amount, @@ -86,5 +108,6 @@ $proofs[$code] = [ 'verified_at' => time() ]; -file_put_contents($dbFile, json_encode($proofs, JSON_PRETTY_PRINT)); +write_json_locked($fp, $proofs); echo json_encode(['ok' => true]); + diff --git a/index.html b/index.html index f093e6e..a99eadc 100644 --- a/index.html +++ b/index.html @@ -4,7 +4,7 @@