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

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

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

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

KNOWN RESIDUAL (Round 2 — as severe as the withdrawal BOLA): release_/lock_/refund_
escrow_internal still have PUBLIC EXECUTE and the client calls release_escrow_internal
directly with the anon key from confirmPaymentReceived -> an anon caller can drain any
victim's LOCKED balance. Fix = a wallet-signed confirm-payment edge function (same
pattern as withdrawals) before revoking PUBLIC execute. Not yet fixed.
This commit is contained in:
2026-07-25 00:09:03 -07:00
parent 27b4057bd4
commit a8f41cd47f
13 changed files with 1178 additions and 96 deletions
+74 -21
View File
@@ -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-<item>-<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<string>
): Promise<string> {
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<Deposit
try {
if (!userId) throw new Error('Identity required for P2P trading');
const { data, error } = await supabase
.from('p2p_deposit_withdraw_requests')
.select('*')
.eq('user_id', userId)
.order('created_at', { ascending: false })
.limit(50);
// Scoped SECURITY DEFINER RPC (direct table SELECT is no longer world-open).
const { data, error } = await supabase.rpc('get_user_deposit_withdraw_requests', {
p_user_id: userId,
p_limit: 50,
});
if (error) throw error;
return data || [];
return (data as DepositWithdrawRequest[]) || [];
} catch (error) {
console.error('Get deposit/withdraw history error:', error);
return [];
@@ -1103,16 +1153,19 @@ export async function getDepositWithdrawHistory(userId: string): Promise<Deposit
/**
* Get user's balance transaction history
*/
export async function getBalanceTransactionHistory(limit: number = 50): Promise<BalanceTransaction[]> {
export async function getBalanceTransactionHistory(userId: string, limit: number = 50): Promise<BalanceTransaction[]> {
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 [];