Files
pwap/web/supabase/functions/_shared/__tests__/identity-auth.test.ts
T
pezkuwichain 25ed29edcb security(p2p): freeze trade/offer fund-routing columns + atomic release (2 verify-found bypasses) + tests
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.
2026-07-25 02:44:02 -07:00

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