security: verify invoice amounts + require provider-issued zap receipts (1.2.14)
Fixes the audit findings: HIGH-1 (NWC/WebLN auto-pay of unverified invoices): add assertInvoiceAmount and call it before every programmatic payment in useZaps, useCrewZap, and SpamFeePayment. A malicious LNURL/Lightning-address server can no longer return an inflated invoice and drain the wallet — amount is decoded from bolt11 and must match the intended sats (±1 sat), amountless invoices are rejected. HIGH-2 (forgeable trust signals): - useSasquatchPro: Pro is granted ONLY by a zap receipt AUTHORED by the platform's Lightning provider, embedding a user-signed zap request that tags the platform, with a real paid amount >= smallest tier. The self-signed kind:32671 receipt is now advisory only and never grants access (closed the "self-grant permanent Pro" hole). - useFeaturedVerification: a listing is 'verified' only when the matching 9735 is authored by the provider pubkey (not any self-published receipt). - useCrewGoal: tally only invoice-backed (bolt11) zaps; the forgeable amount tag is no longer summed, so goal progress can't be inflated with fake receipts. Bump to 1.2.14. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
850c37dbaa
commit
be05a603be
9 changed files with 210 additions and 72 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.13",
|
"version": "1.2.14",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,20 @@
|
||||||
|
v1.2.14
|
||||||
|
|
||||||
|
🔒 Security hardening (payments & trust signals)
|
||||||
|
- Auto-pay now verifies every Lightning invoice's amount before paying (zaps,
|
||||||
|
crew zaps, listing fee). A server can no longer return an inflated invoice to
|
||||||
|
overcharge your wallet.
|
||||||
|
- Pro access is granted only by a verifiable, provider-issued zap receipt (plus
|
||||||
|
platform owners). Self-signed receipts no longer grant Pro.
|
||||||
|
- "Verified payment" badges on featured listings now require a provider-issued
|
||||||
|
zap receipt — self-published receipts can't fake verification.
|
||||||
|
- Crew funding goals tally only real invoice-backed zaps, so totals can't be
|
||||||
|
inflated with fake zap receipts.
|
||||||
|
|
||||||
|
Plus the delete diagnostics from 1.2.13 to pinpoint the signing/publish step.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
v1.2.10
|
v1.2.10
|
||||||
|
|
||||||
🛠️ Fixes a startup crash (black screen) introduced in the previous build and
|
🛠️ Fixes a startup crash (black screen) introduced in the previous build and
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useToast } from '@/hooks/useToast';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { useNWC } from '@/hooks/useNWCContext';
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
|
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
|
||||||
|
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
||||||
import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
|
import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
|
||||||
import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react';
|
import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react';
|
||||||
// qrcode imported lazily in async function
|
// qrcode imported lazily in async function
|
||||||
|
|
@ -49,6 +50,10 @@ export function SpamFeePayment({
|
||||||
comment: 'SkateSpots listing fee',
|
comment: 'SkateSpots listing fee',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SECURITY: verify the returned invoice really requests the listing fee
|
||||||
|
// (and not an inflated amount) before it can be auto-paid.
|
||||||
|
assertInvoiceAmount(bolt11, LISTING_FEE_SATS);
|
||||||
|
|
||||||
setInvoice(bolt11);
|
setInvoice(bolt11);
|
||||||
|
|
||||||
// Generate QR code
|
// Generate QR code
|
||||||
|
|
|
||||||
|
|
@ -24,31 +24,32 @@ export interface CrewGoal {
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sum sats from a set of zap receipts (kind 9735), reusing the bolt11/amount logic. */
|
/**
|
||||||
|
* Sum sats from a set of zap receipts (kind 9735).
|
||||||
|
*
|
||||||
|
* SECURITY: we tally ONLY the amount encoded in each receipt's bolt11 invoice.
|
||||||
|
* The free-form `amount` tag is trivially forgeable — anyone can publish a fake
|
||||||
|
* 9735 with `["amount","1000000000"]` `#e`-tagging the goal to inflate "raised"
|
||||||
|
* for fake funding/social proof. The bolt11 amount reflects a real invoice, so
|
||||||
|
* a receipt with no (or unparseable) bolt11 contributes 0 and is not counted.
|
||||||
|
*/
|
||||||
function tallyZaps(receipts: NostrEvent[], closedAt?: number): { sats: number; count: number } {
|
function tallyZaps(receipts: NostrEvent[], closedAt?: number): { sats: number; count: number } {
|
||||||
let sats = 0;
|
let sats = 0;
|
||||||
let count = 0;
|
let count = 0;
|
||||||
for (const z of receipts) {
|
for (const z of receipts) {
|
||||||
if (closedAt && z.created_at > closedAt) continue; // NIP-75: ignore late zaps
|
if (closedAt && z.created_at > closedAt) continue; // NIP-75: ignore late zaps
|
||||||
|
|
||||||
const amountTag = z.tags.find(([n]) => n === 'amount')?.[1];
|
|
||||||
if (amountTag) {
|
|
||||||
const ms = parseInt(amountTag, 10);
|
|
||||||
if (!Number.isNaN(ms)) {
|
|
||||||
sats += Math.floor(ms / 1000);
|
|
||||||
count++;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const bolt11 = z.tags.find(([n]) => n === 'bolt11')?.[1];
|
const bolt11 = z.tags.find(([n]) => n === 'bolt11')?.[1];
|
||||||
if (bolt11) {
|
if (!bolt11) continue; // no invoice → unverifiable → ignore
|
||||||
try {
|
|
||||||
sats += getSatoshisFromBolt11(bolt11);
|
try {
|
||||||
|
const invoiceSats = getSatoshisFromBolt11(bolt11);
|
||||||
|
if (invoiceSats > 0) {
|
||||||
|
sats += invoiceSats;
|
||||||
count++;
|
count++;
|
||||||
continue;
|
|
||||||
} catch {
|
|
||||||
// ignore unparseable invoice
|
|
||||||
}
|
}
|
||||||
|
} catch {
|
||||||
|
// Unparseable invoice → ignore (do NOT fall back to the amount tag).
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { sats, count };
|
return { sats, count };
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react';
|
||||||
import { useNWC } from '@/hooks/useNWCContext';
|
import { useNWC } from '@/hooks/useNWCContext';
|
||||||
import { useWallet } from '@/hooks/useWallet';
|
import { useWallet } from '@/hooks/useWallet';
|
||||||
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
|
import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress';
|
||||||
|
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
||||||
import type { NostrMetadata } from '@nostrify/nostrify';
|
import type { NostrMetadata } from '@nostrify/nostrify';
|
||||||
|
|
||||||
export type CrewZapStatus = 'pending' | 'resolving' | 'paying' | 'success' | 'skipped' | 'failed';
|
export type CrewZapStatus = 'pending' | 'resolving' | 'paying' | 'success' | 'skipped' | 'failed';
|
||||||
|
|
@ -117,6 +118,9 @@ export function useCrewZap() {
|
||||||
amountSats: amount,
|
amountSats: amount,
|
||||||
comment,
|
comment,
|
||||||
});
|
});
|
||||||
|
// SECURITY: never auto-pay an invoice whose amount doesn't match this
|
||||||
|
// recipient's share — a hostile address server could inflate it.
|
||||||
|
assertInvoiceAmount(invoice, amount);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
update(pubkey, { status: 'failed', error: err instanceof Error ? err.message : 'Invoice failed' });
|
update(pubkey, { status: 'failed', error: err instanceof Error ? err.message : 'Invoice failed' });
|
||||||
continue;
|
continue;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||||
|
import { getZapProviderPubkey } from '@/lib/lightningAddress';
|
||||||
|
import { PRO_LIGHTNING_ADDRESS } from '@/lib/sasquatchPro';
|
||||||
import { isFeaturedActive } from '@/lib/types';
|
import { isFeaturedActive } from '@/lib/types';
|
||||||
|
|
||||||
export type FeaturedStatus = 'none' | 'claimed' | 'verified';
|
export type FeaturedStatus = 'none' | 'claimed' | 'verified';
|
||||||
|
|
@ -8,16 +10,18 @@ export type FeaturedStatus = 'none' | 'claimed' | 'verified';
|
||||||
/**
|
/**
|
||||||
* Verify a "featured"/Pro listing against NIP-57 zap receipts.
|
* Verify a "featured"/Pro listing against NIP-57 zap receipts.
|
||||||
*
|
*
|
||||||
* Layered / best-effort:
|
* SECURITY: a receipt only counts as `verified` when it is AUTHORED by the
|
||||||
* - `verified`: a kind-9735 zap receipt paying the platform (p = platform pubkey)
|
* platform's Lightning provider pubkey. Previously any kind-9735 with matching
|
||||||
* and referencing this entity's `a` coordinate was found on relays. Tamper-proof.
|
* `#a`/`#p` tags was accepted — but anyone can publish such an event themselves,
|
||||||
* - `claimed`: the event carries an unexpired `featured` tag but no matching zap
|
* so a scammer could self-mint a "Verified payment" badge. Requiring the
|
||||||
* receipt could be found (e.g. the Lightning provider doesn't publish receipts).
|
* provider as author makes the receipt unforgeable.
|
||||||
* This is the older self-attested trust level — still shown, but not verified.
|
|
||||||
* - `none`: not featured (or the featured window has expired).
|
|
||||||
*
|
*
|
||||||
* The graceful fallback means Pro never silently breaks when the provider
|
* Layered / best-effort:
|
||||||
* (currently minibits) doesn't support zaps.
|
* - `verified`: a provider-authored kind-9735 zap receipt paying the platform
|
||||||
|
* and referencing this entity's `a` coordinate was found. Tamper-proof.
|
||||||
|
* - `claimed`: the event carries an unexpired `featured` tag but no verifiable
|
||||||
|
* provider receipt could be found. Self-attested — shown, but NOT verified.
|
||||||
|
* - `none`: not featured (or the featured window has expired).
|
||||||
*/
|
*/
|
||||||
export function useFeaturedVerification(
|
export function useFeaturedVerification(
|
||||||
entityCoord: string | undefined,
|
entityCoord: string | undefined,
|
||||||
|
|
@ -36,11 +40,18 @@ export function useFeaturedVerification(
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Zap receipts paying the platform that reference this entity.
|
// Only a receipt AUTHORED by the platform's Lightning provider is
|
||||||
|
// trustworthy. Resolve it; if unavailable we can't verify → claimed.
|
||||||
|
const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null);
|
||||||
|
if (!providerPubkey) return 'claimed';
|
||||||
|
|
||||||
|
// Provider-authored zap receipts paying the platform that reference this
|
||||||
|
// entity. `authors` is the critical anti-forgery constraint.
|
||||||
const receipts = await nostr.query(
|
const receipts = await nostr.query(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
kinds: [9735],
|
kinds: [9735],
|
||||||
|
authors: [providerPubkey],
|
||||||
'#a': [entityCoord],
|
'#a': [entityCoord],
|
||||||
'#p': PLATFORM_OWNER_PUBKEYS,
|
'#p': PLATFORM_OWNER_PUBKEYS,
|
||||||
limit: 20,
|
limit: 20,
|
||||||
|
|
@ -49,7 +60,7 @@ export function useFeaturedVerification(
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Any matching receipt authorizes the featured status.
|
// A provider-authored matching receipt authorizes featured status.
|
||||||
if (receipts.length > 0) return 'verified';
|
if (receipts.length > 0) return 'verified';
|
||||||
} catch {
|
} catch {
|
||||||
// Relay error — fall through to claimed.
|
// Relay error — fall through to claimed.
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,26 @@
|
||||||
/**
|
/**
|
||||||
* useSasquatchPro — is the logged-in user a current Pro supporter?
|
* useSasquatchPro — is the logged-in user a current Pro supporter?
|
||||||
* ─────────────────────────────────────────────────────────────────────────────
|
* ─────────────────────────────────────────────────────────────────────────────
|
||||||
* Verifies Pro access two ways, strongest first:
|
* Pro access is granted ONLY by cryptographically-verifiable proof of payment:
|
||||||
*
|
*
|
||||||
* 1. ZAP RECEIPT (NIP-57, kind 9735): the platform's Lightning provider
|
* 1. OWNER: platform owners always have Pro.
|
||||||
* publishes a receipt when the user zaps the platform. The receipt's zap
|
|
||||||
* request (kind 9734) is signed by the USER's npub and tags the SkateSpots
|
|
||||||
* Pro address, so it cryptographically binds payment → identity. A user
|
|
||||||
* cannot forge this. We read the tier/expiry from the zap request's
|
|
||||||
* `pro-tier` / `pro-expiry` tags (added at purchase time).
|
|
||||||
*
|
*
|
||||||
* 2. SELF-SIGNED RECEIPT (kind 32671): fallback for providers that don't emit
|
* 2. ZAP RECEIPT (NIP-57, kind 9735) — the ONLY network-trusted source. To
|
||||||
* zap receipts. Signed by the user, references the payment. Weaker (a
|
* count, a receipt must ALL of:
|
||||||
* technical user could self-issue), but never locks a paying user out.
|
* • be AUTHORED by the platform's own Lightning provider pubkey
|
||||||
|
* (resolved from the Pro Lightning address), so a user can't publish a
|
||||||
|
* fake receipt themselves;
|
||||||
|
* • embed a zap request (kind 9734) SIGNED BY THE USER (binds payment →
|
||||||
|
* identity) that tags the platform (`p`);
|
||||||
|
* • encode a paid amount of at least the smallest Pro tier.
|
||||||
|
* Expiry is read from the user-signed request's `pro-expiry` tag.
|
||||||
*
|
*
|
||||||
* Access = the max unexpired expiry found across either source.
|
* SECURITY: the self-signed kind-32671 receipt is NObodyy's proof of payment —
|
||||||
|
* any user can sign one with an arbitrary `expiry`, which previously let anyone
|
||||||
|
* grant themselves permanent Pro. It is therefore treated as ADVISORY ONLY and
|
||||||
|
* never grants access on its own. Genuine buyers are covered by the zap receipt
|
||||||
|
* (or by owner status); the fallback existed only for providers that emit no zap
|
||||||
|
* receipt, which is not worth a self-grant bypass.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
@ -22,16 +28,23 @@ import { useQuery } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions';
|
||||||
import { SASQUATCH_PRO_KIND } from '@/lib/types';
|
import { getZapProviderPubkey } from '@/lib/lightningAddress';
|
||||||
|
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
||||||
|
import { PRO_LIGHTNING_ADDRESS, PRO_TIERS } from '@/lib/sasquatchPro';
|
||||||
import type { NostrEvent } from '@nostrify/nostrify';
|
import type { NostrEvent } from '@nostrify/nostrify';
|
||||||
|
|
||||||
|
const PLATFORM_PUBKEY = PLATFORM_OWNER_PUBKEYS[0];
|
||||||
|
|
||||||
|
/** Smallest purchasable tier — the minimum a valid Pro zap must pay. */
|
||||||
|
const MIN_PRO_SATS = Math.min(...PRO_TIERS.map((t) => t.sats));
|
||||||
|
|
||||||
export interface ProStatus {
|
export interface ProStatus {
|
||||||
/** True if the user currently has active Pro access. */
|
/** True if the user currently has active Pro access. */
|
||||||
isPro: boolean;
|
isPro: boolean;
|
||||||
/** Unix seconds when access expires (max across sources), or null. */
|
/** Unix seconds when access expires (max across sources), or null. */
|
||||||
expiresAt: number | null;
|
expiresAt: number | null;
|
||||||
/** How access was granted. */
|
/** How access was granted. */
|
||||||
source: 'owner' | 'zap' | 'self' | null;
|
source: 'owner' | 'zap' | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -39,26 +52,59 @@ function tagVal(tags: string[][], name: string): string | undefined {
|
||||||
return tags.find((t) => t[0] === name)?.[1];
|
return tags.find((t) => t[0] === name)?.[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Extract an expiry (unix seconds) from a Pro-bearing event, if valid & ours. */
|
/**
|
||||||
function expiryFromSelfReceipt(event: NostrEvent, userPubkey: string): number | null {
|
* Extract expiry from a zap receipt, but ONLY if it's a genuine, provider-issued
|
||||||
if (event.pubkey !== userPubkey) return null;
|
* proof of payment for THIS user to the platform. Returns null unless every
|
||||||
const exp = Number(tagVal(event.tags, 'expiry'));
|
* check passes:
|
||||||
return Number.isFinite(exp) && exp > 0 ? exp : null;
|
* • receipt authored by the platform's Lightning provider pubkey;
|
||||||
}
|
* • embedded zap request signed by the user (payment↔identity binding);
|
||||||
|
* • zap request tags the platform pubkey (`p`);
|
||||||
|
* • paid amount (from the bolt11 tag, else the request `amount` tag) ≥ minimum.
|
||||||
|
*/
|
||||||
|
function expiryFromZapReceipt(
|
||||||
|
receipt: NostrEvent,
|
||||||
|
userPubkey: string,
|
||||||
|
providerPubkey: string,
|
||||||
|
): number | null {
|
||||||
|
// The receipt itself MUST be authored by the platform's Lightning provider.
|
||||||
|
if (!providerPubkey || receipt.pubkey !== providerPubkey) return null;
|
||||||
|
|
||||||
/** Extract expiry from a zap receipt whose zap-request is signed by the user. */
|
|
||||||
function expiryFromZapReceipt(receipt: NostrEvent, userPubkey: string): number | null {
|
|
||||||
// The 9735 receipt embeds the original 9734 zap request as a `description` tag.
|
|
||||||
const desc = tagVal(receipt.tags, 'description');
|
const desc = tagVal(receipt.tags, 'description');
|
||||||
if (!desc) return null;
|
if (!desc) return null;
|
||||||
|
|
||||||
|
let zapReq: NostrEvent;
|
||||||
try {
|
try {
|
||||||
const zapReq = JSON.parse(desc) as NostrEvent;
|
zapReq = JSON.parse(desc) as NostrEvent;
|
||||||
if (zapReq.pubkey !== userPubkey) return null; // must be signed by the user
|
|
||||||
const exp = Number(tagVal(zapReq.tags, 'pro-expiry'));
|
|
||||||
return Number.isFinite(exp) && exp > 0 ? exp : null;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zap request must be signed by the user and tag the platform.
|
||||||
|
if (zapReq.pubkey !== userPubkey) return null;
|
||||||
|
const tagsPlatform = zapReq.tags?.some((t) => t[0] === 'p' && t[1] === PLATFORM_PUBKEY);
|
||||||
|
if (!tagsPlatform) return null;
|
||||||
|
|
||||||
|
// Verify a real amount was paid: prefer the receipt's bolt11, else the
|
||||||
|
// request's amount tag (millisats). Must meet the smallest tier.
|
||||||
|
let paidSats = 0;
|
||||||
|
const bolt11 = tagVal(receipt.tags, 'bolt11');
|
||||||
|
if (bolt11) {
|
||||||
|
try {
|
||||||
|
paidSats = getSatoshisFromBolt11(bolt11);
|
||||||
|
} catch {
|
||||||
|
paidSats = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (paidSats === 0) {
|
||||||
|
const amountMsats = Number(tagVal(zapReq.tags, 'amount'));
|
||||||
|
if (Number.isFinite(amountMsats) && amountMsats > 0) {
|
||||||
|
paidSats = Math.floor(amountMsats / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (paidSats < MIN_PRO_SATS) return null;
|
||||||
|
|
||||||
|
const exp = Number(tagVal(zapReq.tags, 'pro-expiry'));
|
||||||
|
return Number.isFinite(exp) && exp > 0 ? exp : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSasquatchPro(): ProStatus {
|
export function useSasquatchPro(): ProStatus {
|
||||||
|
|
@ -77,32 +123,27 @@ export function useSasquatchPro(): ProStatus {
|
||||||
if (!pubkey) return { expiresAt: null, source: null };
|
if (!pubkey) return { expiresAt: null, source: null };
|
||||||
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]);
|
||||||
|
|
||||||
// Fetch both sources in one round-trip:
|
// The ONLY network-trusted source is a zap receipt authored by the
|
||||||
// • self-signed receipts authored by the user (kind 32671)
|
// platform's Lightning provider. Resolve that provider pubkey first; if we
|
||||||
// • zap receipts (9735) tagging the user as the zapper (#P)
|
// can't, we cannot verify any receipt and grant no access (owners are
|
||||||
|
// handled separately above).
|
||||||
|
const providerPubkey = await getZapProviderPubkey(PRO_LIGHTNING_ADDRESS).catch(() => null);
|
||||||
|
if (!providerPubkey) return { expiresAt: null, source: null };
|
||||||
|
|
||||||
|
// Zap receipts (9735) tagging the user as the zapper (#P), authored by the
|
||||||
|
// provider (#authors) — verified thoroughly in expiryFromZapReceipt.
|
||||||
const events = await nostr.query(
|
const events = await nostr.query(
|
||||||
[
|
[{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 50 }],
|
||||||
{ kinds: [SASQUATCH_PRO_KIND], authors: [pubkey], limit: 10 },
|
|
||||||
{ kinds: [9735], '#P': [pubkey], limit: 50 },
|
|
||||||
],
|
|
||||||
{ signal },
|
{ signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
let best = 0;
|
let best = 0;
|
||||||
let source: ProStatus['source'] = null;
|
|
||||||
|
|
||||||
for (const ev of events) {
|
for (const ev of events) {
|
||||||
if (ev.kind === SASQUATCH_PRO_KIND) {
|
const exp = expiryFromZapReceipt(ev, pubkey, providerPubkey);
|
||||||
const exp = expiryFromSelfReceipt(ev, pubkey);
|
if (exp && exp > best) best = exp;
|
||||||
if (exp && exp > best) { best = exp; source = 'self'; }
|
|
||||||
} else if (ev.kind === 9735) {
|
|
||||||
const exp = expiryFromZapReceipt(ev, pubkey);
|
|
||||||
// Zap receipts are the stronger source — prefer them on ties.
|
|
||||||
if (exp && exp >= best) { best = exp; source = 'zap'; }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { expiresAt: best || null, source: best ? source : null };
|
return { expiresAt: best || null, source: best ? 'zap' : null };
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import type { NWCConnection } from '@/hooks/useNWC';
|
||||||
import { nip57 } from 'nostr-tools';
|
import { nip57 } from 'nostr-tools';
|
||||||
import type { Event } from 'nostr-tools';
|
import type { Event } from 'nostr-tools';
|
||||||
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
||||||
|
import { assertInvoiceAmount } from '@/lib/verifyInvoice';
|
||||||
import type { WebLNProvider } from '@webbtc/webln-types';
|
import type { WebLNProvider } from '@webbtc/webln-types';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useNostr } from '@nostrify/react';
|
import { useNostr } from '@nostrify/react';
|
||||||
|
|
@ -242,6 +243,12 @@ export function useZaps(
|
||||||
throw new Error('Lightning service did not return a valid invoice');
|
throw new Error('Lightning service did not return a valid invoice');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SECURITY: verify the invoice actually requests the amount the user
|
||||||
|
// chose BEFORE any auto-pay. A malicious LNURL server could otherwise
|
||||||
|
// return an inflated invoice and drain the wallet. If this throws we
|
||||||
|
// fall into the catch below and surface the error instead of paying.
|
||||||
|
assertInvoiceAmount(newInvoice, amount);
|
||||||
|
|
||||||
// Get the current active NWC connection dynamically
|
// Get the current active NWC connection dynamically
|
||||||
const currentNWCConnection = getActiveConnection();
|
const currentNWCConnection = getActiveConnection();
|
||||||
|
|
||||||
|
|
|
||||||
52
src/lib/verifyInvoice.ts
Normal file
52
src/lib/verifyInvoice.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
/**
|
||||||
|
* Invoice amount verification for auto-pay flows.
|
||||||
|
*
|
||||||
|
* SECURITY (HIGH): Any invoice returned by a third-party LNURL / Lightning
|
||||||
|
* address server MUST be verified before it is paid programmatically (NWC /
|
||||||
|
* WebLN). Otherwise a malicious zap target can return a 1,000,000-sat invoice
|
||||||
|
* for a 21-sat zap and the app pays it silently. We decode the bolt11 amount
|
||||||
|
* ourselves and refuse to auto-pay if it doesn't match what the user intended.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getSatoshisFromBolt11 } from '@/lib/bolt11';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assert that a bolt11 invoice encodes an amount matching `expectedSats`.
|
||||||
|
*
|
||||||
|
* - Rejects a malformed / unparseable invoice.
|
||||||
|
* - Rejects an amountless invoice (0) when a concrete amount was expected — an
|
||||||
|
* amountless invoice would let the wallet be prompted for an arbitrary amount.
|
||||||
|
* - Allows a tiny tolerance for rounding between millisat/sat conversions.
|
||||||
|
*
|
||||||
|
* @throws Error with a clear message if the invoice must NOT be auto-paid.
|
||||||
|
*/
|
||||||
|
export function assertInvoiceAmount(invoice: string, expectedSats: number): void {
|
||||||
|
if (!invoice || typeof invoice !== 'string') {
|
||||||
|
throw new Error('The Lightning service returned an invalid invoice.');
|
||||||
|
}
|
||||||
|
|
||||||
|
let invoiceSats: number;
|
||||||
|
try {
|
||||||
|
invoiceSats = getSatoshisFromBolt11(invoice);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
'Could not verify the invoice amount — refusing to auto-pay for your safety.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amountless invoice: never auto-pay when we intended a specific amount.
|
||||||
|
if (invoiceSats === 0 && expectedSats > 0) {
|
||||||
|
throw new Error(
|
||||||
|
'The invoice has no fixed amount — refusing to auto-pay for your safety.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow 1 sat of rounding slack (millisat↔sat conversions).
|
||||||
|
const diff = Math.abs(invoiceSats - expectedSats);
|
||||||
|
if (diff > 1) {
|
||||||
|
throw new Error(
|
||||||
|
`Invoice amount mismatch: expected ${expectedSats} sats but the invoice ` +
|
||||||
|
`requests ${invoiceSats} sats. Payment blocked for your safety.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue