security(p2p): fix withdrawal BOLA, financial RLS exposure, admin dispute escrow

Critical/high audit remediation on the custodial P2P ledger. Auth model is
wallet-based; identity (citizen/visa) is cryptographically bound to a wallet
(People Chain tiki.citizenNft / active p2p_visa), which enables a correct fix.

- Withdrawal BOLA (CRITICAL): process-withdraw now requires a wallet-SIGNED
  challenge; server verifies signature (raw + <Bytes> forms), asserts the signer
  OWNS the identity, consumes a single-use nonce (replay), and derives user_id
  server-side — the client-supplied user_id is ignored. process-withdrawal (batch)
  now requires the service-role key (was: any bearer, incl. public anon key).
  request_withdraw is REVOKEd from anon/authenticated + service-role guarded.
- Financial RLS (HIGH): drop the blanket USING(true) SELECT on user_internal_balances,
  p2p_balance_transactions, p2p_deposit_withdraw_requests; lock p2p_user_payment_methods
  (IBAN PII) + p2p_fiat_disputes UPDATE to service_role; legitimate reads move behind
  scoped SECURITY DEFINER RPCs.
- Deposit integrity: verify-deposit now binds the on-chain sender to identity ownership
  before crediting.
- Admin dispute (CRITICAL fund-logic): DisputeResolutionPanel relabeled trades without
  moving escrow. New admin-signed resolve-dispute function + admin_resolve_dispute RPC
  moves escrow (release/refund/split) atomically with correct accounting (avoids the
  double-count in the legacy resolve_p2p_dispute). Client isAdmin documented as cosmetic.

DEPLOY RUNBOOK (gated; owner runs): 1) apply migrations 20260225/20260725* in order;
2) deploy edge functions process-withdraw, process-withdrawal, verify-deposit, resolve-dispute;
3) set edge secrets PEOPLE_RPC_ENDPOINT (+ optional ADMIN_WALLETS); 4) ship frontend.
Migrations + functions + frontend must go together or the app breaks.

