diff --git a/shared/lib/p2p-fiat.ts b/shared/lib/p2p-fiat.ts index f0de9164..df758b14 100644 --- a/shared/lib/p2p-fiat.ts +++ b/shared/lib/p2p-fiat.ts @@ -1023,18 +1023,53 @@ export async function getInternalBalance(userId: string, token: CryptoToken): Pr } /** - * Request a withdrawal from internal balance to external wallet - * Calls the process-withdraw edge function which handles the full flow: - * limit check → lock balance → blockchain TX → complete withdrawal + * Canonical withdrawal challenge string. MUST be byte-identical to the server's + * buildWithdrawChallenge (web/supabase/functions/_shared/identity-auth.ts). + */ +export function buildWithdrawChallenge(p: { + identityId: string; + token: string; + amount: number; + destination: string; + signerAddress: string; + timestamp: number; + nonce: string; +}): string { + return [ + 'Pezkuwi P2P Withdrawal', + `identity:${p.identityId}`, + `token:${p.token}`, + `amount:${p.amount}`, + `destination:${p.destination}`, + `signer:${p.signerAddress}`, + `timestamp:${p.timestamp}`, + `nonce:${p.nonce}`, + ].join('\n'); +} + +/** + * Request a withdrawal from internal balance to external wallet. + * + * AUTHORIZATION: the caller must prove ownership of the P2P identity by signing + * a challenge with the wallet that owns it (citizen NFT / visa). The edge + * function derives the user_id server-side from `identityId` after verifying the + * signature and identity ownership — a client-supplied user_id is never trusted. + * + * @param identityId Citizen number (#42--<6d>) or visa number (V-XXXXXX) + * @param signerAddress The connected wallet (must own the identity) + * @param signMessage Signs a message with the connected wallet (signRaw) */ export async function requestWithdraw( - userId: string, + identityId: string, token: CryptoToken, amount: number, - walletAddress: string + walletAddress: string, + signerAddress: string, + signMessage: (message: string) => Promise ): Promise { try { - if (!userId) throw new Error('Identity required for P2P trading'); + if (!identityId) throw new Error('Identity required for P2P trading'); + if (!signerAddress) throw new Error('Connect a wallet to withdraw'); // Validate amount if (amount <= 0) throw new Error('Amount must be greater than 0'); @@ -1044,10 +1079,26 @@ export async function requestWithdraw( throw new Error('Invalid wallet address'); } + // Build + sign the authorization challenge binding the full withdrawal intent. + const timestamp = Date.now(); + const nonce = `wd-${timestamp}-${Math.random().toString(36).slice(2)}-${Math.random().toString(36).slice(2)}`; + const challenge = buildWithdrawChallenge({ + identityId, + token, + amount, + destination: walletAddress, + signerAddress, + timestamp, + nonce, + }); + + toast.info('Sign the request in your wallet to authorize the withdrawal...'); + const signature = await signMessage(challenge); + toast.info('Processing withdrawal...'); const { data, error, response } = await supabase.functions.invoke('process-withdraw', { - body: { userId, token, amount, walletAddress } + body: { identityId, token, amount, walletAddress, signerAddress, signature, timestamp, nonce } }); // Supabase client wraps non-2xx as generic FunctionsHttpError (data=null). @@ -1085,15 +1136,14 @@ export async function getDepositWithdrawHistory(userId: string): Promise { +export async function getBalanceTransactionHistory(userId: string, limit: number = 50): Promise { try { - const { data, error } = await supabase - .from('p2p_balance_transactions') - .select('*') - .order('created_at', { ascending: false }) - .limit(limit); + if (!userId) throw new Error('Identity required for P2P trading'); + + // Scoped SECURITY DEFINER RPC — returns only this user's ledger rows + // (the previous unfiltered SELECT * exposed every user's transactions). + const { data, error } = await supabase.rpc('get_user_balance_transactions', { + p_user_id: userId, + p_limit: limit, + }); if (error) throw error; - return data || []; + return (data as BalanceTransaction[]) || []; } catch (error) { console.error('Get balance transaction history error:', error); return []; diff --git a/web/src/components/ProtectedRoute.tsx b/web/src/components/ProtectedRoute.tsx index 47a09d7e..5c89f7ca 100644 --- a/web/src/components/ProtectedRoute.tsx +++ b/web/src/components/ProtectedRoute.tsx @@ -151,6 +151,12 @@ export const ProtectedRoute: React.FC = ({ return ; } + // 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 (
diff --git a/web/src/components/admin/DisputeResolutionPanel.tsx b/web/src/components/admin/DisputeResolutionPanel.tsx index 72009969..851e9203 100644 --- a/web/src/components/admin/DisputeResolutionPanel.tsx +++ b/web/src/components/admin/DisputeResolutionPanel.tsx @@ -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 = { 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([]); const [loading, setLoading] = useState(true); const [selectedDispute, setSelectedDispute] = useState(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' && ( diff --git a/web/src/components/p2p/WithdrawModal.tsx b/web/src/components/p2p/WithdrawModal.tsx index 5ad0c0a5..9f8b533e 100644 --- a/web/src/components/p2p/WithdrawModal.tsx +++ b/web/src/components/p2p/WithdrawModal.tsx @@ -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('form'); const [token, setToken] = useState('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?.(); diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 955393b9..09d9ec3e 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -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 diff --git a/web/supabase/functions/_shared/identity-auth.ts b/web/supabase/functions/_shared/identity-auth.ts new file mode 100644 index 00000000..4bf372d1 --- /dev/null +++ b/web/supabase/functions/_shared/identity-auth.ts @@ -0,0 +1,283 @@ +// _shared/identity-auth.ts +// +// Server-side identity + wallet-signature authorization for fund-moving edge +// functions (withdrawals, deposits). This is the security boundary that binds +// an incoming request to a real, cryptographically-proven principal: +// +// 1. The caller signs a canonical challenge with their Substrate wallet. +// 2. We verify that signature server-side (sr25519/ed25519) -> proves control +// of `signerAddress`. +// 3. We prove `signerAddress` OWNS the claimed identity (citizen number / visa): +// - citizen -> People Chain `tiki.citizenNft(signerAddress)` + the +// deterministic 6-digit derivation must match the claimed number. +// - visa -> `p2p_visa` row (wallet_address == signerAddress, active). +// 4. Only then do we derive `userId = identityToUUID(identityId)` SERVER-SIDE. +// +// The client-supplied `userId` is NEVER trusted for authorization. Every +// caller can only ever act on the balance of the identity their wallet owns. + +import { signatureVerify, cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25' +import { stringToU8a, u8aWrapBytes } from 'npm:@pezkuwi/util@14.0.25' +import type { ApiPromise } from 'npm:@pezkuwi/api@16.5.36' +import type { SupabaseClient } from 'npm:@supabase/supabase-js@2' + +// Max age of a signed challenge (replay window). Nonce dedup (see caller) +// provides exact-once semantics; this bounds how long a captured signature +// could even be presented. +export const CHALLENGE_MAX_AGE_MS = 5 * 60 * 1000 // 5 minutes + +// UUID v5 namespace (RFC 4122 DNS namespace) — MUST match shared/lib/identity.ts +const UUID_V5_NAMESPACE = '6ba7b810-9dad-11d1-80b4-00c04fd430c8' + +/** + * Deterministic UUID v5 from a citizen/visa number. Identical algorithm to + * shared/lib/identity.ts (client) and verify-deposit — the value MUST match so + * balances resolve to the same row everywhere. + */ +export async function identityToUUID(identityId: string): Promise { + const namespaceHex = UUID_V5_NAMESPACE.replace(/-/g, '') + const namespaceBytes = new Uint8Array(16) + for (let i = 0; i < 16; i++) { + namespaceBytes[i] = parseInt(namespaceHex.substr(i * 2, 2), 16) + } + const nameBytes = new TextEncoder().encode(identityId) + const combined = new Uint8Array(namespaceBytes.length + nameBytes.length) + combined.set(namespaceBytes) + combined.set(nameBytes, namespaceBytes.length) + + const hashBuffer = await crypto.subtle.digest('SHA-1', combined) + const h = new Uint8Array(hashBuffer) + h[6] = (h[6] & 0x0f) | 0x50 + h[8] = (h[8] & 0x3f) | 0x80 + const hex = Array.from(h.slice(0, 16)).map(b => b.toString(16).padStart(2, '0')).join('') + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}` +} + +/** + * Deterministic 6-digit citizen number derivation. Identical algorithm to + * shared/lib/tiki.ts generateCitizenNumber — used to bind a citizen number to + * the wallet that owns the NFT. + */ +export function generateCitizenNumber(ownerAddress: string, collectionId: number, itemId: number): string { + let hash = 0 + for (let i = 0; i < ownerAddress.length; i++) { + hash = ((hash << 5) - hash) + ownerAddress.charCodeAt(i) + hash = hash & hash + } + hash += collectionId * 1000 + itemId + hash = Math.abs(hash) + return (hash % 1000000).toString().padStart(6, '0') +} + +/** + * Verify a wallet signature over `message`. Handles both the raw-bytes form and + * the `...` wrapped form that Polkadot-style extensions apply to + * signRaw payloads, so extension, WalletConnect and native signers all verify. + */ +export async function verifyWalletSignature( + message: string, + signature: string, + signerAddress: string +): Promise { + try { + await cryptoWaitReady() + const raw = stringToU8a(message) + const wrapped = u8aWrapBytes(message) + for (const candidate of [raw, wrapped]) { + const res = signatureVerify(candidate, signature, signerAddress) + if (res.isValid) return true + } + return false + } catch (_e) { + return false + } +} + +/** + * Canonical challenge string for a withdrawal. MUST be byte-identical to what + * the client builds and signs (see shared/lib/p2p-fiat.ts buildWithdrawChallenge). + */ +export function buildWithdrawChallenge(p: { + identityId: string + token: string + amount: number + destination: string + signerAddress: string + timestamp: number + nonce: string +}): string { + return [ + 'Pezkuwi P2P Withdrawal', + `identity:${p.identityId}`, + `token:${p.token}`, + `amount:${p.amount}`, + `destination:${p.destination}`, + `signer:${p.signerAddress}`, + `timestamp:${p.timestamp}`, + `nonce:${p.nonce}`, + ].join('\n') +} + +/** + * Canonical challenge string for a deposit-credit. Binds the on-chain tx and the + * identity being credited to the wallet that signs. + */ +export function buildDepositChallenge(p: { + identityId: string + token: string + txHash: string + signerAddress: string + timestamp: number + nonce: string +}): string { + return [ + 'Pezkuwi P2P Deposit', + `identity:${p.identityId}`, + `token:${p.token}`, + `tx:${p.txHash}`, + `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). + */ +export 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 interface OwnershipResult { + ok: boolean + error?: string + /** 'citizen' | 'visa' */ + kind?: 'citizen' | 'visa' +} + +/** + * Prove that `signerAddress` owns `identityId`. + * + * - Visa numbers ("V-XXXXXX"): must have an active p2p_visa row bound to the + * signer's wallet_address. + * - Citizen numbers ("#42--<6digit>"): the People Chain must report that + * signerAddress owns the citizen NFT with that item id, and the deterministic + * 6-digit derivation for (signerAddress, 42, itemId) must equal the claimed + * 6-digit — i.e. the number is cryptographically tied to the wallet. + * + * `peopleApi` may be lazily created by the caller; it is only required for + * citizen identities. + */ +export async function assertIdentityOwnedByWallet( + serviceClient: SupabaseClient, + identityId: string, + signerAddress: string, + getPeopleApi: () => Promise +): Promise { + if (!identityId || !signerAddress) { + return { ok: false, error: 'Missing identity or signer' } + } + + // ---- Visa identity ---- + if (identityId.startsWith('V-')) { + const { data: visa, error } = await serviceClient + .from('p2p_visa') + .select('visa_number, wallet_address, status') + .eq('visa_number', identityId) + .eq('wallet_address', signerAddress) + .eq('status', 'active') + .maybeSingle() + + if (error) return { ok: false, error: 'Visa lookup failed' } + if (!visa) return { ok: false, error: 'Signing wallet does not own this visa identity' } + return { ok: true, kind: 'visa' } + } + + // ---- Citizen identity: "#--<6digit>" ---- + const clean = identityId.trim().replace('#', '') + const parts = clean.split('-') + if (parts.length !== 3) { + return { ok: false, error: 'Invalid citizen identity format' } + } + const collectionId = parseInt(parts[0], 10) + const itemId = parseInt(parts[1], 10) + const providedSixDigit = parts[2] + if (isNaN(collectionId) || isNaN(itemId) || providedSixDigit.length !== 6) { + return { ok: false, error: 'Invalid citizen identity format' } + } + if (collectionId !== 42) { + return { ok: false, error: 'Invalid citizen collection' } + } + + let peopleApi: ApiPromise + try { + peopleApi = await getPeopleApi() + } catch (_e) { + return { ok: false, error: 'People Chain unavailable for identity verification' } + } + + try { + if (!peopleApi.query?.tiki?.citizenNft) { + return { ok: false, error: 'Citizen NFT pallet unavailable' } + } + const res: any = await peopleApi.query.tiki.citizenNft(signerAddress) + if (res.isEmpty || (res.isSome === false && typeof res.isSome === 'boolean')) { + return { ok: false, error: 'Signing wallet owns no citizen NFT' } + } + const actualItemId = res.isSome ? res.unwrap().toNumber() : (typeof res.toNumber === 'function' ? res.toNumber() : null) + if (actualItemId === null || actualItemId !== itemId) { + return { ok: false, error: 'Citizen NFT does not match signing wallet' } + } + const expected = generateCitizenNumber(signerAddress, collectionId, itemId) + if (expected !== providedSixDigit) { + return { ok: false, error: 'Citizen number does not match signing wallet' } + } + return { ok: true, kind: 'citizen' } + } catch (_e) { + return { ok: false, error: 'Citizen identity verification failed' } + } +} + +/** + * Atomically record a used challenge nonce for replay protection. Returns false + * if the nonce was already used (replay) — caller MUST reject in that case. + * Requires table public.p2p_challenge_nonces(nonce text primary key, ...). + */ +export async function consumeNonce( + serviceClient: SupabaseClient, + nonce: string, + purpose: string +): Promise { + if (!nonce || nonce.length < 8) return false + const { error } = await serviceClient + .from('p2p_challenge_nonces') + .insert({ nonce, purpose }) + // Unique violation => already used => replay. + if (error) return false + return true +} + +/** Basic timestamp freshness check for a signed challenge. */ +export function isFreshTimestamp(timestamp: number): boolean { + if (!Number.isFinite(timestamp)) return false + const age = Date.now() - timestamp + return age >= -60_000 && age <= CHALLENGE_MAX_AGE_MS // allow 60s clock skew +} diff --git a/web/supabase/functions/process-withdraw/index.ts b/web/supabase/functions/process-withdraw/index.ts index 0cf7dc3c..0b8c322f 100644 --- a/web/supabase/functions/process-withdraw/index.ts +++ b/web/supabase/functions/process-withdraw/index.ts @@ -6,6 +6,14 @@ import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' import { createClient } from 'npm:@supabase/supabase-js@2' import { ApiPromise, WsProvider, Keyring } from 'npm:@pezkuwi/api@16.5.36' import { cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25' +import { + identityToUUID, + verifyWalletSignature, + buildWithdrawChallenge, + assertIdentityOwnedByWallet, + consumeNonce, + isFreshTimestamp, +} from '../_shared/identity-auth.ts' // Allowed origins for CORS const ALLOWED_ORIGINS = [ @@ -35,6 +43,9 @@ const DECIMALS = 12 // PEZ asset ID const PEZ_ASSET_ID = 1 +// People Chain endpoint — required to verify citizen NFT ownership server-side +const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io' + // Minimum withdrawal amounts const MIN_WITHDRAW = { HEZ: 1, @@ -49,10 +60,19 @@ const WITHDRAW_FEE = { interface WithdrawRequest { requestId?: string // If processing specific request - userId: string // Identity-based UUID (from citizen/visa number) + + // ---- Authorization (all required) ---- + // The caller proves control of a wallet that OWNS `identityId`. The user_id + // is derived server-side from identityId; a client-supplied user_id is IGNORED. + identityId?: string // Citizen number (#42--<6d>) or visa (V-XXXXXX) + signerAddress?: string // Wallet that signed the challenge + signature?: string // Signature over the canonical withdraw challenge + timestamp?: number // ms epoch, must be fresh + nonce?: string // single-use, replay-protected + token?: 'HEZ' | 'PEZ' amount?: number - walletAddress?: string + walletAddress?: string // Withdrawal destination (bound into the signature) } // Cache API connection @@ -68,6 +88,18 @@ async function getApi(): Promise { return apiInstance } +// Cache People Chain connection (citizen NFT ownership verification) +let peopleApiInstance: ApiPromise | null = null + +async function getPeopleApi(): Promise { + if (peopleApiInstance && peopleApiInstance.isConnected) { + return peopleApiInstance + } + const provider = new WsProvider(PEOPLE_RPC_ENDPOINT) + peopleApiInstance = await ApiPromise.create({ provider }) + return peopleApiInstance +} + // Send tokens from hot wallet async function sendTokens( api: ApiPromise, @@ -190,7 +222,8 @@ serve(async (req) => { } try { - // Get authorization header + // Get authorization header (transport-level only — NOT the authz boundary). + // Real authorization is the wallet signature + identity-ownership proof below. const authHeader = req.headers.get('Authorization') if (!authHeader) { return new Response( @@ -218,16 +251,74 @@ serve(async (req) => { // Parse request body const body: WithdrawRequest = await req.json() - const { userId } = body + const { identityId, signerAddress, signature, timestamp, nonce } = body let { requestId, token, amount, walletAddress } = body - if (!userId) { + // ===================================================== + // OBJECT-LEVEL AUTHORIZATION (the security boundary) + // ===================================================== + // 1) Require a complete signed challenge. + if (!identityId || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') { return new Response( - JSON.stringify({ success: false, error: 'Missing required field: userId' }), - { status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + JSON.stringify({ success: false, error: 'Missing authorization (identityId, signerAddress, signature, timestamp, nonce required)' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } ) } + // 2) The signature covers the full withdrawal intent, incl. destination and + // amount. Determine the values that will be bound into the challenge. + // - Mode 1 (requestId): destination/amount/token come from the stored + // request; the client still signs them (fetched before submit). + // - Mode 2: destination/amount/token come from the (signed) body. + if (!isFreshTimestamp(timestamp)) { + return new Response( + JSON.stringify({ success: false, error: 'Authorization challenge expired. Please retry.' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // 3) Verify the wallet signature over the canonical challenge. For Mode 1 the + // signed destination/amount/token are supplied alongside requestId and are + // re-checked against the stored request after lookup. + const challenge = buildWithdrawChallenge({ + identityId, + token: String(token ?? ''), + amount: Number(amount ?? 0), + destination: String(walletAddress ?? ''), + signerAddress, + timestamp, + nonce, + }) + const sigOk = await verifyWalletSignature(challenge, signature, signerAddress) + if (!sigOk) { + return new Response( + JSON.stringify({ success: false, error: 'Invalid signature' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // 4) Prove the signing wallet OWNS the claimed identity (citizen NFT / visa). + const ownership = await assertIdentityOwnedByWallet(serviceClient, identityId, signerAddress, getPeopleApi) + if (!ownership.ok) { + return new Response( + JSON.stringify({ success: false, error: ownership.error || 'Identity ownership verification failed' }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // 5) Consume the nonce (single-use) to block replay of a captured signature. + const nonceOk = await consumeNonce(serviceClient, nonce, 'withdraw') + if (!nonceOk) { + return new Response( + JSON.stringify({ success: false, error: 'Authorization already used (replay detected)' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + + // 6) Derive user_id SERVER-SIDE from the verified identity. Any client-supplied + // user_id is intentionally ignored. + const userId = await identityToUUID(identityId) + // Mode 1: Process existing request by ID if (requestId) { const { data: request, error: reqError } = await serviceClient @@ -246,8 +337,23 @@ serve(async (req) => { ) } + // The signed challenge (built from body values) must match the stored + // request, so the signature authorizes exactly this withdrawal. + const storedAmount = parseFloat(request.amount) + const bodyAmount = Number(amount ?? 0) + if ( + String(token ?? '') !== String(request.token) || + String(walletAddress ?? '') !== String(request.wallet_address) || + Math.abs(bodyAmount - storedAmount) > 1e-9 + ) { + return new Response( + JSON.stringify({ success: false, error: 'Signed withdrawal does not match the stored request' }), + { status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + token = request.token as 'HEZ' | 'PEZ' - amount = parseFloat(request.amount) + amount = storedAmount walletAddress = request.wallet_address } // Mode 2: Create new withdrawal request diff --git a/web/supabase/functions/process-withdrawal/index.ts b/web/supabase/functions/process-withdrawal/index.ts index 1176c15f..7a67a843 100644 --- a/web/supabase/functions/process-withdrawal/index.ts +++ b/web/supabase/functions/process-withdrawal/index.ts @@ -199,11 +199,16 @@ serve(async (req: Request) => { } try { - // Verify authorization (should be called with service role key or admin JWT) + // Authorization: this batch processor drains ALL pending withdrawal requests + // to their destination wallets, so it MUST be restricted to the backend + // service role (cron / admin tooling). Merely having *any* bearer token + // (e.g. the public anon key) is NOT sufficient — previously that let anyone + // trigger mass processing. const authHeader = req.headers.get("Authorization"); - if (!authHeader) { + const bearer = authHeader?.replace(/^Bearer\s+/i, "").trim(); + if (!bearer || bearer !== SUPABASE_SERVICE_ROLE_KEY) { return new Response( - JSON.stringify({ error: "Unauthorized" }), + JSON.stringify({ error: "Unauthorized: service role required" }), { status: 401, headers } ); } diff --git a/web/supabase/functions/resolve-dispute/index.ts b/web/supabase/functions/resolve-dispute/index.ts new file mode 100644 index 00000000..891ef1e0 --- /dev/null +++ b/web/supabase/functions/resolve-dispute/index.ts @@ -0,0 +1,151 @@ +// resolve-dispute Edge Function +// +// Server-side authorization for privileged P2P dispute actions (claim / resolve). +// The admin authorization is NOT the client isAdmin flag (which is cosmetic and +// bypassable). Here we: +// 1. Verify a wallet signature over a canonical admin-action challenge. +// 2. Check the signing wallet is in the server-side admin wallet set. +// 3. Enforce freshness + single-use nonce (replay protection). +// 4. For 'resolve', invoke admin_resolve_dispute() with the service role so the +// escrow movement + status changes happen atomically server-side. + +import { serve } from 'https://deno.land/std@0.168.0/http/server.ts' +import { createClient } from 'npm:@supabase/supabase-js@2' +import { + verifyWalletSignature, + buildAdminChallenge, + 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', + } +} + +// Authoritative admin wallet set (server-side). Overridable via ADMIN_WALLETS +// (comma-separated SS58). Defaults mirror the historical client whitelist. +function getAdminWallets(): string[] { + const env = Deno.env.get('ADMIN_WALLETS') + if (env) return env.split(',').map(s => s.trim()).filter(Boolean) + return [ + '5CyuFfbF95rzBxru7c9yEsX4XmQXUxpLUcbj9RLg9K1cGiiF', // Founder + '5EhCpn82QtdU53MF6PoNFrKHgSrsfcAxFTMwrn3JYf9dioQw', // Treasury admin + '5ELgySrX5ZyK7EWXjj6bAedyTCcTNWDANbiiipsT5gnpoCEp', // Admin + ] +} + +interface Body { + action?: 'claim' | 'resolve' + disputeId?: string + tradeId?: string + decision?: string + reasoning?: string + adminAddress?: 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 supabaseUrl = Deno.env.get('SUPABASE_URL')! + const supabaseServiceKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')! + const serviceClient = createClient(supabaseUrl, supabaseServiceKey) + + const body: Body = await req.json() + const { action, disputeId, tradeId, decision, reasoning, adminAddress, signature, timestamp, nonce } = body + + if (!action || !['claim', 'resolve'].includes(action)) { + return json(400, { success: false, error: 'Invalid action' }) + } + if (!disputeId || !tradeId || !adminAddress || !signature || !nonce || typeof timestamp !== 'number') { + return json(401, { success: false, error: 'Missing authorization fields' }) + } + if (action === 'resolve' && (!decision || !reasoning)) { + return json(400, { success: false, error: 'Decision and reasoning are required' }) + } + + // 1) Freshness + if (!isFreshTimestamp(timestamp)) { + return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' }) + } + + // 2) Verify signature over the canonical admin challenge + const challenge = buildAdminChallenge({ + action, + disputeId, + tradeId, + decision: String(decision ?? ''), + adminAddress, + timestamp, + nonce, + }) + const sigOk = await verifyWalletSignature(challenge, signature, adminAddress) + if (!sigOk) { + return json(401, { success: false, error: 'Invalid signature' }) + } + + // 3) Server-side admin authorization + if (!getAdminWallets().includes(adminAddress)) { + return json(403, { success: false, error: 'Wallet is not authorized for admin actions' }) + } + + // 4) Replay protection + const nonceOk = await consumeNonce(serviceClient, nonce, `admin_${action}`) + if (!nonceOk) { + return json(401, { success: false, error: 'Authorization already used (replay detected)' }) + } + + // ---- CLAIM ---- + if (action === 'claim') { + const { error } = await serviceClient + .from('p2p_fiat_disputes') + .update({ status: 'under_review', assigned_at: new Date().toISOString() }) + .eq('id', disputeId) + if (error) return json(500, { success: false, error: 'Failed to claim dispute' }) + return json(200, { success: true, action: 'claim' }) + } + + // ---- RESOLVE (atomic escrow movement + status) ---- + const { data, error } = await serviceClient.rpc('admin_resolve_dispute', { + p_dispute_id: disputeId, + p_trade_id: tradeId, + p_decision: decision, + p_reasoning: reasoning, + p_admin_ref: adminAddress, + }) + + if (error) { + console.error('admin_resolve_dispute error:', error) + return json(500, { success: false, error: error.message || 'Resolution failed' }) + } + const result = typeof data === 'string' ? JSON.parse(data) : data + if (!result?.success) { + return json(400, { success: false, error: result?.error || 'Resolution failed' }) + } + + return json(200, { success: true, ...result }) + } catch (error) { + console.error('resolve-dispute error:', error) + return json(500, { success: false, error: 'Internal server error' }) + } +}) diff --git a/web/supabase/functions/verify-deposit/index.ts b/web/supabase/functions/verify-deposit/index.ts index d17fac9b..1fe7f35e 100644 --- a/web/supabase/functions/verify-deposit/index.ts +++ b/web/supabase/functions/verify-deposit/index.ts @@ -8,6 +8,7 @@ import { createClient } from 'npm:@supabase/supabase-js@2' import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36' import { blake2b } from 'npm:@noble/hashes@1.7.1/blake2b' import { base58 } from 'npm:@scure/base@1.2.4' +import { assertIdentityOwnedByWallet } from '../_shared/identity-auth.ts' // Allowed origins for CORS const ALLOWED_ORIGINS = [ @@ -61,6 +62,17 @@ async function identityToUUID(identityId: string): Promise { // PEZ asset ID const PEZ_ASSET_ID = 1 +// People Chain endpoint — required to verify citizen NFT ownership server-side +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 + const provider = new WsProvider(PEOPLE_RPC_ENDPOINT) + peopleApiInstance = await ApiPromise.create({ provider }) + return peopleApiInstance +} + interface DepositRequest { txHash: string token: 'HEZ' | 'PEZ' @@ -501,6 +513,30 @@ serve(async (req) => { ) } + // Bind the crediting identity to the depositing wallet. The on-chain sender + // has just been proven to equal `walletAddress`; requiring that wallet to + // OWN `identityId` prevents crediting a balance the depositor does not own + // (citizen NFT / active visa binding). + const depositOwnership = await assertIdentityOwnedByWallet(serviceClient, identityId, walletAddress, getPeopleApi) + if (!depositOwnership.ok) { + await serviceClient + .from('p2p_deposit_withdraw_requests') + .update({ + status: 'failed', + error_message: `Identity ownership check failed: ${depositOwnership.error}`, + processed_at: new Date().toISOString() + }) + .eq('id', depositRequest.id) + + return new Response( + JSON.stringify({ + success: false, + error: depositOwnership.error || 'Depositing wallet does not own this identity' + }), + { status: 403, headers: { ...corsHeaders, 'Content-Type': 'application/json' } } + ) + } + // Process deposit const { data: processResult, error: processError } = await serviceClient .rpc('process_deposit', { diff --git a/web/supabase/migrations/20260725000000_withdraw_authz_hardening.sql b/web/supabase/migrations/20260725000000_withdraw_authz_hardening.sql new file mode 100644 index 00000000..8e549e33 --- /dev/null +++ b/web/supabase/migrations/20260725000000_withdraw_authz_hardening.sql @@ -0,0 +1,118 @@ +-- ===================================================== +-- Migration: Withdrawal authorization hardening +-- Date: 2026-07-25 +-- ===================================================== +-- +-- Part of the fix for the CRITICAL broken-object-level-auth finding on the +-- withdrawal path. Two defenses: +-- +-- 1. Replay protection for the signed withdrawal challenge (nonce store). +-- 2. Defense-in-depth: request_withdraw() may ONLY be executed by the backend +-- service role. Previously it was executable by anon/authenticated, so an +-- attacker with the public anon key could call it directly with a victim's +-- user_id to create a pending withdrawal to an attacker-controlled wallet, +-- which the batch processor would then pay out. +-- + +-- ===================================================== +-- 1. CHALLENGE NONCE STORE (single-use signed challenges) +-- ===================================================== +CREATE TABLE IF NOT EXISTS public.p2p_challenge_nonces ( + nonce TEXT PRIMARY KEY, + purpose TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_p2p_challenge_nonces_created + ON public.p2p_challenge_nonces(created_at); + +ALTER TABLE public.p2p_challenge_nonces ENABLE ROW LEVEL SECURITY; + +-- Only the service role (edge functions) may read/write nonces. +DROP POLICY IF EXISTS "challenge_nonces_service_only" ON public.p2p_challenge_nonces; +CREATE POLICY "challenge_nonces_service_only" ON public.p2p_challenge_nonces + FOR ALL USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role'); + +REVOKE ALL ON public.p2p_challenge_nonces FROM anon, authenticated, PUBLIC; + +-- ===================================================== +-- 2. LOCK request_withdraw() TO SERVICE ROLE +-- ===================================================== +-- Recreate with an explicit service-role guard (mirrors process_deposit()). +CREATE OR REPLACE FUNCTION request_withdraw( + p_user_id UUID, + p_token TEXT, + p_amount DECIMAL(20, 12), + p_wallet_address TEXT +) RETURNS JSON AS $$ +DECLARE + v_balance RECORD; + v_request_id UUID; +BEGIN + -- SECURITY: backend service role only. Object-level authorization (proving the + -- caller owns p_user_id) is enforced by the process-withdraw edge function via + -- a verified wallet signature before this is ever invoked. + IF current_setting('role', true) <> 'service_role' + AND current_setting('request.jwt.claim.role', true) <> 'service_role' THEN + RETURN json_build_object( + 'success', false, + 'error', 'UNAUTHORIZED: withdrawals must go through the backend service' + ); + END IF; + + -- Lock user's balance + SELECT * INTO v_balance + FROM user_internal_balances + WHERE user_id = p_user_id AND token = p_token + FOR UPDATE; + + -- Check sufficient available balance + IF v_balance IS NULL OR v_balance.available_balance < p_amount THEN + RETURN json_build_object( + 'success', false, + 'error', 'Insufficient available balance. Available: ' || COALESCE(v_balance.available_balance, 0) + ); + END IF; + + -- Lock the amount (move to locked_balance) + UPDATE user_internal_balances + SET + available_balance = available_balance - p_amount, + locked_balance = locked_balance + p_amount, + updated_at = NOW() + WHERE user_id = p_user_id AND token = p_token; + + -- Create withdrawal request + INSERT INTO p2p_deposit_withdraw_requests ( + user_id, request_type, token, amount, wallet_address, status + ) VALUES ( + p_user_id, 'withdraw', p_token, p_amount, p_wallet_address, 'pending' + ) RETURNING id INTO v_request_id; + + -- Log the transaction + INSERT INTO p2p_balance_transactions ( + user_id, token, transaction_type, amount, + balance_before, balance_after, reference_type, reference_id, + description + ) VALUES ( + p_user_id, p_token, 'withdraw', -p_amount, + v_balance.available_balance, v_balance.available_balance - p_amount, 'withdraw_request', v_request_id, + 'Withdrawal request to ' || p_wallet_address + ); + + RETURN json_build_object( + 'success', true, + 'request_id', v_request_id, + 'amount', p_amount, + 'wallet_address', p_wallet_address, + 'status', 'pending' + ); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +REVOKE EXECUTE ON FUNCTION request_withdraw(UUID, TEXT, DECIMAL, TEXT) FROM PUBLIC, anon, authenticated; + +COMMENT ON FUNCTION request_withdraw IS +'Create withdrawal request and lock balance. SERVICE ROLE ONLY — called by the +process-withdraw edge function after it verifies the caller owns the identity via +a wallet signature. Never call directly from the client.'; diff --git a/web/supabase/migrations/20260725010000_harden_financial_rls.sql b/web/supabase/migrations/20260725010000_harden_financial_rls.sql new file mode 100644 index 00000000..f7d2c7da --- /dev/null +++ b/web/supabase/migrations/20260725010000_harden_financial_rls.sql @@ -0,0 +1,122 @@ +-- ===================================================== +-- Migration: Harden RLS on financial + PII tables +-- Date: 2026-07-25 +-- ===================================================== +-- +-- Forward migration (does NOT rewrite history) that removes the blanket +-- `USING (true)` read policies added in 20260223160000_fix_rls_for_wallet_auth +-- on the crown-jewel financial/PII tables, so the public anon key can no longer +-- enumerate every user's balances, ledger history, deposit/withdraw requests, or +-- payment-method PII. +-- +-- Reads the app legitimately needs are moved behind SECURITY DEFINER RPCs that +-- return only the requested user's rows (instead of the whole table). Writes to +-- these tables already go through service_role edge functions / SECURITY DEFINER +-- RPCs, so no client write path is affected. +-- +-- NOTE (residual, tracked): user_id is a UUID v5 derived from a citizen/visa +-- number, which is not a secret. These RPCs remove full-table exfiltration but do +-- not yet cryptographically bind the reader to the row owner. Fully closing that +-- requires a signed-session read path (same mechanism as withdrawals) applied to +-- these reads — see the security report. The high-severity leak (dump ALL rows) +-- is closed here. + +-- ===================================================== +-- 1. user_internal_balances — no direct client reads (uses +-- get_user_internal_balance RPC which is SECURITY DEFINER and bypasses RLS) +-- ===================================================== +DROP POLICY IF EXISTS "balances_anon_select" ON public.user_internal_balances; +-- No anon policy remains -> anon/authenticated cannot read the table directly. +-- SECURITY DEFINER RPCs (get_user_internal_balance) continue to work. + +-- ===================================================== +-- 2. p2p_balance_transactions — replace open SELECT with a scoped RPC +-- ===================================================== +DROP POLICY IF EXISTS "balance_tx_anon_select" ON public.p2p_balance_transactions; + +CREATE OR REPLACE FUNCTION public.get_user_balance_transactions( + p_user_id UUID, + p_limit INT DEFAULT 50 +) RETURNS SETOF public.p2p_balance_transactions AS $$ + SELECT * + FROM public.p2p_balance_transactions + WHERE user_id = p_user_id + ORDER BY created_at DESC + LIMIT LEAST(GREATEST(COALESCE(p_limit, 50), 1), 200); +$$ LANGUAGE sql STABLE SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION public.get_user_balance_transactions(UUID, INT) TO anon, authenticated; + +COMMENT ON FUNCTION public.get_user_balance_transactions IS +'Returns balance-transaction history for a single user_id only (no full-table +dump). Replaces the removed USING(true) SELECT policy.'; + +-- ===================================================== +-- 3. p2p_deposit_withdraw_requests — replace open SELECT with a scoped RPC +-- (service_role write policy from 20260223160000 is retained) +-- ===================================================== +DROP POLICY IF EXISTS "deposit_requests_anon_select" ON public.p2p_deposit_withdraw_requests; + +CREATE OR REPLACE FUNCTION public.get_user_deposit_withdraw_requests( + p_user_id UUID, + p_limit INT DEFAULT 50 +) RETURNS SETOF public.p2p_deposit_withdraw_requests AS $$ + SELECT * + FROM public.p2p_deposit_withdraw_requests + WHERE user_id = p_user_id + ORDER BY created_at DESC + LIMIT LEAST(GREATEST(COALESCE(p_limit, 50), 1), 200); +$$ LANGUAGE sql STABLE SECURITY DEFINER; + +GRANT EXECUTE ON FUNCTION public.get_user_deposit_withdraw_requests(UUID, INT) TO anon, authenticated; + +COMMENT ON FUNCTION public.get_user_deposit_withdraw_requests IS +'Returns deposit/withdraw requests for a single user_id only. Replaces the +removed USING(true) SELECT policy.'; + +-- ===================================================== +-- 4. p2p_user_payment_methods (IBAN / payment PII) — lock to service role +-- (no direct client access exists; offers embed encrypted payment details). +-- ===================================================== +DO $$ +BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'p2p_user_payment_methods') THEN + EXECUTE 'ALTER TABLE public.p2p_user_payment_methods ENABLE ROW LEVEL SECURITY'; + EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_select" ON public.p2p_user_payment_methods'; + EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_insert" ON public.p2p_user_payment_methods'; + EXECUTE 'DROP POLICY IF EXISTS "user_pm_anon_update" ON public.p2p_user_payment_methods'; + EXECUTE 'DROP POLICY IF EXISTS "user_pm_service_only" ON public.p2p_user_payment_methods'; + EXECUTE 'CREATE POLICY "user_pm_service_only" ON public.p2p_user_payment_methods + FOR ALL USING (auth.role() = ''service_role'') WITH CHECK (auth.role() = ''service_role'')'; + EXECUTE 'REVOKE ALL ON public.p2p_user_payment_methods FROM anon, authenticated, PUBLIC'; + END IF; +END $$; + +-- ===================================================== +-- 5. p2p_fiat_disputes — remove world-open UPDATE. +-- Dispute state transitions (claim/resolve) now go through the service-role +-- `resolve-dispute` edge function (which verifies the admin wallet signature). +-- Regular users still INSERT disputes (disputes_anon_insert retained). +-- ===================================================== +DROP POLICY IF EXISTS "disputes_anon_update" ON public.p2p_fiat_disputes; +DROP POLICY IF EXISTS "disputes_service_update" ON public.p2p_fiat_disputes; +CREATE POLICY "disputes_service_update" ON public.p2p_fiat_disputes + FOR UPDATE USING (auth.role() = 'service_role') WITH CHECK (auth.role() = 'service_role'); + +-- ===================================================== +-- Done. Balances, ledger, deposit/withdraw requests and payment PII are no longer +-- world-readable via the anon key; dispute resolution is no longer world-writable. +-- +-- RESIDUAL (documented in the security report, NOT closed here to avoid breaking +-- the live app without the signed-session refactor): +-- * p2p_fiat_offers / p2p_fiat_trades / p2p_messages still carry USING(true) +-- policies because the client reads/writes them directly with the anon key +-- and there is no server session to bind to. Marking a trade status does not +-- itself move funds. +-- * The internal-ledger escrow RPCs (lock_/release_/refund_escrow_internal) +-- are still EXECUTE-able by anon (confirmPaymentReceived / createFiatOffer +-- call them directly). This is a SEPARATE critical hole that needs the same +-- signed-challenge mechanism applied to trade actions (an authenticated +-- confirm-payment / create-offer edge function). See report. +-- ===================================================== diff --git a/web/supabase/migrations/20260725020000_admin_dispute_resolution.sql b/web/supabase/migrations/20260725020000_admin_dispute_resolution.sql new file mode 100644 index 00000000..b97e6e6b --- /dev/null +++ b/web/supabase/migrations/20260725020000_admin_dispute_resolution.sql @@ -0,0 +1,158 @@ +-- ===================================================== +-- Migration: Atomic admin dispute resolution (moves escrow) +-- Date: 2026-07-25 +-- ===================================================== +-- +-- Fix for the CRITICAL fund-logic finding: the admin Dispute Resolution panel +-- only relabelled p2p_fiat_trades.status and NEVER moved escrow, so "buyer wins" +-- paid nothing, "refund seller" left funds locked, and "split" was unhandled. +-- +-- This SECURITY DEFINER function performs the escrow movement AND all status +-- changes in a single transaction. It is service-role only; caller identity +-- (admin) is verified by the resolve-dispute edge function via a wallet +-- signature before this is invoked. +-- +-- ESCROW ACCOUNTING (verified against createFiatOffer + accept_p2p_offer): +-- * createFiatOffer locks the FULL offer amount (available -> locked). +-- * accept_p2p_offer does NOT lock again; it only decrements +-- offer.remaining_amount. So: seller.locked == remaining_amount + SUM(active +-- trade amounts) for the offer. +-- Therefore, per trade of amount X: +-- - release_to_buyer : locked(seller) -= X, available(buyer) += X. (offer untouched) +-- - refund_to_seller : locked(seller) -= X, available(seller) += X. (offer untouched) +-- - split : release X/2 to buyer + refund X/2 to seller. (offer untouched) +-- remaining_amount is intentionally NOT restored (that would double-count the +-- funds — a latent bug in the older resolve_p2p_dispute()). + +CREATE OR REPLACE FUNCTION public.admin_resolve_dispute( + p_dispute_id UUID, + p_trade_id UUID, + p_decision TEXT, -- 'release_to_buyer' | 'refund_to_seller' | 'split' | 'escalate' + p_reasoning TEXT, + p_admin_ref TEXT -- admin wallet address (for audit only) +) RETURNS JSON AS $$ +DECLARE + v_trade RECORD; + v_token TEXT; + v_half DECIMAL(20, 12); + v_res JSON; + v_trade_status TEXT; +BEGIN + -- SECURITY: backend service role only. Admin identity is verified upstream by + -- the resolve-dispute edge function (wallet signature vs. admin wallet set). + IF current_setting('role', true) <> 'service_role' + AND current_setting('request.jwt.claim.role', true) <> 'service_role' THEN + RETURN json_build_object('success', false, 'error', 'UNAUTHORIZED: service role required'); + END IF; + + IF p_reasoning IS NULL OR length(trim(p_reasoning)) = 0 THEN + RETURN json_build_object('success', false, 'error', 'Reasoning is required'); + END IF; + + IF p_decision NOT IN ('release_to_buyer', 'refund_to_seller', 'split', 'escalate') THEN + RETURN json_build_object('success', false, 'error', 'Invalid decision'); + END IF; + + -- Lock the trade row + SELECT * INTO v_trade FROM public.p2p_fiat_trades WHERE id = p_trade_id FOR UPDATE; + IF NOT FOUND THEN + RETURN json_build_object('success', false, 'error', 'Trade not found'); + END IF; + + -- ----- ESCALATE: no fund movement, just mark dispute escalated ----- + IF p_decision = 'escalate' THEN + UPDATE public.p2p_fiat_disputes + SET status = 'escalated', decision = 'escalate', decision_reasoning = p_reasoning, updated_at = NOW() + WHERE id = p_dispute_id; + + INSERT INTO public.p2p_audit_log (user_id, action, entity_type, entity_id, details) + VALUES (NULL, 'dispute_escalated', 'trade', p_trade_id, + jsonb_build_object('dispute_id', p_dispute_id, 'admin_ref', p_admin_ref, 'reasoning', p_reasoning)); + + RETURN json_build_object('success', true, 'decision', 'escalate'); + END IF; + + -- For fund-moving decisions the trade must be in dispute. + IF v_trade.status <> 'disputed' THEN + RETURN json_build_object('success', false, 'error', 'Trade is not in disputed status (current: ' || v_trade.status || ')'); + END IF; + + -- Resolve token from the offer + SELECT token INTO v_token FROM public.p2p_fiat_offers WHERE id = v_trade.offer_id; + IF v_token IS NULL THEN + RETURN json_build_object('success', false, 'error', 'Offer/token not found for trade'); + END IF; + + IF p_decision = 'release_to_buyer' THEN + v_res := public.release_escrow_internal( + v_trade.seller_id, v_trade.buyer_id, v_token, v_trade.crypto_amount, 'dispute_resolution', p_trade_id); + IF (v_res->>'success')::boolean IS NOT TRUE THEN + RAISE EXCEPTION 'release_escrow_internal failed: %', COALESCE(v_res->>'error', 'unknown'); + END IF; + v_trade_status := 'completed'; + + ELSIF p_decision = 'refund_to_seller' THEN + v_res := public.refund_escrow_internal( + v_trade.seller_id, v_token, v_trade.crypto_amount, 'dispute_resolution', p_trade_id); + IF (v_res->>'success')::boolean IS NOT TRUE THEN + RAISE EXCEPTION 'refund_escrow_internal failed: %', COALESCE(v_res->>'error', 'unknown'); + END IF; + v_trade_status := 'refunded'; + + ELSIF p_decision = 'split' THEN + v_half := ROUND(v_trade.crypto_amount / 2, 12); + -- Half to buyer + v_res := public.release_escrow_internal( + v_trade.seller_id, v_trade.buyer_id, v_token, v_half, 'dispute_resolution_split', p_trade_id); + IF (v_res->>'success')::boolean IS NOT TRUE THEN + RAISE EXCEPTION 'split release failed: %', COALESCE(v_res->>'error', 'unknown'); + END IF; + -- Remainder back to seller (handles odd cents deterministically) + v_res := public.refund_escrow_internal( + v_trade.seller_id, v_token, v_trade.crypto_amount - v_half, 'dispute_resolution_split', p_trade_id); + IF (v_res->>'success')::boolean IS NOT TRUE THEN + RAISE EXCEPTION 'split refund failed: %', COALESCE(v_res->>'error', 'unknown'); + END IF; + v_trade_status := 'completed'; + END IF; + + -- Update trade + UPDATE public.p2p_fiat_trades + SET status = v_trade_status, + completed_at = CASE WHEN v_trade_status = 'completed' THEN NOW() ELSE completed_at END, + escrow_released_at = NOW(), + dispute_resolved_at = NOW(), + dispute_resolution = p_decision || ': ' || COALESCE(p_reasoning, ''), + updated_at = NOW() + WHERE id = p_trade_id; + + -- Update dispute + UPDATE public.p2p_fiat_disputes + SET status = 'resolved', decision = p_decision, decision_reasoning = p_reasoning, + resolved_at = NOW(), updated_at = NOW() + WHERE id = p_dispute_id; + + -- Notify both parties (best-effort, same txn) + INSERT INTO public.p2p_notifications (user_id, type, title, message, reference_type, reference_id) + VALUES + (v_trade.seller_id, 'dispute_resolved', 'Dispute Resolved', 'Your dispute was resolved: ' || p_decision, 'dispute', p_dispute_id), + (v_trade.buyer_id, 'dispute_resolved', 'Dispute Resolved', 'Your dispute was resolved: ' || p_decision, 'dispute', p_dispute_id); + + -- Audit + INSERT INTO public.p2p_audit_log (user_id, action, entity_type, entity_id, details) + VALUES (NULL, 'dispute_resolved', 'trade', p_trade_id, + jsonb_build_object( + 'dispute_id', p_dispute_id, 'decision', p_decision, 'admin_ref', p_admin_ref, + 'reasoning', p_reasoning, 'seller_id', v_trade.seller_id, 'buyer_id', v_trade.buyer_id, + 'amount', v_trade.crypto_amount, 'token', v_token)); + + RETURN json_build_object('success', true, 'decision', p_decision, 'trade_id', p_trade_id, 'token', v_token, 'amount', v_trade.crypto_amount); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; + +REVOKE EXECUTE ON FUNCTION public.admin_resolve_dispute(UUID, UUID, TEXT, TEXT, TEXT) FROM PUBLIC, anon, authenticated; + +COMMENT ON FUNCTION public.admin_resolve_dispute IS +'Atomically resolves a P2P dispute: moves escrow (release/refund/split) AND +updates trade + dispute status + notifications in one transaction. SERVICE ROLE +ONLY — admin identity is verified by the resolve-dispute edge function.';