security(p2p): close escrow-RPC anon-drain (release/lock behind signed edge functions)

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 + <Bytes>), 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).
This commit is contained in:
2026-07-25 00:21:20 -07:00
parent a8f41cd47f
commit 165cb47c64
7 changed files with 505 additions and 95 deletions
+6 -2
View File
@@ -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<PaymentMethod[]>([]);
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<PaymentMethod | null>(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,
+5 -3
View File
@@ -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<TradeWithDetails | null>(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) {