KNOWN RESIDUAL (Round 2 — as severe as the withdrawal BOLA): release_/lock_/refund_
escrow_internal still have PUBLIC EXECUTE and the client calls release_escrow_internal
directly with the anon key from confirmPaymentReceived -> an anon caller can drain any
victim's LOCKED balance. Fix = a wallet-signed confirm-payment edge function (same
pattern as withdrawals) before revoking PUBLIC execute. Not yet fixed.
This commit is contained in:
2026-07-25 00:09:03 -07:00
parent 27b4057bd4
commit a8f41cd47f
13 changed files with 1178 additions and 96 deletions
+6
View File
@@ -151,6 +151,12 @@ export const ProtectedRoute: React.FC<ProtectedRouteProps> = ({
return <Navigate to="/login" replace />;
}
// NOTE: `isAdmin` here is a COSMETIC UX gate only (derived from a localStorage
// wallet and forgeable in DevTools). It prevents casual access to the admin UI
// but is NOT a security boundary. All privileged operations behind this route
// (e.g. dispute resolution) independently verify admin authority server-side
// via a wallet signature (resolve-dispute edge function). Never rely on this
// check alone to protect funds or state.
if (requireAdmin && !isAdmin) {
return (
<div className="min-h-screen flex items-center justify-center bg-gray-900">
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { supabase } from '@/lib/supabase';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useWallet } from '@/contexts/WalletContext';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -111,8 +113,35 @@ const CATEGORY_KEYS: Record<string, string> = {
other: 'dispute.categoryOther'
};
/**
* Canonical admin-action challenge. MUST be byte-identical to the server
* buildAdminChallenge (web/supabase/functions/_shared/identity-auth.ts).
*/
function buildAdminChallenge(p: {
action: string;
disputeId: string;
tradeId: string;
decision: string;
adminAddress: string;
timestamp: number;
nonce: string;
}): string {
return [
'Pezkuwi P2P Admin Action',
`action:${p.action}`,
`dispute:${p.disputeId}`,
`trade:${p.tradeId}`,
`decision:${p.decision}`,
`admin:${p.adminAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n');
}
export function DisputeResolutionPanel() {
const { t } = useTranslation();
const { selectedAccount } = usePezkuwi();
const { signMessage } = useWallet();
const [disputes, setDisputes] = useState<Dispute[]>([]);
const [loading, setLoading] = useState(true);
const [selectedDispute, setSelectedDispute] = useState<Dispute | null>(null);
@@ -202,28 +231,46 @@ export function DisputeResolutionPanel() {
return true;
});
// Claim dispute for review
const claimDispute = async (disputeId: string) => {
// Sign an admin-action challenge with the connected wallet. The server
// (resolve-dispute edge function) verifies the signature AND that the wallet
// is in the admin set — this is the real authorization, not the client flag.
const signAdminAction = async (
action: 'claim' | 'resolve',
disputeId: string,
tradeId: string,
decision: string
) => {
if (!selectedAccount?.address) throw new Error('Connect an admin wallet');
const timestamp = Date.now();
const nonce = `adm-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`;
const challenge = buildAdminChallenge({
action,
disputeId,
tradeId,
decision,
adminAddress: selectedAccount.address,
timestamp,
nonce,
});
const signature = await signMessage(challenge);
return { adminAddress: selectedAccount.address, signature, timestamp, nonce };
};
// Claim dispute for review (server-verified admin action)
const claimDispute = async (disputeId: string, tradeId: string) => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
const { error } = await supabase
.from('p2p_fiat_disputes')
.update({
status: 'under_review',
assigned_moderator_id: user.id,
assigned_at: new Date().toISOString()
})
.eq('id', disputeId);
if (error) throw error;
toast.info(t('dispute.claimSignPrompt', 'Sign to claim this dispute...'));
const auth = await signAdminAction('claim', disputeId, tradeId, '');
const { data, error } = await supabase.functions.invoke('resolve-dispute', {
body: { action: 'claim', disputeId, tradeId, ...auth },
});
if (error || !data?.success) throw new Error(data?.error || error?.message || 'Claim failed');
toast.success(t('dispute.claimedToast'));
fetchDisputes();
} catch (error) {
console.error('Error claiming dispute:', error);
toast.error(t('dispute.claimFailed'));
toast.error(error instanceof Error ? error.message : t('dispute.claimFailed'));
}
};
@@ -236,52 +283,26 @@ export function DisputeResolutionPanel() {
setSubmitting(true);
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) throw new Error('Not authenticated');
// Server-verified admin action. The edge function verifies the admin
// signature, then atomically MOVES ESCROW (release/refund/split) and
// updates trade + dispute status in one transaction. The client no longer
// relabels rows directly (which previously moved no funds).
toast.info(t('dispute.resolveSignPrompt', 'Sign to authorize the resolution...'));
const auth = await signAdminAction('resolve', selectedDispute.id, selectedDispute.trade_id, decision);
// Update dispute
const { error: disputeError } = await supabase
.from('p2p_fiat_disputes')
.update({
status: decision === 'escalate' ? 'escalated' : 'resolved',
const { data, error } = await supabase.functions.invoke('resolve-dispute', {
body: {
action: 'resolve',
disputeId: selectedDispute.id,
tradeId: selectedDispute.trade_id,
decision,
decision_reasoning: reasoning,
resolved_at: new Date().toISOString()
})
.eq('id', selectedDispute.id);
reasoning,
...auth,
},
});
if (disputeError) throw disputeError;
// Update trade status based on decision
if (decision !== 'escalate' && selectedDispute.trade) {
const tradeStatus = decision === 'release_to_buyer' ? 'completed' : 'refunded';
await supabase
.from('p2p_fiat_trades')
.update({ status: tradeStatus })
.eq('id', selectedDispute.trade_id);
}
// Create notifications for both parties
if (selectedDispute.trade) {
const notificationPromises = [
supabase.rpc('create_p2p_notification', {
p_user_id: selectedDispute.trade.seller_id,
p_type: 'dispute_resolved',
p_title: 'Dispute Resolved',
p_message: `The dispute has been resolved: ${t(DECISION_OPTION_KEYS.find(o => o.value === decision)?.labelKey || '')}`,
p_reference_type: 'dispute',
p_reference_id: selectedDispute.id
}),
supabase.rpc('create_p2p_notification', {
p_user_id: selectedDispute.trade.buyer_id,
p_type: 'dispute_resolved',
p_title: 'Dispute Resolved',
p_message: `The dispute has been resolved: ${t(DECISION_OPTION_KEYS.find(o => o.value === decision)?.labelKey || '')}`,
p_reference_type: 'dispute',
p_reference_id: selectedDispute.id
})
];
await Promise.all(notificationPromises);
if (error || !data?.success) {
throw new Error(data?.error || error?.message || 'Resolution failed');
}
toast.success(t('dispute.resolvedToast'));
@@ -292,7 +313,7 @@ export function DisputeResolutionPanel() {
fetchDisputes();
} catch (error) {
console.error('Error resolving dispute:', error);
toast.error(t('dispute.resolveFailed'));
toast.error(error instanceof Error ? error.message : t('dispute.resolveFailed'));
} finally {
setSubmitting(false);
}
@@ -475,7 +496,7 @@ export function DisputeResolutionPanel() {
{dispute.status === 'open' && (
<Button
size="sm"
onClick={() => claimDispute(dispute.id)}
onClick={() => claimDispute(dispute.id, dispute.trade_id)}
>
{t('dispute.claim')}
</Button>
+15 -3
View File
@@ -30,6 +30,7 @@ import {
Info
} from 'lucide-react';
import { usePezkuwi } from '@/contexts/PezkuwiContext';
import { useWallet } from '@/contexts/WalletContext';
import { useP2PIdentity } from '@/contexts/P2PIdentityContext';
import { toast } from 'sonner';
import {
@@ -52,7 +53,8 @@ type WithdrawStep = 'form' | 'confirm' | 'success';
export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps) {
const { t } = useTranslation();
const { selectedAccount } = usePezkuwi();
const { userId } = useP2PIdentity();
const { signMessage } = useWallet();
const { userId, identityId } = useP2PIdentity();
const [step, setStep] = useState<WithdrawStep>('form');
const [token, setToken] = useState<CryptoToken>('HEZ');
@@ -177,8 +179,18 @@ export function WithdrawModal({ isOpen, onClose, onSuccess }: WithdrawModalProps
try {
const withdrawAmount = parseFloat(amount);
if (!userId) throw new Error('Identity required');
const id = await requestWithdraw(userId, token, withdrawAmount, walletAddress);
if (!identityId) throw new Error('Identity required');
if (!selectedAccount?.address) throw new Error('Connect a wallet to withdraw');
// Authorization: sign a challenge with the wallet that owns the identity.
// The edge function derives user_id from identityId after verifying this.
const id = await requestWithdraw(
identityId,
token,
withdrawAmount,
walletAddress,
selectedAccount.address,
signMessage
);
setRequestId(id);
setStep('success');
onSuccess?.();
+11
View File
@@ -115,6 +115,17 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const checkAdminStatus = useCallback(async () => {
// ============================================================
// COSMETIC ONLY — NOT an authorization boundary.
// ------------------------------------------------------------
// This flag is derived from a localStorage wallet value and can be trivially
// forged in DevTools. It ONLY controls whether admin UI is shown. Every
// privileged operation MUST be verified server-side:
// - Dispute claim/resolve -> `resolve-dispute` edge function verifies a
// wallet SIGNATURE against the server-side admin wallet set before doing
// anything (see supabase/functions/resolve-dispute + _shared/identity-auth).
// Do NOT add fund-moving or state-changing logic gated solely on isAdmin.
// ============================================================
// Admin wallet whitelist (blockchain-based auth)
const ADMIN_WALLETS = [
'5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF', // Founder