security(p2p): close escrow-RPC anon-drain (release/lock behind signed edge functions)

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).
This commit is contained in:
2026-07-25 00:21:20 -07:00
parent a8f41cd47f
commit 165cb47c64
7 changed files with 505 additions and 95 deletions
@@ -141,6 +141,50 @@ export function buildDepositChallenge(p: {
].join('\n')
}
/**
* Canonical challenge for a seller confirming payment / releasing escrow.
* MUST be byte-identical to the client (shared/lib/p2p-fiat.ts).
*/
export function buildConfirmPaymentChallenge(p: {
tradeId: string
sellerIdentity: string
signerAddress: string
timestamp: number
nonce: string
}): string {
return [
'Pezkuwi P2P Confirm Payment',
`trade:${p.tradeId}`,
`identity:${p.sellerIdentity}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n')
}
/**
* Canonical challenge for locking one's OWN balance into escrow (offer create).
* MUST be byte-identical to the client (shared/lib/p2p-fiat.ts).
*/
export function buildLockEscrowChallenge(p: {
identityId: string
token: string
amount: number
signerAddress: string
timestamp: number
nonce: string
}): string {
return [
'Pezkuwi P2P Lock Escrow',
`identity:${p.identityId}`,
`token:${p.token}`,
`amount:${p.amount}`,
`signer:${p.signerAddress}`,
`timestamp:${p.timestamp}`,
`nonce:${p.nonce}`,
].join('\n')
}
/**
* Canonical challenge string for a privileged admin action (dispute claim /
* resolve). MUST be byte-identical to the client (DisputeResolutionPanel).