diff --git a/web/supabase/functions/_shared/__tests__/identity-auth.test.ts b/web/supabase/functions/_shared/__tests__/identity-auth.test.ts index ffab3d97..55dfb3f1 100644 --- a/web/supabase/functions/_shared/__tests__/identity-auth.test.ts +++ b/web/supabase/functions/_shared/__tests__/identity-auth.test.ts @@ -15,6 +15,7 @@ import { 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 * as substrateSr25519 from 'npm:@scure/sr25519@2.4.0' import { verifyWalletSignature, @@ -65,6 +66,27 @@ Deno.test('verifyWalletSignature: signature over a DIFFERENT message is rejected assertEquals(await verifyWalletSignature('message-B', sig, alice.address), false) }) +// The Pezkuwi Wallet mobile app signs a message in the standard `substrate` sr25519 +// context, not Pezkuwi's `bizinikiwi` (measured 2026-10-01). The keyring above signs +// like the extension, so without these the mobile path was never exercised. +const phone = substrateSr25519.secretFromSeed(crypto.getRandomValues(new Uint8Array(32))) +const phoneAddress = encodeAddress(substrateSr25519.getPublicKey(phone), 42) +const signAsPhone = (msg: string) => u8aToHex(substrateSr25519.sign(phone, u8aWrapBytes(msg))) + +Deno.test('verifyWalletSignature: mobile-wallet signature (substrate context) passes', async () => { + const msg = 'Pezkuwi P2P Withdrawal\ntoken:HEZ' + assert(await verifyWalletSignature(msg, signAsPhone(msg), phoneAddress)) +}) + +Deno.test('verifyWalletSignature: substrate-context signature claimed for another address is rejected', async () => { + const msg = 'authorize me' + assertEquals(await verifyWalletSignature(msg, signAsPhone(msg), alice.address), false) +}) + +Deno.test('verifyWalletSignature: substrate-context signature over a DIFFERENT message is rejected', async () => { + assertEquals(await verifyWalletSignature('message-B', signAsPhone('message-A'), phoneAddress), 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) diff --git a/web/supabase/functions/_shared/identity-auth.ts b/web/supabase/functions/_shared/identity-auth.ts index a58cc1cc..6bc4424e 100644 --- a/web/supabase/functions/_shared/identity-auth.ts +++ b/web/supabase/functions/_shared/identity-auth.ts @@ -16,8 +16,9 @@ // The client-supplied `userId` is NEVER trusted for authorization. Every // caller can only ever act on the balance of the identity their wallet owns. -import { signatureVerify, cryptoWaitReady } from 'npm:@pezkuwi/util-crypto@14.0.25' -import { stringToU8a, u8aWrapBytes } from 'npm:@pezkuwi/util@14.0.25' +import { signatureVerify, cryptoWaitReady, decodeAddress } from 'npm:@pezkuwi/util-crypto@14.0.25' +import { hexToU8a, stringToU8a, u8aWrapBytes } from 'npm:@pezkuwi/util@14.0.25' +import { verify as sr25519VerifySubstrateContext } from 'npm:@scure/sr25519@2.4.0' import type { ApiPromise } from 'npm:@pezkuwi/api@16.5.36' import type { SupabaseClient } from 'npm:@supabase/supabase-js@2' @@ -87,7 +88,17 @@ export async function verifyWalletSignature( const res = signatureVerify(candidate, signature, signerAddress) if (res.isValid) return true } - return false + // Pezkuwi's sr25519 signs in the `bizinikiwi` context, and the extension does. + // The Pezkuwi Wallet mobile app does not, for a message: it picks the signing + // chain from the address prefix, prefix 42 falls back to Polkadot, and Polkadot + // gets the standard `substrate` context (measured 2026-10-01 on dks.news, where + // every mobile sign-in was refused for this alone). The wallet is not changed + // for it, so the verifier accepts both: the proof is the same -- the account's + // own key over a canonical, single-use challenge. + const publicKey = decodeAddress(signerAddress) + const sig = hexToU8a(signature) + if (publicKey.length !== 32 || sig.length !== 64) return false + return [raw, wrapped].some((candidate) => sr25519VerifySubstrateContext(candidate, sig, publicKey)) } catch (_e) { return false }