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
@@ -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 }
);
}