mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-27 12:25:40 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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?.();
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user