mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 13:15:41 +00:00
a8f41cd47f
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.
152 lines
5.4 KiB
TypeScript
152 lines
5.4 KiB
TypeScript
// 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' })
|
|
}
|
|
})
|