From be05a603beff5f1ba0d0e00d73a949f70e3d29d4 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Mon, 7 Sep 2026 11:45:21 +0700 Subject: [PATCH] security: verify invoice amounts + require provider-issued zap receipts (1.2.14) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- package.json | 2 +- release-notes.txt | 17 ++++ src/components/SpamFeePayment.tsx | 5 ++ src/hooks/useCrewGoal.ts | 33 +++---- src/hooks/useCrewZap.ts | 4 + src/hooks/useFeaturedVerification.ts | 33 ++++--- src/hooks/useSasquatchPro.ts | 129 ++++++++++++++++++--------- src/hooks/useZaps.ts | 7 ++ src/lib/verifyInvoice.ts | 52 +++++++++++ 9 files changed, 210 insertions(+), 72 deletions(-) create mode 100644 src/lib/verifyInvoice.ts diff --git a/package.json b/package.json index 8a63852..f9e5776 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "skate-spots", "private": true, - "version": "1.2.13", + "version": "1.2.14", "type": "module", "homepage": "https://skate-spots.com", "scripts": { diff --git a/release-notes.txt b/release-notes.txt index 608e1ef..ba5716b 100644 --- a/release-notes.txt +++ b/release-notes.txt @@ -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 πŸ› οΈ Fixes a startup crash (black screen) introduced in the previous build and diff --git a/src/components/SpamFeePayment.tsx b/src/components/SpamFeePayment.tsx index c6fc363..a75db6c 100644 --- a/src/components/SpamFeePayment.tsx +++ b/src/components/SpamFeePayment.tsx @@ -5,6 +5,7 @@ import { useToast } from '@/hooks/useToast'; import { useWallet } from '@/hooks/useWallet'; import { useNWC } from '@/hooks/useNWCContext'; import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress'; +import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import { LISTING_FEE_SATS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions'; import { Zap, Loader2, CheckCircle, Copy, ExternalLink, ShieldAlert, TrendingUp, MapPin } from 'lucide-react'; // qrcode imported lazily in async function @@ -49,6 +50,10 @@ export function SpamFeePayment({ 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); // Generate QR code diff --git a/src/hooks/useCrewGoal.ts b/src/hooks/useCrewGoal.ts index b932ee2..6cb23c5 100644 --- a/src/hooks/useCrewGoal.ts +++ b/src/hooks/useCrewGoal.ts @@ -24,31 +24,32 @@ export interface CrewGoal { 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 } { let sats = 0; let count = 0; for (const z of receipts) { 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]; - if (bolt11) { - try { - sats += getSatoshisFromBolt11(bolt11); + if (!bolt11) continue; // no invoice β†’ unverifiable β†’ ignore + + try { + const invoiceSats = getSatoshisFromBolt11(bolt11); + if (invoiceSats > 0) { + sats += invoiceSats; count++; - continue; - } catch { - // ignore unparseable invoice } + } catch { + // Unparseable invoice β†’ ignore (do NOT fall back to the amount tag). } } return { sats, count }; diff --git a/src/hooks/useCrewZap.ts b/src/hooks/useCrewZap.ts index a3bb514..1867c21 100644 --- a/src/hooks/useCrewZap.ts +++ b/src/hooks/useCrewZap.ts @@ -3,6 +3,7 @@ import { useNostr } from '@nostrify/react'; import { useNWC } from '@/hooks/useNWCContext'; import { useWallet } from '@/hooks/useWallet'; import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress'; +import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import type { NostrMetadata } from '@nostrify/nostrify'; export type CrewZapStatus = 'pending' | 'resolving' | 'paying' | 'success' | 'skipped' | 'failed'; @@ -117,6 +118,9 @@ export function useCrewZap() { amountSats: amount, 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) { update(pubkey, { status: 'failed', error: err instanceof Error ? err.message : 'Invoice failed' }); continue; diff --git a/src/hooks/useFeaturedVerification.ts b/src/hooks/useFeaturedVerification.ts index 13ae6d9..f57e6ce 100644 --- a/src/hooks/useFeaturedVerification.ts +++ b/src/hooks/useFeaturedVerification.ts @@ -1,6 +1,8 @@ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; +import { getZapProviderPubkey } from '@/lib/lightningAddress'; +import { PRO_LIGHTNING_ADDRESS } from '@/lib/sasquatchPro'; import { isFeaturedActive } from '@/lib/types'; 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. * - * Layered / best-effort: - * - `verified`: a kind-9735 zap receipt paying the platform (p = platform pubkey) - * and referencing this entity's `a` coordinate was found on relays. Tamper-proof. - * - `claimed`: the event carries an unexpired `featured` tag but no matching zap - * receipt could be found (e.g. the Lightning provider doesn't publish receipts). - * This is the older self-attested trust level β€” still shown, but not verified. - * - `none`: not featured (or the featured window has expired). + * SECURITY: a receipt only counts as `verified` when it is AUTHORED by the + * platform's Lightning provider pubkey. Previously any kind-9735 with matching + * `#a`/`#p` tags was accepted β€” but anyone can publish such an event themselves, + * so a scammer could self-mint a "Verified payment" badge. Requiring the + * provider as author makes the receipt unforgeable. * - * The graceful fallback means Pro never silently breaks when the provider - * (currently minibits) doesn't support zaps. + * Layered / best-effort: + * - `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( entityCoord: string | undefined, @@ -36,11 +40,18 @@ export function useFeaturedVerification( const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); 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( [ { kinds: [9735], + authors: [providerPubkey], '#a': [entityCoord], '#p': PLATFORM_OWNER_PUBKEYS, limit: 20, @@ -49,7 +60,7 @@ export function useFeaturedVerification( { signal }, ); - // Any matching receipt authorizes the featured status. + // A provider-authored matching receipt authorizes featured status. if (receipts.length > 0) return 'verified'; } catch { // Relay error β€” fall through to claimed. diff --git a/src/hooks/useSasquatchPro.ts b/src/hooks/useSasquatchPro.ts index abce422..4bacc7d 100644 --- a/src/hooks/useSasquatchPro.ts +++ b/src/hooks/useSasquatchPro.ts @@ -1,20 +1,26 @@ /** * 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 - * 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). + * 1. OWNER: platform owners always have Pro. * - * 2. SELF-SIGNED RECEIPT (kind 32671): fallback for providers that don't emit - * zap receipts. Signed by the user, references the payment. Weaker (a - * technical user could self-issue), but never locks a paying user out. + * 2. ZAP RECEIPT (NIP-57, kind 9735) β€” the ONLY network-trusted source. To + * count, a receipt must ALL of: + * β€’ 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'; @@ -22,16 +28,23 @@ import { useQuery } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; import { useCurrentUser } from '@/hooks/useCurrentUser'; 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'; +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 { /** True if the user currently has active Pro access. */ isPro: boolean; /** Unix seconds when access expires (max across sources), or null. */ expiresAt: number | null; /** How access was granted. */ - source: 'owner' | 'zap' | 'self' | null; + source: 'owner' | 'zap' | null; isLoading: boolean; } @@ -39,26 +52,59 @@ function tagVal(tags: string[][], name: string): string | undefined { 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 { - if (event.pubkey !== userPubkey) return null; - const exp = Number(tagVal(event.tags, 'expiry')); - return Number.isFinite(exp) && exp > 0 ? exp : null; -} +/** + * Extract expiry from a zap receipt, but ONLY if it's a genuine, provider-issued + * proof of payment for THIS user to the platform. Returns null unless every + * check passes: + * β€’ 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'); if (!desc) return null; + + let zapReq: NostrEvent; try { - const 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; + zapReq = JSON.parse(desc) as NostrEvent; } catch { 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 { @@ -77,32 +123,27 @@ export function useSasquatchPro(): ProStatus { if (!pubkey) return { expiresAt: null, source: null }; const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); - // Fetch both sources in one round-trip: - // β€’ self-signed receipts authored by the user (kind 32671) - // β€’ zap receipts (9735) tagging the user as the zapper (#P) + // The ONLY network-trusted source is a zap receipt authored by the + // platform's Lightning provider. Resolve that provider pubkey first; if we + // 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( - [ - { kinds: [SASQUATCH_PRO_KIND], authors: [pubkey], limit: 10 }, - { kinds: [9735], '#P': [pubkey], limit: 50 }, - ], + [{ kinds: [9735], '#P': [pubkey], authors: [providerPubkey], limit: 50 }], { signal }, ); let best = 0; - let source: ProStatus['source'] = null; - for (const ev of events) { - if (ev.kind === SASQUATCH_PRO_KIND) { - const exp = expiryFromSelfReceipt(ev, pubkey); - 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'; } - } + const exp = expiryFromZapReceipt(ev, pubkey, providerPubkey); + if (exp && exp > best) best = exp; } - return { expiresAt: best || null, source: best ? source : null }; + return { expiresAt: best || null, source: best ? 'zap' : null }; }, }); diff --git a/src/hooks/useZaps.ts b/src/hooks/useZaps.ts index 9f4db39..c8f9420 100644 --- a/src/hooks/useZaps.ts +++ b/src/hooks/useZaps.ts @@ -8,6 +8,7 @@ import type { NWCConnection } from '@/hooks/useNWC'; import { nip57 } from 'nostr-tools'; import type { Event } from 'nostr-tools'; import { getSatoshisFromBolt11 } from '@/lib/bolt11'; +import { assertInvoiceAmount } from '@/lib/verifyInvoice'; import type { WebLNProvider } from '@webbtc/webln-types'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useNostr } from '@nostrify/react'; @@ -242,6 +243,12 @@ export function useZaps( 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 const currentNWCConnection = getActiveConnection(); diff --git a/src/lib/verifyInvoice.ts b/src/lib/verifyInvoice.ts new file mode 100644 index 0000000..24c65b5 --- /dev/null +++ b/src/lib/verifyInvoice.ts @@ -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.`, + ); + } +}