From 165cb47c645b5ec9feef9c69c7ebe76d42b53fd3 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Sat, 25 Jul 2026 00:21:20 -0700 Subject: [PATCH] security(p2p): close escrow-RPC anon-drain (release/lock behind signed edge functions) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Round 2 — fixes the CRITICAL discovered during round 1 (as severe as the withdrawal BOLA): lock_/release_/refund_escrow_internal had default PUBLIC EXECUTE and the client called release_escrow_internal directly with the anon key, so anyone could drain any victim's LOCKED balance. - release_escrow_internal now reachable only via new signed confirm-payment edge function: verifies seller wallet signature (raw + ), asserts the wallet owns the seller identity, derived user_id == trade.seller_id, trade == payment_sent, single-use nonce, then release runs with the service role. confirmPaymentReceived now invokes confirm-payment (no more anon rpc). - lock_escrow_internal now behind new signed lock-escrow edge function (a caller can only lock its own balance; stops griefing a victim's balance). Removed a zero-amount no-op lock call. - Migration 20260725030000: REVOKE EXECUTE on lock_/release_/refund_escrow_internal from PUBLIC/anon/authenticated; GRANT to service_role only. refund has no direct client caller (service-role + admin_resolve_dispute only). - DEPLOY_RUNBOOK.md consolidates the ordered migrations, edge functions and secrets for both security rounds. Migration 20260725030000 must ship WITH the two new edge functions + frontend or offer-create/payment-release break. Remaining (non-fund, Round 2+ follow-up): read RPCs still key on a non-secret user_id (binding to a signed session would force a sign-prompt on every passive balance read — deferred); p2p_fiat_offers/trades/messages retain USING(true) (status labels move no funds now that all escrow movement is service-role-gated). --- shared/lib/p2p-fiat.ts | 214 ++++++++++-------- web/src/components/p2p/CreateAd.tsx | 8 +- web/src/pages/P2PTrade.tsx | 8 +- .../functions/_shared/identity-auth.ts | 44 ++++ .../functions/confirm-payment/index.ts | 159 +++++++++++++ web/supabase/functions/lock-escrow/index.ts | 115 ++++++++++ ...260725030000_lock_escrow_internal_rpcs.sql | 52 +++++ 7 files changed, 505 insertions(+), 95 deletions(-) create mode 100644 web/supabase/functions/confirm-payment/index.ts create mode 100644 web/supabase/functions/lock-escrow/index.ts create mode 100644 web/supabase/migrations/20260725030000_lock_escrow_internal_rpcs.sql diff --git a/shared/lib/p2p-fiat.ts b/shared/lib/p2p-fiat.ts index df758b14..243ab552 100644 --- a/shared/lib/p2p-fiat.ts +++ b/shared/lib/p2p-fiat.ts @@ -119,6 +119,8 @@ export interface P2PReputation { export interface CreateOfferParams { userId: string; + /** Citizen/visa identity id (needed to authorize the escrow lock) */ + identityId: string; sellerWallet: string; token: CryptoToken; amountCrypto: number; @@ -130,6 +132,8 @@ export interface CreateOfferParams { minOrderAmount?: number; maxOrderAmount?: number; adType?: 'buy' | 'sell'; + /** Signs a message with the wallet that owns `identityId` (signRaw) */ + signMessage: (message: string) => Promise; } export interface AcceptOfferParams { @@ -375,9 +379,54 @@ async function decryptPaymentDetails(encrypted: string): Promise { const { userId, + identityId, sellerWallet, token, amountCrypto, @@ -388,19 +437,42 @@ export async function createFiatOffer(params: CreateOfferParams): Promise { +export async function confirmPaymentReceived( + tradeId: string, + sellerIdentity: string, + signerAddress: string, + signMessage: (message: string) => Promise +): Promise { try { - if (!sellerId) throw new Error('Identity required for P2P trading'); + if (!sellerIdentity) throw new Error('Identity required for P2P trading'); + if (!signerAddress) throw new Error('Connect a wallet to release escrow'); - // 2. Get trade details - const { data: trade, error: tradeError } = await supabase - .from('p2p_fiat_trades') - .select('*') - .eq('id', tradeId) - .single(); - - if (tradeError) throw tradeError; - if (!trade) throw new Error('Trade not found'); - - // Verify caller is the seller - if (trade.seller_id !== sellerId) { - throw new Error('Only seller can confirm payment'); - } - - if (trade.status !== 'payment_sent') { - throw new Error('Payment has not been marked as sent'); - } - - // 3. Get offer to get token type - const { data: offer } = await supabase - .from('p2p_fiat_offers') - .select('token') - .eq('id', trade.offer_id) - .single(); - - if (!offer) throw new Error('Offer not found'); - - toast.info('Releasing crypto to buyer...'); - - // 4. Release escrow internally (NO blockchain tx!) - // This transfers from seller's locked_balance to buyer's available_balance - const { data: releaseResult, error: releaseError } = await supabase.rpc('release_escrow_internal', { - p_from_user_id: trade.seller_id, - p_to_user_id: trade.buyer_id, - p_token: offer.token, - p_amount: trade.crypto_amount, - p_reference_type: 'trade', - p_reference_id: tradeId + // Release is a money-OUT action: it is authorized ONLY via the confirm-payment + // edge function, which verifies the seller's wallet signature + identity + // ownership before calling release_escrow_internal with the service role. + // release_escrow_internal is no longer callable with the anon key. + const timestamp = Date.now(); + const nonce = `cp-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; + const challenge = buildConfirmPaymentChallenge({ + tradeId, + sellerIdentity, + signerAddress, + timestamp, + nonce, }); - if (releaseError) throw releaseError; + toast.info('Sign to release crypto to the buyer...'); + const signature = await signMessage(challenge); - // Parse result - const releaseResponse = typeof releaseResult === 'string' ? JSON.parse(releaseResult) : releaseResult; + const { data, error } = await supabase.functions.invoke('confirm-payment', { + body: { tradeId, sellerIdentity, signerAddress, signature, timestamp, nonce }, + }); - if (!releaseResponse.success) { - throw new Error(releaseResponse.error || 'Failed to release escrow'); + if (error) throw error; + if (!data?.success) { + throw new Error(data?.error || 'Failed to release escrow'); } - // 5. Update trade status - const { error: updateError } = await supabase - .from('p2p_fiat_trades') - .update({ - seller_confirmed_at: new Date().toISOString(), - escrow_released_at: new Date().toISOString(), - status: 'completed', - completed_at: new Date().toISOString() - // NOTE: No escrow_release_tx_hash - internal ledger doesn't use blockchain during trades - }) - .eq('id', tradeId); - - if (updateError) throw updateError; - - // 6. Update reputations - await updateReputations(trade.seller_id, trade.buyer_id, tradeId); - - // 7. Audit log - await logAction('trade', tradeId, 'confirm_payment', { - released_amount: trade.crypto_amount, - token: offer.token, - escrow_type: 'internal_ledger' - }, sellerId); + // Non-financial follow-ups (best effort). The escrow move + trade status + // change already happened atomically server-side. + try { + await updateReputations(data.sellerId, data.buyerId, tradeId); + await logAction('trade', tradeId, 'confirm_payment', { + released_amount: data.amount, + token: data.token, + escrow_type: 'internal_ledger' + }, data.sellerId); + } catch (followupErr) { + console.error('Post-release follow-up failed (non-critical):', followupErr); + } toast.success('Payment confirmed! Crypto released to buyer\'s balance.'); } catch (error: unknown) { diff --git a/web/src/components/p2p/CreateAd.tsx b/web/src/components/p2p/CreateAd.tsx index 738f15e5..0dfa972c 100644 --- a/web/src/components/p2p/CreateAd.tsx +++ b/web/src/components/p2p/CreateAd.tsx @@ -1,6 +1,7 @@ import React, { useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { usePezkuwi } from '@/contexts/PezkuwiContext'; +import { useWallet } from '@/contexts/WalletContext'; import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; @@ -25,7 +26,8 @@ interface CreateAdProps { export function CreateAd({ onAdCreated }: CreateAdProps) { const { t } = useTranslation(); const { selectedAccount } = usePezkuwi(); - const { userId } = useP2PIdentity(); + const { signMessage } = useWallet(); + const { userId, identityId } = useP2PIdentity(); const [paymentMethods, setPaymentMethods] = useState([]); const [selectedPaymentMethod, setSelectedPaymentMethod] = useState(null); @@ -77,7 +79,7 @@ export function CreateAd({ onAdCreated }: CreateAdProps) { }; const handleCreateAd = async () => { - if (!selectedAccount || !userId) { + if (!selectedAccount || !userId || !identityId) { toast.error(t('p2p.connectWalletAndLogin')); return; } @@ -128,6 +130,8 @@ export function CreateAd({ onAdCreated }: CreateAdProps) { try { await createFiatOffer({ userId, + identityId: identityId!, + signMessage, sellerWallet: selectedAccount.address, token, amountCrypto: cryptoAmt, diff --git a/web/src/pages/P2PTrade.tsx b/web/src/pages/P2PTrade.tsx index 50dcedf0..6bb23715 100644 --- a/web/src/pages/P2PTrade.tsx +++ b/web/src/pages/P2PTrade.tsx @@ -34,6 +34,7 @@ import { Star, } from 'lucide-react'; import { useP2PIdentity } from '@/contexts/P2PIdentityContext'; +import { useWallet } from '@/contexts/WalletContext'; import { toast } from 'sonner'; import { supabase } from '@/lib/supabase'; import { @@ -74,7 +75,8 @@ export default function P2PTrade() { const { tradeId } = useParams<{ tradeId: string }>(); const navigate = useNavigate(); const { t } = useTranslation(); - const { userId } = useP2PIdentity(); + const { userId, identityId, walletAddress } = useP2PIdentity(); + const { signMessage } = useWallet(); const [trade, setTrade] = useState(null); const [loading, setLoading] = useState(true); @@ -295,14 +297,14 @@ export default function P2PTrade() { // Handle release crypto const handleReleaseCrypto = async () => { - if (!trade || !userId) { + if (!trade || !userId || !identityId || !walletAddress) { toast.error(t('p2p.connectWalletAndLogin')); return; } setActionLoading(true); try { - await confirmPaymentReceived(trade.id, userId); + await confirmPaymentReceived(trade.id, identityId, walletAddress, signMessage); toast.success(t('p2pTrade.cryptoReleasedToast')); fetchTrade(); } catch (error) { diff --git a/web/supabase/functions/_shared/identity-auth.ts b/web/supabase/functions/_shared/identity-auth.ts index 4bf372d1..a58cc1cc 100644 --- a/web/supabase/functions/_shared/identity-auth.ts +++ b/web/supabase/functions/_shared/identity-auth.ts @@ -141,6 +141,50 @@ export function buildDepositChallenge(p: { ].join('\n') } +/** + * Canonical challenge for a seller confirming payment / releasing escrow. + * MUST be byte-identical to the client (shared/lib/p2p-fiat.ts). + */ +export function buildConfirmPaymentChallenge(p: { + tradeId: string + sellerIdentity: string + signerAddress: string + timestamp: number + nonce: string +}): string { + return [ + 'Pezkuwi P2P Confirm Payment', + `trade:${p.tradeId}`, + `identity:${p.sellerIdentity}`, + `signer:${p.signerAddress}`, + `timestamp:${p.timestamp}`, + `nonce:${p.nonce}`, + ].join('\n') +} + +/** + * Canonical challenge for locking one's OWN balance into escrow (offer create). + * MUST be byte-identical to the client (shared/lib/p2p-fiat.ts). + */ +export function buildLockEscrowChallenge(p: { + identityId: string + token: string + amount: number + signerAddress: string + timestamp: number + nonce: string +}): string { + return [ + 'Pezkuwi P2P Lock Escrow', + `identity:${p.identityId}`, + `token:${p.token}`, + `amount:${p.amount}`, + `signer:${p.signerAddress}`, + `timestamp:${p.timestamp}`, + `nonce:${p.nonce}`, + ].join('\n') +} + /** * Canonical challenge string for a privileged admin action (dispute claim / * resolve). MUST be byte-identical to the client (DisputeResolutionPanel). diff --git a/web/supabase/functions/confirm-payment/index.ts b/web/supabase/functions/confirm-payment/index.ts new file mode 100644 index 00000000..05c76616 --- /dev/null +++ b/web/supabase/functions/confirm-payment/index.ts @@ -0,0 +1,159 @@ +// confirm-payment Edge Function +// +// Seller-authorized escrow RELEASE (the money-OUT path that was previously +// callable by anyone holding the public anon key via rpc('release_escrow_internal')). +// +// Authorization chain: +// 1. Seller signs a canonical challenge with their wallet. +// 2. verifyWalletSignature (raw + ) proves control of `signerAddress`. +// 3. assertIdentityOwnedByWallet proves the wallet owns the claimed seller +// identity (citizen NFT / active visa). +// 4. The derived seller user_id MUST equal the trade's seller_id, and the +// trade MUST be in 'payment_sent'. +// 5. Single-use nonce (replay protection). +// 6. Only then release_escrow_internal(seller -> buyer) runs with the service +// role, atomically with the trade status update. + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' +import { createClient } from 'npm:@supabase/supabase-js@2' +import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36' +import { + identityToUUID, + verifyWalletSignature, + buildConfirmPaymentChallenge, + assertIdentityOwnedByWallet, + consumeNonce, + isFreshTimestamp, +} from '../_shared/identity-auth.ts' + +const ALLOWED_ORIGINS = [ + 'https://app.pezkuwichain.io', + 'https://www.pezkuwichain.io', + 'https://pezkuwichain.io', +] + +function getCorsHeaders(origin: string | null) { + const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0] + return { + 'Access-Control-Allow-Origin': allowedOrigin, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Credentials': 'true', + } +} + +const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io' +let peopleApiInstance: ApiPromise | null = null +async function getPeopleApi(): Promise { + if (peopleApiInstance && peopleApiInstance.isConnected) return peopleApiInstance + peopleApiInstance = await ApiPromise.create({ provider: new WsProvider(PEOPLE_RPC_ENDPOINT) }) + return peopleApiInstance +} + +interface Body { + tradeId?: string + sellerIdentity?: string + signerAddress?: string + signature?: string + timestamp?: number + nonce?: string +} + +serve(async (req) => { + const corsHeaders = getCorsHeaders(req.headers.get('Origin')) + const json = (status: number, obj: unknown) => + new Response(JSON.stringify(obj), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) + + if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders }) + + try { + const serviceClient = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!) + const { tradeId, sellerIdentity, signerAddress, signature, timestamp, nonce }: Body = await req.json() + + if (!tradeId || !sellerIdentity || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') { + return json(401, { success: false, error: 'Missing authorization fields' }) + } + if (!isFreshTimestamp(timestamp)) { + return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' }) + } + + // 1) Verify signature + const challenge = buildConfirmPaymentChallenge({ tradeId, sellerIdentity, signerAddress, timestamp, nonce }) + if (!(await verifyWalletSignature(challenge, signature, signerAddress))) { + return json(401, { success: false, error: 'Invalid signature' }) + } + + // 2) Prove the signer owns the claimed seller identity + const ownership = await assertIdentityOwnedByWallet(serviceClient, sellerIdentity, signerAddress, getPeopleApi) + if (!ownership.ok) { + return json(403, { success: false, error: ownership.error || 'Identity ownership verification failed' }) + } + const sellerUserId = await identityToUUID(sellerIdentity) + + // 3) Load trade and enforce it belongs to this seller and is releasable + const { data: trade, error: tradeErr } = await serviceClient + .from('p2p_fiat_trades') + .select('id, seller_id, buyer_id, offer_id, crypto_amount, status') + .eq('id', tradeId) + .single() + if (tradeErr || !trade) return json(404, { success: false, error: 'Trade not found' }) + if (trade.seller_id !== sellerUserId) { + return json(403, { success: false, error: 'Only the trade seller can release this escrow' }) + } + if (trade.status !== 'payment_sent') { + return json(400, { success: false, error: `Trade is not awaiting confirmation (status: ${trade.status})` }) + } + + // 4) Resolve token from offer + const { data: offer } = await serviceClient + .from('p2p_fiat_offers') + .select('token') + .eq('id', trade.offer_id) + .single() + if (!offer?.token) return json(400, { success: false, error: 'Offer/token not found' }) + + // 5) Consume nonce (single use) + if (!(await consumeNonce(serviceClient, nonce, 'confirm_payment'))) { + return json(401, { success: false, error: 'Authorization already used (replay detected)' }) + } + + // 6) Release escrow (service role) + mark trade completed + const { data: releaseRes, error: releaseErr } = await serviceClient.rpc('release_escrow_internal', { + p_from_user_id: trade.seller_id, + p_to_user_id: trade.buyer_id, + p_token: offer.token, + p_amount: trade.crypto_amount, + p_reference_type: 'trade', + p_reference_id: tradeId, + }) + if (releaseErr) return json(500, { success: false, error: releaseErr.message || 'Release failed' }) + const parsed = typeof releaseRes === 'string' ? JSON.parse(releaseRes) : releaseRes + if (!parsed?.success) return json(400, { success: false, error: parsed?.error || 'Release failed' }) + + const nowIso = new Date().toISOString() + const { error: updErr } = await serviceClient + .from('p2p_fiat_trades') + .update({ + seller_confirmed_at: nowIso, + escrow_released_at: nowIso, + status: 'completed', + completed_at: nowIso, + }) + .eq('id', tradeId) + if (updErr) { + // Funds released but status update failed — log for reconciliation. + console.error('Trade status update failed after release:', updErr) + } + + return json(200, { + success: true, + tradeId, + sellerId: trade.seller_id, + buyerId: trade.buyer_id, + token: offer.token, + amount: trade.crypto_amount, + }) + } catch (error) { + console.error('confirm-payment error:', error) + return json(500, { success: false, error: 'Internal server error' }) + } +}) diff --git a/web/supabase/functions/lock-escrow/index.ts b/web/supabase/functions/lock-escrow/index.ts new file mode 100644 index 00000000..115ed7d0 --- /dev/null +++ b/web/supabase/functions/lock-escrow/index.ts @@ -0,0 +1,115 @@ +// lock-escrow Edge Function +// +// Locks a caller's OWN available balance into escrow (used by offer creation). +// lock_escrow_internal only ever moves the caller's own funds available->locked, +// so it is not a theft vector — but an anon caller could previously lock a +// VICTIM's balance (griefing/DoS on their funds). This gates it so a caller can +// only ever lock the balance of the identity their wallet owns. +// +// Authorization: wallet signature -> identity ownership -> derived user_id -> +// lock_escrow_internal with the service role. + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' +import { createClient } from 'npm:@supabase/supabase-js@2' +import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36' +import { + identityToUUID, + verifyWalletSignature, + buildLockEscrowChallenge, + assertIdentityOwnedByWallet, + consumeNonce, + isFreshTimestamp, +} from '../_shared/identity-auth.ts' + +const ALLOWED_ORIGINS = [ + 'https://app.pezkuwichain.io', + 'https://www.pezkuwichain.io', + 'https://pezkuwichain.io', +] + +function getCorsHeaders(origin: string | null) { + const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0] + return { + 'Access-Control-Allow-Origin': allowedOrigin, + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Credentials': 'true', + } +} + +const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io' +let peopleApiInstance: ApiPromise | null = null +async function getPeopleApi(): Promise { + if (peopleApiInstance && peopleApiInstance.isConnected) return peopleApiInstance + peopleApiInstance = await ApiPromise.create({ provider: new WsProvider(PEOPLE_RPC_ENDPOINT) }) + return peopleApiInstance +} + +interface Body { + identityId?: string + token?: 'HEZ' | 'PEZ' + amount?: number + signerAddress?: string + signature?: string + timestamp?: number + nonce?: string +} + +serve(async (req) => { + const corsHeaders = getCorsHeaders(req.headers.get('Origin')) + const json = (status: number, obj: unknown) => + new Response(JSON.stringify(obj), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } }) + + if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders }) + + try { + const serviceClient = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!) + const { identityId, token, amount, signerAddress, signature, timestamp, nonce }: Body = await req.json() + + if (!identityId || !token || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') { + return json(401, { success: false, error: 'Missing authorization fields' }) + } + if (!['HEZ', 'PEZ'].includes(token)) { + return json(400, { success: false, error: 'Invalid token' }) + } + const amt = Number(amount) + if (!Number.isFinite(amt) || amt <= 0) { + return json(400, { success: false, error: 'Amount must be greater than 0' }) + } + if (!isFreshTimestamp(timestamp)) { + return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' }) + } + + // 1) Verify signature over the exact lock intent + const challenge = buildLockEscrowChallenge({ identityId, token, amount: amt, signerAddress, timestamp, nonce }) + if (!(await verifyWalletSignature(challenge, signature, signerAddress))) { + return json(401, { success: false, error: 'Invalid signature' }) + } + + // 2) Prove the signer owns the identity whose balance is being locked + const ownership = await assertIdentityOwnedByWallet(serviceClient, identityId, signerAddress, getPeopleApi) + if (!ownership.ok) { + return json(403, { success: false, error: ownership.error || 'Identity ownership verification failed' }) + } + const userId = await identityToUUID(identityId) + + // 3) Replay protection + if (!(await consumeNonce(serviceClient, nonce, 'lock_escrow'))) { + return json(401, { success: false, error: 'Authorization already used (replay detected)' }) + } + + // 4) Lock the caller's OWN balance (service role) + const { data: lockRes, error: lockErr } = await serviceClient.rpc('lock_escrow_internal', { + p_user_id: userId, + p_token: token, + p_amount: amt, + }) + if (lockErr) return json(500, { success: false, error: lockErr.message || 'Lock failed' }) + const parsed = typeof lockRes === 'string' ? JSON.parse(lockRes) : lockRes + if (!parsed?.success) return json(400, { success: false, error: parsed?.error || 'Lock failed' }) + + return json(200, { success: true, userId, token, amount: amt, ...parsed }) + } catch (error) { + console.error('lock-escrow error:', error) + return json(500, { success: false, error: 'Internal server error' }) + } +}) diff --git a/web/supabase/migrations/20260725030000_lock_escrow_internal_rpcs.sql b/web/supabase/migrations/20260725030000_lock_escrow_internal_rpcs.sql new file mode 100644 index 00000000..29cdae25 --- /dev/null +++ b/web/supabase/migrations/20260725030000_lock_escrow_internal_rpcs.sql @@ -0,0 +1,52 @@ +-- ===================================================== +-- Migration: Lock down internal-ledger escrow RPCs +-- Date: 2026-07-25 +-- ===================================================== +-- +-- Fix for the CRITICAL drain: lock_/release_/refund_escrow_internal (migration +-- 014) had default PUBLIC EXECUTE, and the client called release_escrow_internal +-- directly with the public anon key. An anon caller could therefore run +-- release_escrow_internal(victim, attacker, token, amount) +-- and drain any victim's LOCKED balance. +-- +-- These functions now execute ONLY as the service role. Every legitimate caller +-- goes through a service-role edge function that first verifies a wallet +-- signature + identity ownership: +-- * release -> confirm-payment edge function (seller-authorized) +-- * lock -> lock-escrow edge function (owner-authorized) +-- * refund -> process-withdraw (on failed payout) + admin_resolve_dispute +-- (dispute refunds/splits). No direct client caller. +-- +-- SECURITY DEFINER functions that call these internally (admin_resolve_dispute, +-- cancel_expired_trades, process_deposit) are unaffected: EXECUTE for an inner +-- call is checked against the function OWNER, not the session role. + +-- release_escrow_internal(p_from_user_id, p_to_user_id, p_token, p_amount, p_reference_type, p_reference_id) +REVOKE EXECUTE ON FUNCTION release_escrow_internal(UUID, UUID, TEXT, DECIMAL, TEXT, UUID) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION release_escrow_internal(UUID, UUID, TEXT, DECIMAL, TEXT, UUID) + TO service_role; + +-- refund_escrow_internal(p_user_id, p_token, p_amount, p_reference_type, p_reference_id) +REVOKE EXECUTE ON FUNCTION refund_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION refund_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID) + TO service_role; + +-- lock_escrow_internal(p_user_id, p_token, p_amount, p_reference_type, p_reference_id) +REVOKE EXECUTE ON FUNCTION lock_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID) + FROM PUBLIC, anon, authenticated; +GRANT EXECUTE ON FUNCTION lock_escrow_internal(UUID, TEXT, DECIMAL, TEXT, UUID) + TO service_role; + +COMMENT ON FUNCTION release_escrow_internal IS +'Release escrow seller->buyer. SERVICE ROLE ONLY — invoked by the confirm-payment +edge function after verifying the seller''s wallet signature + identity ownership.'; + +COMMENT ON FUNCTION refund_escrow_internal IS +'Refund locked escrow to its owner. SERVICE ROLE ONLY — invoked by process-withdraw +(failed payout) and admin_resolve_dispute (dispute refund/split).'; + +COMMENT ON FUNCTION lock_escrow_internal IS +'Lock a user''s own available balance into escrow. SERVICE ROLE ONLY — invoked by +the lock-escrow edge function after verifying the caller owns that identity.';