mirror of
https://github.com/pezkuwichain/pwap.git
synced 2026-07-26 17:45:42 +00:00
25ed29edcb
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.
299 lines
14 KiB
TypeScript
299 lines
14 KiB
TypeScript
// Unit tests for the fund-custody authorization boundary (_shared/identity-auth.ts).
|
|
//
|
|
// Run: deno test -A web/supabase/functions/_shared/__tests__/identity-auth.test.ts
|
|
//
|
|
// These exercise the REAL module (real sr25519 sign/verify via @pezkuwi/*), with
|
|
// the Supabase client and People-Chain API mocked. They prove the gates that the
|
|
// edge functions compose: signature verification, canonical money-bound
|
|
// challenges, identity-ownership, single-use nonces and timestamp freshness.
|
|
|
|
import {
|
|
assertEquals,
|
|
assert,
|
|
assertNotEquals,
|
|
} from 'https://deno.land/std@0.224.0/assert/mod.ts'
|
|
import { Keyring } from 'npm:@pezkuwi/keyring@14.0.25'
|
|
import { cryptoWaitReady, encodeAddress } from 'npm:@pezkuwi/util-crypto@14.0.25'
|
|
import { stringToU8a, u8aToHex, u8aWrapBytes } from 'npm:@pezkuwi/util@14.0.25'
|
|
|
|
import {
|
|
verifyWalletSignature,
|
|
buildWithdrawChallenge,
|
|
buildConfirmPaymentChallenge,
|
|
buildLockEscrowChallenge,
|
|
buildDepositChallenge,
|
|
buildAdminChallenge,
|
|
assertIdentityOwnedByWallet,
|
|
consumeNonce,
|
|
isFreshTimestamp,
|
|
generateCitizenNumber,
|
|
identityToUUID,
|
|
CHALLENGE_MAX_AGE_MS,
|
|
} from '../identity-auth.ts'
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Test key material
|
|
// ---------------------------------------------------------------------------
|
|
await cryptoWaitReady()
|
|
const kr = new Keyring({ type: 'sr25519' })
|
|
const alice = kr.addFromUri('//Alice')
|
|
const bob = kr.addFromUri('//Bob')
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// verifyWalletSignature
|
|
// ---------------------------------------------------------------------------
|
|
Deno.test('verifyWalletSignature: valid raw signature passes', async () => {
|
|
const msg = 'Pezkuwi P2P Withdrawal\ntoken:HEZ'
|
|
const sig = u8aToHex(alice.sign(stringToU8a(msg)))
|
|
assert(await verifyWalletSignature(msg, sig, alice.address))
|
|
})
|
|
|
|
Deno.test('verifyWalletSignature: <Bytes>-wrapped signature passes (extension signRaw form)', async () => {
|
|
const msg = 'Pezkuwi P2P Confirm Payment\ntrade:abc'
|
|
const sig = u8aToHex(alice.sign(u8aWrapBytes(msg)))
|
|
assert(await verifyWalletSignature(msg, sig, alice.address))
|
|
})
|
|
|
|
Deno.test('verifyWalletSignature: wrong signer is rejected', async () => {
|
|
const msg = 'authorize me'
|
|
const sig = u8aToHex(alice.sign(stringToU8a(msg))) // signed by Alice
|
|
assertEquals(await verifyWalletSignature(msg, sig, bob.address), false)
|
|
})
|
|
|
|
Deno.test('verifyWalletSignature: signature over a DIFFERENT message is rejected (no malleability/reuse)', async () => {
|
|
const sig = u8aToHex(alice.sign(stringToU8a('message-A')))
|
|
assertEquals(await verifyWalletSignature('message-B', sig, alice.address), false)
|
|
})
|
|
|
|
Deno.test('verifyWalletSignature: empty / malformed signatures return false (no throw)', async () => {
|
|
assertEquals(await verifyWalletSignature('m', '', alice.address), false)
|
|
assertEquals(await verifyWalletSignature('m', '0x', alice.address), false)
|
|
assertEquals(await verifyWalletSignature('m', 'not-hex', alice.address), false)
|
|
assertEquals(await verifyWalletSignature('m', '0xdeadbeef', alice.address), false)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Canonical challenge builders — money-param binding
|
|
// ---------------------------------------------------------------------------
|
|
Deno.test('buildWithdrawChallenge: canonical shape + binds token/amount/destination/nonce', () => {
|
|
const base = { identityId: 'V-100001', token: 'HEZ', amount: 5, destination: '5DEST', signerAddress: '5SIGN', timestamp: 111, nonce: 'n-1234abcd' }
|
|
const c = buildWithdrawChallenge(base)
|
|
assertEquals(c.split('\n')[0], 'Pezkuwi P2P Withdrawal')
|
|
assert(c.includes('token:HEZ'))
|
|
assert(c.includes('amount:5'))
|
|
assert(c.includes('destination:5DEST'))
|
|
// Any change to a money field changes the signed bytes:
|
|
assertNotEquals(c, buildWithdrawChallenge({ ...base, token: 'PEZ' }))
|
|
assertNotEquals(c, buildWithdrawChallenge({ ...base, amount: 6 }))
|
|
assertNotEquals(c, buildWithdrawChallenge({ ...base, destination: '5EVIL' }))
|
|
assertNotEquals(c, buildWithdrawChallenge({ ...base, nonce: 'n-otherabcd' }))
|
|
})
|
|
|
|
Deno.test('buildConfirmPaymentChallenge: binds tradeId + seller identity', () => {
|
|
const base = { tradeId: 'T1', sellerIdentity: 'V-1', signerAddress: '5S', timestamp: 1, nonce: 'nnnnnnnn' }
|
|
const c = buildConfirmPaymentChallenge(base)
|
|
assertEquals(c.split('\n')[0], 'Pezkuwi P2P Confirm Payment')
|
|
assertNotEquals(c, buildConfirmPaymentChallenge({ ...base, tradeId: 'T2' }))
|
|
assertNotEquals(c, buildConfirmPaymentChallenge({ ...base, sellerIdentity: 'V-2' }))
|
|
})
|
|
|
|
Deno.test('buildLockEscrowChallenge: binds token + amount', () => {
|
|
const base = { identityId: 'V-1', token: 'HEZ', amount: 10, signerAddress: '5S', timestamp: 1, nonce: 'nnnnnnnn' }
|
|
const c = buildLockEscrowChallenge(base)
|
|
assertEquals(c.split('\n')[0], 'Pezkuwi P2P Lock Escrow')
|
|
assertNotEquals(c, buildLockEscrowChallenge({ ...base, amount: 11 }))
|
|
assertNotEquals(c, buildLockEscrowChallenge({ ...base, token: 'PEZ' }))
|
|
})
|
|
|
|
Deno.test('challenge prefixes are distinct — a signature for one action cannot satisfy another', () => {
|
|
const t = 1, n = 'nnnnnnnn', s = '5S'
|
|
const prefixes = new Set([
|
|
buildWithdrawChallenge({ identityId: 'i', token: 'HEZ', amount: 1, destination: 'd', signerAddress: s, timestamp: t, nonce: n }).split('\n')[0],
|
|
buildConfirmPaymentChallenge({ tradeId: 'x', sellerIdentity: 'i', signerAddress: s, timestamp: t, nonce: n }).split('\n')[0],
|
|
buildLockEscrowChallenge({ identityId: 'i', token: 'HEZ', amount: 1, signerAddress: s, timestamp: t, nonce: n }).split('\n')[0],
|
|
buildDepositChallenge({ identityId: 'i', token: 'HEZ', txHash: '0x', signerAddress: s, timestamp: t, nonce: n }).split('\n')[0],
|
|
buildAdminChallenge({ action: 'resolve', disputeId: 'd', tradeId: 'x', decision: 'split', adminAddress: s, timestamp: t, nonce: n }).split('\n')[0],
|
|
])
|
|
assertEquals(prefixes.size, 5) // all unique
|
|
})
|
|
|
|
Deno.test('end-to-end: a withdraw signature does NOT verify against a tampered amount challenge', async () => {
|
|
const p = { identityId: 'V-1', token: 'HEZ', amount: 5, destination: '5DEST', signerAddress: alice.address, timestamp: Date.now(), nonce: 'wd-abcdefgh' }
|
|
const sig = u8aToHex(alice.sign(u8aWrapBytes(buildWithdrawChallenge(p))))
|
|
// Attacker replays the signature but bumps the amount -> server rebuilds a
|
|
// different challenge -> verification fails.
|
|
const tampered = buildWithdrawChallenge({ ...p, amount: 5000 })
|
|
assertEquals(await verifyWalletSignature(tampered, sig, alice.address), false)
|
|
// sanity: the untampered challenge does verify
|
|
assert(await verifyWalletSignature(buildWithdrawChallenge(p), sig, alice.address))
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// identityToUUID / generateCitizenNumber
|
|
// ---------------------------------------------------------------------------
|
|
Deno.test('identityToUUID: deterministic + distinct per identity', async () => {
|
|
const a = await identityToUUID('V-100001')
|
|
const b = await identityToUUID('V-100001')
|
|
const c = await identityToUUID('V-100002')
|
|
assertEquals(a, b)
|
|
assertNotEquals(a, c)
|
|
assert(/^[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(a), 'valid v5 uuid shape')
|
|
})
|
|
|
|
Deno.test('generateCitizenNumber: deterministic 6-digit tied to (address, collection, item)', () => {
|
|
const n = generateCitizenNumber(alice.address, 42, 7)
|
|
assertEquals(n, generateCitizenNumber(alice.address, 42, 7))
|
|
assertEquals(n.length, 6)
|
|
// Different owner or item generally yields a different number.
|
|
assertNotEquals(generateCitizenNumber(alice.address, 42, 7), generateCitizenNumber(bob.address, 42, 7))
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mock Supabase client
|
|
// ---------------------------------------------------------------------------
|
|
type VisaRow = { visa_number: string; wallet_address: string; status: string }
|
|
|
|
function makeSupabaseMock(opts: { visaRows?: VisaRow[]; nonceStore?: Set<string> } = {}) {
|
|
const visaRows = opts.visaRows ?? []
|
|
const nonceStore = opts.nonceStore ?? new Set<string>()
|
|
return {
|
|
nonceStore,
|
|
from(table: string) {
|
|
if (table === 'p2p_visa') {
|
|
const filters: Record<string, string> = {}
|
|
const builder: any = {
|
|
select() { return builder },
|
|
eq(col: string, val: string) { filters[col] = val; return builder },
|
|
async maybeSingle() {
|
|
const row = visaRows.find(r =>
|
|
r.visa_number === filters['visa_number'] &&
|
|
r.wallet_address === filters['wallet_address'] &&
|
|
r.status === filters['status'])
|
|
return { data: row ?? null, error: null }
|
|
},
|
|
}
|
|
return builder
|
|
}
|
|
if (table === 'p2p_challenge_nonces') {
|
|
return {
|
|
async insert(row: { nonce: string; purpose: string }) {
|
|
if (nonceStore.has(row.nonce)) {
|
|
return { error: { code: '23505', message: 'duplicate key value violates unique constraint' } }
|
|
}
|
|
nonceStore.add(row.nonce)
|
|
return { error: null }
|
|
},
|
|
}
|
|
}
|
|
throw new Error(`unexpected table ${table}`)
|
|
},
|
|
} as any
|
|
}
|
|
|
|
// People-chain API mock. `ownedItem === null` => wallet owns no citizen NFT.
|
|
function makePeopleApi(ownedItem: number | null) {
|
|
const res = ownedItem === null
|
|
? { isEmpty: true }
|
|
: { isEmpty: false, isSome: true, unwrap: () => ({ toNumber: () => ownedItem }) }
|
|
return async () => ({ query: { tiki: { citizenNft: async (_addr: string) => res } } }) as any
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// assertIdentityOwnedByWallet — visa
|
|
// ---------------------------------------------------------------------------
|
|
Deno.test('ownership(visa): active visa bound to signer -> ok', async () => {
|
|
const sb = makeSupabaseMock({ visaRows: [{ visa_number: 'V-100001', wallet_address: alice.address, status: 'active' }] })
|
|
const r = await assertIdentityOwnedByWallet(sb, 'V-100001', alice.address, makePeopleApi(null))
|
|
assertEquals(r.ok, true)
|
|
assertEquals(r.kind, 'visa')
|
|
})
|
|
|
|
Deno.test('ownership(visa): signer B cannot act on identity A (visa bound to A) -> rejected', async () => {
|
|
const sb = makeSupabaseMock({ visaRows: [{ visa_number: 'V-100001', wallet_address: alice.address, status: 'active' }] })
|
|
const r = await assertIdentityOwnedByWallet(sb, 'V-100001', bob.address, makePeopleApi(null))
|
|
assertEquals(r.ok, false)
|
|
})
|
|
|
|
Deno.test('ownership(visa): inactive visa -> rejected', async () => {
|
|
const sb = makeSupabaseMock({ visaRows: [{ visa_number: 'V-100001', wallet_address: alice.address, status: 'revoked' }] })
|
|
const r = await assertIdentityOwnedByWallet(sb, 'V-100001', alice.address, makePeopleApi(null))
|
|
assertEquals(r.ok, false)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// assertIdentityOwnedByWallet — citizen
|
|
// ---------------------------------------------------------------------------
|
|
Deno.test('ownership(citizen): chain owns item + derivation matches -> ok', async () => {
|
|
const item = 7
|
|
const six = generateCitizenNumber(alice.address, 42, item)
|
|
const sb = makeSupabaseMock()
|
|
const r = await assertIdentityOwnedByWallet(sb, `#42-${item}-${six}`, alice.address, makePeopleApi(item))
|
|
assertEquals(r.ok, true)
|
|
assertEquals(r.kind, 'citizen')
|
|
})
|
|
|
|
Deno.test('ownership(citizen): claimed item != chain-owned item -> rejected (no NFT theft)', async () => {
|
|
const claimedItem = 7
|
|
const six = generateCitizenNumber(alice.address, 42, claimedItem)
|
|
const sb = makeSupabaseMock()
|
|
// Chain says alice actually owns item 9, not the claimed 7.
|
|
const r = await assertIdentityOwnedByWallet(sb, `#42-${claimedItem}-${six}`, alice.address, makePeopleApi(9))
|
|
assertEquals(r.ok, false)
|
|
})
|
|
|
|
Deno.test('ownership(citizen): correct item but forged 6-digit -> rejected', async () => {
|
|
const item = 7
|
|
const sb = makeSupabaseMock()
|
|
const r = await assertIdentityOwnedByWallet(sb, `#42-${item}-000000`, alice.address, makePeopleApi(item))
|
|
assertEquals(r.ok, false)
|
|
})
|
|
|
|
Deno.test('ownership(citizen): wallet owns no NFT -> rejected', async () => {
|
|
const item = 7
|
|
const six = generateCitizenNumber(alice.address, 42, item)
|
|
const sb = makeSupabaseMock()
|
|
const r = await assertIdentityOwnedByWallet(sb, `#42-${item}-${six}`, alice.address, makePeopleApi(null))
|
|
assertEquals(r.ok, false)
|
|
})
|
|
|
|
Deno.test('ownership: malformed identity / wrong collection -> rejected', async () => {
|
|
const sb = makeSupabaseMock()
|
|
assertEquals((await assertIdentityOwnedByWallet(sb, 'garbage', alice.address, makePeopleApi(1))).ok, false)
|
|
assertEquals((await assertIdentityOwnedByWallet(sb, '#1-2-345678', alice.address, makePeopleApi(2))).ok, false) // collection != 42
|
|
assertEquals((await assertIdentityOwnedByWallet(sb, '', alice.address, makePeopleApi(1))).ok, false)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// consumeNonce — single use / replay
|
|
// ---------------------------------------------------------------------------
|
|
Deno.test('consumeNonce: first use ok, second use (replay) rejected', async () => {
|
|
const sb = makeSupabaseMock()
|
|
assertEquals(await consumeNonce(sb, 'wd-11112222', 'withdraw'), true)
|
|
assertEquals(await consumeNonce(sb, 'wd-11112222', 'withdraw'), false) // replay
|
|
})
|
|
|
|
Deno.test('consumeNonce: concurrent double-spend of one nonce yields exactly one success', async () => {
|
|
const sb = makeSupabaseMock()
|
|
const [a, b] = await Promise.all([
|
|
consumeNonce(sb, 'wd-race0001', 'withdraw'),
|
|
consumeNonce(sb, 'wd-race0001', 'withdraw'),
|
|
])
|
|
assertEquals([a, b].filter(Boolean).length, 1)
|
|
})
|
|
|
|
Deno.test('consumeNonce: too-short nonce rejected', async () => {
|
|
const sb = makeSupabaseMock()
|
|
assertEquals(await consumeNonce(sb, 'short', 'withdraw'), false)
|
|
})
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// isFreshTimestamp
|
|
// ---------------------------------------------------------------------------
|
|
Deno.test('isFreshTimestamp: now is fresh; expired and non-finite rejected', () => {
|
|
assert(isFreshTimestamp(Date.now()))
|
|
assertEquals(isFreshTimestamp(Date.now() - CHALLENGE_MAX_AGE_MS - 5000), false)
|
|
assertEquals(isFreshTimestamp(Date.now() + 120_000), false) // beyond 60s skew
|
|
assertEquals(isFreshTimestamp(NaN), false)
|
|
assertEquals(isFreshTimestamp(Infinity), false)
|
|
})
|