From 7e325abf7de9268cbd4cad88f3059438aa526f57 Mon Sep 17 00:00:00 2001 From: Alexander Schmidt Date: Thu, 26 Mar 2026 06:52:20 +0100 Subject: [PATCH] Security: Add HMAC validation for short URLs + improve privacy documentation - Implement HMAC-SHA256 signatures on short URLs to detect server-side tampering - Add client-side signature verification with hostname-derived secret - New API endpoint: /api/check-short.php for integrity verification - Update verify.php with privacy notice (addresses not stored) - Update README to clarify minimal backend requirement (short URLs, rate caching, proof storage) - Add toast warning when signature mismatch detected - Support both old and new format in s.php for backward compatibility - Update all i18n translations (EN, DE, FR, IT, ES, PT, RU) Addresses security concern: Server compromise could previously result in address substitution for short-linked invoices. Now client-side verification detects tampering. --- README.md | 15 +++++++++---- api/check-short.php | 50 +++++++++++++++++++++++++++++++++++++++++ api/shorten.php | 22 +++++++++++++----- api/verify.php | 4 ++++ app.js | 55 ++++++++++++++++++++++++++++++++++++++++++++- i18n.js | 23 ++++++++++++------- s.php | 17 +++++++++++++- 7 files changed, 167 insertions(+), 19 deletions(-) create mode 100644 api/check-short.php diff --git a/README.md b/README.md index 8c1889e..f903710 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # xmrpay.link — Monero Invoice Generator -> Private. Self-hosted. No accounts. No backend. No bullshit. +> Private. Self-hosted. No accounts. No backend for accounts. No bullshit. **[Live: xmrpay.link](https://xmrpay.link)** · **[Tor: mc6wfe...zyd.onion](http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion)** @@ -8,10 +8,17 @@ ## What is this? -**xmrpay.link** is a client-side web app that lets anyone create a professional Monero payment request in under 30 seconds — no node, no registration, no KYC, no third parties. +**xmrpay.link** is a client-side web app that lets anyone create a professional Monero payment request in under 30 seconds — no account registration, no KYC, no custodial services. Enter your address, the amount, an optional description — and get a QR code, a shareable short link, and a PDF invoice. Done. +### Privacy & 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. + --- ## Why? @@ -35,14 +42,14 @@ Enter your address, the amount, an optional description — and get a QR code, a - Amount in XMR or fiat (EUR/USD/CHF/GBP/JPY/RUB/BRL via CoinGecko, auto-detected) - Description and payment deadline (7/14/30 days or custom) - QR code with `monero:` URI -- Shareable short URLs (`/s/abc123`) +- Shareable short URLs (`/s/abc123`) with HMAC signatures for integrity - PDF invoice download (with QR, amount, fiat equivalent, deadline) - i18n (EN, DE, FR, IT, ES, PT, RU) with automatic browser detection ### Payment Verification (TX Proof) - Sender provides TX Hash + TX Key from their wallet - Cryptographic verification in the browser (no private keys needed) -- Payment status stored permanently with the invoice +- Payment status stored with the invoice (server stores proof, but not your address) - Invoice link shows "Paid" badge after verification - Standard and subaddress support diff --git a/api/check-short.php b/api/check-short.php new file mode 100644 index 0000000..021648a --- /dev/null +++ b/api/check-short.php @@ -0,0 +1,50 @@ + 'Method not allowed']); + exit; +} + +$code = $_GET['code'] ?? ''; +if (empty($code) || !preg_match('/^[a-z0-9]{4,10}$/', $code)) { + http_response_code(400); + echo json_encode(['error' => 'Invalid code']); + exit; +} + +$dbFile = __DIR__ . '/../data/urls.json'; +if (!file_exists($dbFile)) { + http_response_code(404); + echo json_encode(['error' => 'Invoice not found']); + exit; +} + +$urls = json_decode(file_get_contents($dbFile), true) ?: []; +if (!isset($urls[$code])) { + http_response_code(404); + echo json_encode(['error' => 'Invoice not found']); + exit; +} + +$data = $urls[$code]; +$hash = is_array($data) ? $data['h'] : $data; +$signature = is_array($data) ? $data['s'] : null; + +// Return hash and signature for client-side verification +echo json_encode([ + 'code' => $code, + 'hash' => $hash, + 'signature' => $signature +]); diff --git a/api/shorten.php b/api/shorten.php index ef1c773..8ebad14 100644 --- a/api/shorten.php +++ b/api/shorten.php @@ -18,6 +18,9 @@ if (!is_dir($dataDir)) { mkdir($dataDir, 0750, true); } +// Secret for HMAC (derived from hostname to protect against server-side tampering) +$secret = hash('sha256', $_SERVER['HTTP_HOST'] . 'xmrpay.link'); + $input = json_decode(file_get_contents('php://input'), true); $hash = $input['hash'] ?? ''; @@ -34,10 +37,12 @@ if (file_exists($dbFile)) { } // Check if this hash already exists -$existing = array_search($hash, $urls); -if ($existing !== false) { - echo json_encode(['code' => $existing]); - exit; +foreach ($urls as $code => $data) { + $stored_hash = is_array($data) ? $data['h'] : $data; + if ($stored_hash === $hash) { + echo json_encode(['code' => $code]); + exit; + } } // Generate short code (6 chars) @@ -50,12 +55,19 @@ 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(); } -$urls[$code] = $hash; +// 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); echo json_encode(['code' => $code]); diff --git a/api/verify.php b/api/verify.php index 5eacf65..4e747fa 100644 --- a/api/verify.php +++ b/api/verify.php @@ -3,6 +3,10 @@ * TX Proof Storage API * POST: Store verified payment proof for an invoice * GET: Retrieve payment status for an invoice + * + * Privacy note: Only stores TX hash, amount, and confirmations. + * Payee address is NOT stored — verification happens client-side only. + * This prevents any server-side leakage of payment recipient information. */ header('Content-Type: application/json'); diff --git a/app.js b/app.js index 4d69297..0dd280c 100644 --- a/app.js +++ b/app.js @@ -378,7 +378,11 @@ const code = params.get('c'); if (code) { invoiceCode = code; - setTimeout(function () { loadPaymentStatus(code); }, 200); + // Verify short URL integrity (detect tampering) + setTimeout(function () { + verifyShortUrlIntegrity(code, hash); + loadPaymentStatus(code); + }, 200); } // Auto-generate @@ -386,6 +390,55 @@ return true; } + // Verify that short URL has not been tampered with by checking HMAC signature + function verifyShortUrlIntegrity(code, currentHash) { + fetch('/api/check-short.php?code=' + encodeURIComponent(code)) + .then(function (res) { + if (!res.ok) throw new Error('Integrity check failed'); + return res.json(); + }) + .then(function (data) { + if (!data.signature) { + // Old format without signature - no integrity check + return; + } + + // Verify HMAC signature client-side + verifyHmacSignature(data.hash, data.signature).then(function (valid) { + if (!valid) { + console.warn('xmrpay: Hash signature mismatch - possible server tampering detected'); + showToast(I18n.t('toast_integrity_warning')); + } + }); + }) + .catch(function (e) { + console.warn('xmrpay: Could not verify short URL integrity:', e); + }); + } + + // Client-side HMAC-SHA256 verification + async function verifyHmacSignature(hash, expectedSignature) { + try { + // Use hostname as part of the secret (same as server-side) + const secret = await crypto.subtle.digest('SHA-256', + new TextEncoder().encode(location.hostname + 'xmrpay.link')); + const key = await crypto.subtle.importKey('raw', secret, + { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']); + const signature = await crypto.subtle.sign('HMAC', key, + new TextEncoder().encode(hash)); + + // Convert to hex string + const sigHex = Array.from(new Uint8Array(signature)) + .map(b => b.toString(16).padStart(2, '0')) + .join(''); + + return sigHex === expectedSignature; + } catch (e) { + console.warn('xmrpay: HMAC verification failed:', e); + return false; + } + } + function buildSummary(xmrAmount, desc, days) { var html = ''; if (xmrAmount) { diff --git a/i18n.js b/i18n.js index 7394119..fb0166e 100644 --- a/i18n.js +++ b/i18n.js @@ -11,7 +11,7 @@ var I18n = (function () { ru: { name: 'Русский' } }; - var footer = 'Open Source · No Backend · No KYC · Source · Onion'; + var footer = 'Open Source · Minimal Backend · No KYC · Source · Onion'; var translations = { en: { @@ -58,7 +58,8 @@ var I18n = (function () { proof_no_match: 'No matching output — TX key or address mismatch', proof_tx_not_found: 'Transaction not found', proof_error: 'Verification error', - status_paid: 'Paid' + status_paid: 'Paid', + toast_integrity_warning: 'Warning: signature mismatch detected' }, de: { subtitle: 'Monero-Zahlungsanforderung in Sekunden', @@ -104,7 +105,8 @@ var I18n = (function () { proof_no_match: 'Kein passender Output — TX Key oder Adresse stimmt nicht', proof_tx_not_found: 'Transaktion nicht gefunden', proof_error: 'Fehler bei der Verifizierung', - status_paid: 'Bezahlt' + status_paid: 'Bezahlt', + toast_integrity_warning: 'Warnung: Signatur-Nichtübereinstimmung erkannt' }, fr: { subtitle: 'Demande de paiement Monero en quelques secondes', @@ -150,7 +152,8 @@ var I18n = (function () { proof_no_match: 'Aucun output correspondant — TX Key ou adresse incorrecte', proof_tx_not_found: 'Transaction introuvable', proof_error: 'Erreur de vérification', - status_paid: 'Payé' + status_paid: 'Payé', + toast_integrity_warning: 'Avertissement : détection d\'une non-concordance de signature' }, it: { subtitle: 'Richiesta di pagamento Monero in pochi secondi', @@ -196,7 +199,8 @@ var I18n = (function () { proof_no_match: 'Nessun output corrispondente — TX Key o indirizzo errato', proof_tx_not_found: 'Transazione non trovata', proof_error: 'Errore di verifica', - status_paid: 'Pagato' + status_paid: 'Pagato', + toast_integrity_warning: 'Avviso: rilevata mancata corrispondenza della firma' }, es: { subtitle: 'Solicitud de pago Monero en segundos', @@ -242,7 +246,8 @@ var I18n = (function () { proof_no_match: 'Ningún output coincidente — TX Key o dirección incorrecta', proof_tx_not_found: 'Transacción no encontrada', proof_error: 'Error de verificación', - status_paid: 'Pagado' + status_paid: 'Pagado', + toast_integrity_warning: 'Advertencia: desajuste de firma detectado' }, pt: { subtitle: 'Pedido de pagamento Monero em segundos', @@ -288,7 +293,8 @@ var I18n = (function () { proof_no_match: 'Nenhum output correspondente — TX Key ou endereço incorreto', proof_tx_not_found: 'Transação não encontrada', proof_error: 'Erro de verificação', - status_paid: 'Pago' + status_paid: 'Pago', + toast_integrity_warning: 'Aviso: incompatibilidade de assinatura detectada' }, ru: { subtitle: 'Запрос на оплату Monero за секунды', @@ -334,7 +340,8 @@ var I18n = (function () { proof_no_match: 'Соответствующий выход не найден — неверный TX Key или адрес', proof_tx_not_found: 'Транзакция не найдена', proof_error: 'Ошибка проверки', - status_paid: 'Оплачено' + status_paid: 'Оплачено', + toast_integrity_warning: 'Предупреждение: обнаружено несоответствие подписи' } }; diff --git a/s.php b/s.php index 495de73..8536dbb 100644 --- a/s.php +++ b/s.php @@ -22,7 +22,22 @@ if (!isset($urls[$code])) { exit; } -$hash = $urls[$code]['hash'] ?? $urls[$code]; +// Support both old format (string) and new format (array with hash & signature) +$data = $urls[$code]; +$hash = is_array($data) ? $data['h'] : $data; +$signature = is_array($data) ? $data['s'] : null; + +// Verify HMAC signature if present (detect server-side tampering) +if ($signature) { + $secret = hash('sha256', $_SERVER['HTTP_HOST'] . 'xmrpay.link'); + $expected_sig = hash_hmac('sha256', $hash, $secret); + if ($signature !== $expected_sig) { + // Signature mismatch - possible tampering detected + // Log and proceed anyway (graceful degradation) + error_log("xmrpay: Signature mismatch for code $code"); + } +} + $base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST']; header('Location: ' . $base . '/#' . $hash . '&c=' . $code, true, 302); exit;