mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 20:05:40 +00:00
165cb47c64
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).
116 lines
4.7 KiB
TypeScript
116 lines
4.7 KiB
TypeScript
// lock-escrow Edge Function
|
|
//
|
|
// Locks a caller's OWN available balance into escrow (used by offer creation).
|
|
// lock_escrow_internal only ever moves the caller's own funds available->locked,
|
|
// so it is not a theft vector — but an anon caller could previously lock a
|
|
// VICTIM's balance (griefing/DoS on their funds). This gates it so a caller can
|
|
// only ever lock the balance of the identity their wallet owns.
|
|
//
|
|
// Authorization: wallet signature -> identity ownership -> derived user_id ->
|
|
// lock_escrow_internal with the service role.
|
|
|
|
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
|
import { createClient } from 'npm:@supabase/supabase-js@2'
|
|
import { ApiPromise, WsProvider } from 'npm:@pezkuwi/api@16.5.36'
|
|
import {
|
|
identityToUUID,
|
|
verifyWalletSignature,
|
|
buildLockEscrowChallenge,
|
|
assertIdentityOwnedByWallet,
|
|
consumeNonce,
|
|
isFreshTimestamp,
|
|
} from '../_shared/identity-auth.ts'
|
|
|
|
const ALLOWED_ORIGINS = [
|
|
'https://app.pezkuwichain.io',
|
|
'https://www.pezkuwichain.io',
|
|
'https://pezkuwichain.io',
|
|
]
|
|
|
|
function getCorsHeaders(origin: string | null) {
|
|
const allowedOrigin = origin && ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0]
|
|
return {
|
|
'Access-Control-Allow-Origin': allowedOrigin,
|
|
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
|
'Access-Control-Allow-Credentials': 'true',
|
|
}
|
|
}
|
|
|
|
const PEOPLE_RPC_ENDPOINT = Deno.env.get('PEOPLE_RPC_ENDPOINT') || 'wss://people-rpc.pezkuwichain.io'
|
|
let peopleApiInstance: ApiPromise | null = null
|
|
async function getPeopleApi(): Promise<ApiPromise> {
|
|
if (peopleApiInstance && peopleApiInstance.isConnected) return peopleApiInstance
|
|
peopleApiInstance = await ApiPromise.create({ provider: new WsProvider(PEOPLE_RPC_ENDPOINT) })
|
|
return peopleApiInstance
|
|
}
|
|
|
|
interface Body {
|
|
identityId?: string
|
|
token?: 'HEZ' | 'PEZ'
|
|
amount?: number
|
|
signerAddress?: string
|
|
signature?: string
|
|
timestamp?: number
|
|
nonce?: string
|
|
}
|
|
|
|
serve(async (req) => {
|
|
const corsHeaders = getCorsHeaders(req.headers.get('Origin'))
|
|
const json = (status: number, obj: unknown) =>
|
|
new Response(JSON.stringify(obj), { status, headers: { ...corsHeaders, 'Content-Type': 'application/json' } })
|
|
|
|
if (req.method === 'OPTIONS') return new Response(null, { headers: corsHeaders })
|
|
|
|
try {
|
|
const serviceClient = createClient(Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!)
|
|
const { identityId, token, amount, signerAddress, signature, timestamp, nonce }: Body = await req.json()
|
|
|
|
if (!identityId || !token || !signerAddress || !signature || !nonce || typeof timestamp !== 'number') {
|
|
return json(401, { success: false, error: 'Missing authorization fields' })
|
|
}
|
|
if (!['HEZ', 'PEZ'].includes(token)) {
|
|
return json(400, { success: false, error: 'Invalid token' })
|
|
}
|
|
const amt = Number(amount)
|
|
if (!Number.isFinite(amt) || amt <= 0) {
|
|
return json(400, { success: false, error: 'Amount must be greater than 0' })
|
|
}
|
|
if (!isFreshTimestamp(timestamp)) {
|
|
return json(401, { success: false, error: 'Authorization challenge expired. Please retry.' })
|
|
}
|
|
|
|
// 1) Verify signature over the exact lock intent
|
|
const challenge = buildLockEscrowChallenge({ identityId, token, amount: amt, signerAddress, timestamp, nonce })
|
|
if (!(await verifyWalletSignature(challenge, signature, signerAddress))) {
|
|
return json(401, { success: false, error: 'Invalid signature' })
|
|
}
|
|
|
|
// 2) Prove the signer owns the identity whose balance is being locked
|
|
const ownership = await assertIdentityOwnedByWallet(serviceClient, identityId, signerAddress, getPeopleApi)
|
|
if (!ownership.ok) {
|
|
return json(403, { success: false, error: ownership.error || 'Identity ownership verification failed' })
|
|
}
|
|
const userId = await identityToUUID(identityId)
|
|
|
|
// 3) Replay protection
|
|
if (!(await consumeNonce(serviceClient, nonce, 'lock_escrow'))) {
|
|
return json(401, { success: false, error: 'Authorization already used (replay detected)' })
|
|
}
|
|
|
|
// 4) Lock the caller's OWN balance (service role)
|
|
const { data: lockRes, error: lockErr } = await serviceClient.rpc('lock_escrow_internal', {
|
|
p_user_id: userId,
|
|
p_token: token,
|
|
p_amount: amt,
|
|
})
|
|
if (lockErr) return json(500, { success: false, error: lockErr.message || 'Lock failed' })
|
|
const parsed = typeof lockRes === 'string' ? JSON.parse(lockRes) : lockRes
|
|
if (!parsed?.success) return json(400, { success: false, error: parsed?.error || 'Lock failed' })
|
|
|
|
return json(200, { success: true, userId, token, amount: amt, ...parsed })
|
|
} catch (error) {
|
|
console.error('lock-escrow error:', error)
|
|
return json(500, { success: false, error: 'Internal server error' })
|
|
}
|
|
})
|