A bounded adversarial re-verification of the new fund-custody auth found two real
bypasses that the fix rounds missed; both fixed here, plus executable proof.
- BYPASS #1 (CRITICAL, external): p2p_fiat_trades/p2p_fiat_offers kept USING(true)
anon UPDATE, and confirm-payment / admin_resolve_dispute read the fund DESTINATION
(buyer_id), amount and token straight from those rows. An anon attacker could
`UPDATE p2p_fiat_trades SET buyer_id=<attacker>` so the honest seller's correctly-
signed release paid the attacker. Fix: BEFORE UPDATE triggers freeze the fund-routing
columns (offer_id, seller_id, buyer_id, crypto_amount, fiat_amount, price_per_unit,
escrow_locked_amount on trades; seller_id, token, amount_crypto on offers) for any
non-service-role writer — trigger-level, independent of RLS/grants; status/proof/chat
stay open. (Corrects the earlier triage that dismissed these tables as non-custodial.)
- BYPASS #2 (double-release TOCTOU): confirm-payment checked status then released
non-atomically, so two concurrent differently-nonced valid requests could both release
and drain other buyers' escrow. Fix: atomic compare-and-swap of payment_sent->completed
BEFORE the escrow move (loser gets 409; revert on release failure).
- Hardening: REVOKE EXECUTE on the dead legacy resolve_p2p_dispute mover from
PUBLIC/anon/authenticated.
- Proof: 34 executable tests (Deno) — identity-auth unit tests (sr25519 sign/verify,
challenge money-param binding, citizen/visa ownership, nonce single-use+concurrency,
freshness) + per-handler authorization-flow tests (bad-sig/cross-id/wrong-state/replay,
CAS double-release). All pass.
Round 2 — fixes the CRITICAL discovered during round 1 (as severe as the withdrawal
BOLA): lock_/release_/refund_escrow_internal had default PUBLIC EXECUTE and the client
called release_escrow_internal directly with the anon key, so anyone could drain any
victim's LOCKED balance.
- release_escrow_internal now reachable only via new signed confirm-payment edge
function: verifies seller wallet signature (raw + <Bytes>), asserts the wallet owns
the seller identity, derived user_id == trade.seller_id, trade == payment_sent,
single-use nonce, then release runs with the service role. confirmPaymentReceived
now invokes confirm-payment (no more anon rpc).
- lock_escrow_internal now behind new signed lock-escrow edge function (a caller can
only lock its own balance; stops griefing a victim's balance). Removed a zero-amount
no-op lock call.
- Migration 20260725030000: REVOKE EXECUTE on lock_/release_/refund_escrow_internal
from PUBLIC/anon/authenticated; GRANT to service_role only. refund has no direct
client caller (service-role + admin_resolve_dispute only).
- DEPLOY_RUNBOOK.md consolidates the ordered migrations, edge functions and secrets
for both security rounds. Migration 20260725030000 must ship WITH the two new edge
functions + frontend or offer-create/payment-release break.
Remaining (non-fund, Round 2+ follow-up): read RPCs still key on a non-secret user_id
(binding to a signed session would force a sign-prompt on every passive balance read —
deferred); p2p_fiat_offers/trades/messages retain USING(true) (status labels move no
funds now that all escrow movement is service-role-gated).