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:
15
README.md
15
README.md
@@ -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
50
api/check-short.php
Normal 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
|
||||||
|
]);
|
||||||
@@ -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,11 +37,13 @@ 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) {
|
||||||
|
echo json_encode(['code' => $code]);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Generate short code (6 chars)
|
// Generate short code (6 chars)
|
||||||
function generateCode($length = 6) {
|
function generateCode($length = 6) {
|
||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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
55
app.js
@@ -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
23
i18n.js
@@ -11,7 +11,7 @@ var I18n = (function () {
|
|||||||
ru: { name: 'Русский' }
|
ru: { name: 'Русский' }
|
||||||
};
|
};
|
||||||
|
|
||||||
var footer = 'Open Source · No Backend · No KYC · <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a> · <a href="http://mc6wfeaqc7oijgdcudrr5zsotmwok3jzk3tu2uezzyjisn7nzzjjizyd.onion" title="Tor Hidden Service">Onion</a>';
|
var footer = 'Open Source · Minimal Backend · No KYC · <a href="https://gitea.schmidt.eco/schmidt1024/xmrpay.link" target="_blank">Source</a> · <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
17
s.php
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user