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.
This commit is contained in:
Alexander Schmidt
2026-03-26 06:52:20 +01:00
parent c1bd97948c
commit 7e325abf7d
7 changed files with 167 additions and 19 deletions

View File

@@ -1,6 +1,6 @@
# xmrpay.link — Monero Invoice Generator # 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)** **[Live: xmrpay.link](https://xmrpay.link)** · **[Tor: mc6wfe...zyd.onion](http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion)**
@@ -8,10 +8,17 @@
## What is this? ## 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. 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? ## 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) - 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) - Description and payment deadline (7/14/30 days or custom)
- QR code with `monero:` URI - 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) - PDF invoice download (with QR, amount, fiat equivalent, deadline)
- i18n (EN, DE, FR, IT, ES, PT, RU) with automatic browser detection - i18n (EN, DE, FR, IT, ES, PT, RU) with automatic browser detection
### Payment Verification (TX Proof) ### Payment Verification (TX Proof)
- Sender provides TX Hash + TX Key from their wallet - Sender provides TX Hash + TX Key from their wallet
- Cryptographic verification in the browser (no private keys needed) - 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 - Invoice link shows "Paid" badge after verification
- Standard and subaddress support - Standard and subaddress support

50
api/check-short.php Normal file
View File

@@ -0,0 +1,50 @@
<?php
/**
* Short URL Integrity Verification API
* GET: Return the hash and HMAC signature for client-side verification
*
* Security: Allows client-side verification that the hash has not been
* tampered with by the server. The signature is verified using the
* hostname as part of the secret HMAC key.
*/
header('Content-Type: application/json');
header('Access-Control-Allow-Origin: *');
if ($_SERVER['REQUEST_METHOD'] !== 'GET') {
http_response_code(405);
echo json_encode(['error' => '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
]);

View File

@@ -18,6 +18,9 @@ if (!is_dir($dataDir)) {
mkdir($dataDir, 0750, true); 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); $input = json_decode(file_get_contents('php://input'), true);
$hash = $input['hash'] ?? ''; $hash = $input['hash'] ?? '';
@@ -34,10 +37,12 @@ if (file_exists($dbFile)) {
} }
// Check if this hash already exists // Check if this hash already exists
$existing = array_search($hash, $urls); foreach ($urls as $code => $data) {
if ($existing !== false) { $stored_hash = is_array($data) ? $data['h'] : $data;
echo json_encode(['code' => $existing]); if ($stored_hash === $hash) {
exit; echo json_encode(['code' => $code]);
exit;
}
} }
// Generate short code (6 chars) // Generate short code (6 chars)
@@ -50,12 +55,19 @@ function generateCode($length = 6) {
return $code; return $code;
} }
// Generate HMAC signature to detect server-side tampering
$signature = hash_hmac('sha256', $hash, $secret);
$code = generateCode(); $code = generateCode();
while (isset($urls[$code])) { while (isset($urls[$code])) {
$code = generateCode(); $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); file_put_contents($dbFile, json_encode($urls, JSON_UNESCAPED_UNICODE), LOCK_EX);
echo json_encode(['code' => $code]); echo json_encode(['code' => $code]);

View File

@@ -3,6 +3,10 @@
* TX Proof Storage API * TX Proof Storage API
* POST: Store verified payment proof for an invoice * POST: Store verified payment proof for an invoice
* GET: Retrieve payment status 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'); header('Content-Type: application/json');

55
app.js
View File

@@ -378,7 +378,11 @@
const code = params.get('c'); const code = params.get('c');
if (code) { if (code) {
invoiceCode = code; invoiceCode = code;
setTimeout(function () { loadPaymentStatus(code); }, 200); // Verify short URL integrity (detect tampering)
setTimeout(function () {
verifyShortUrlIntegrity(code, hash);
loadPaymentStatus(code);
}, 200);
} }
// Auto-generate // Auto-generate
@@ -386,6 +390,55 @@
return true; 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) { function buildSummary(xmrAmount, desc, days) {
var html = ''; var html = '';
if (xmrAmount) { if (xmrAmount) {

23
i18n.js
View File

@@ -11,7 +11,7 @@ var I18n = (function () {
ru: { name: 'Русский' } ru: { name: 'Русский' }
}; };
var footer = 'Open Source &middot; No Backend &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a> &middot; <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a>'; var footer = 'Open Source &middot; Minimal Backend &middot; No KYC &middot; <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a> &middot; <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a>';
var translations = { var translations = {
en: { en: {
@@ -58,7 +58,8 @@ var I18n = (function () {
proof_no_match: 'No matching output — TX key or address mismatch', proof_no_match: 'No matching output — TX key or address mismatch',
proof_tx_not_found: 'Transaction not found', proof_tx_not_found: 'Transaction not found',
proof_error: 'Verification error', proof_error: 'Verification error',
status_paid: 'Paid' status_paid: 'Paid',
toast_integrity_warning: 'Warning: signature mismatch detected'
}, },
de: { de: {
subtitle: 'Monero-Zahlungsanforderung in Sekunden', 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_no_match: 'Kein passender Output — TX Key oder Adresse stimmt nicht',
proof_tx_not_found: 'Transaktion nicht gefunden', proof_tx_not_found: 'Transaktion nicht gefunden',
proof_error: 'Fehler bei der Verifizierung', proof_error: 'Fehler bei der Verifizierung',
status_paid: 'Bezahlt' status_paid: 'Bezahlt',
toast_integrity_warning: 'Warnung: Signatur-Nichtübereinstimmung erkannt'
}, },
fr: { fr: {
subtitle: 'Demande de paiement Monero en quelques secondes', 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_no_match: 'Aucun output correspondant — TX Key ou adresse incorrecte',
proof_tx_not_found: 'Transaction introuvable', proof_tx_not_found: 'Transaction introuvable',
proof_error: 'Erreur de vérification', 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: { it: {
subtitle: 'Richiesta di pagamento Monero in pochi secondi', 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_no_match: 'Nessun output corrispondente — TX Key o indirizzo errato',
proof_tx_not_found: 'Transazione non trovata', proof_tx_not_found: 'Transazione non trovata',
proof_error: 'Errore di verifica', proof_error: 'Errore di verifica',
status_paid: 'Pagato' status_paid: 'Pagato',
toast_integrity_warning: 'Avviso: rilevata mancata corrispondenza della firma'
}, },
es: { es: {
subtitle: 'Solicitud de pago Monero en segundos', 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_no_match: 'Ningún output coincidente — TX Key o dirección incorrecta',
proof_tx_not_found: 'Transacción no encontrada', proof_tx_not_found: 'Transacción no encontrada',
proof_error: 'Error de verificación', proof_error: 'Error de verificación',
status_paid: 'Pagado' status_paid: 'Pagado',
toast_integrity_warning: 'Advertencia: desajuste de firma detectado'
}, },
pt: { pt: {
subtitle: 'Pedido de pagamento Monero em segundos', 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_no_match: 'Nenhum output correspondente — TX Key ou endereço incorreto',
proof_tx_not_found: 'Transação não encontrada', proof_tx_not_found: 'Transação não encontrada',
proof_error: 'Erro de verificação', proof_error: 'Erro de verificação',
status_paid: 'Pago' status_paid: 'Pago',
toast_integrity_warning: 'Aviso: incompatibilidade de assinatura detectada'
}, },
ru: { ru: {
subtitle: 'Запрос на оплату Monero за секунды', subtitle: 'Запрос на оплату Monero за секунды',
@@ -334,7 +340,8 @@ var I18n = (function () {
proof_no_match: 'Соответствующий выход не найден — неверный TX Key или адрес', proof_no_match: 'Соответствующий выход не найден — неверный TX Key или адрес',
proof_tx_not_found: 'Транзакция не найдена', proof_tx_not_found: 'Транзакция не найдена',
proof_error: 'Ошибка проверки', proof_error: 'Ошибка проверки',
status_paid: 'Оплачено' status_paid: 'Оплачено',
toast_integrity_warning: 'Предупреждение: обнаружено несоответствие подписи'
} }
}; };

17
s.php
View File

@@ -22,7 +22,22 @@ if (!isset($urls[$code])) {
exit; 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']; $base = (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'];
header('Location: ' . $base . '/#' . $hash . '&c=' . $code, true, 302); header('Location: ' . $base . '/#' . $hash . '&c=' . $code, true, 302);
exit; exit;