mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 17:45:42 +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:
@@ -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-<item>-<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<ApiPromise> {
|
||||
return apiInstance
|
||||
}
|
||||
|
||||
// Cache People Chain connection (citizen NFT ownership verification)
|
||||
let peopleApiInstance: ApiPromise | null = null
|
||||
|
||||
async function getPeopleApi(): Promise<ApiPromise> {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user