From 3c5970b711a9aabdafb99498eb8b194971367212 Mon Sep 17 00:00:00 2001 From: Satoshi Qazi Muhammed Date: Thu, 1 Oct 2026 17:03:18 +0400 Subject: [PATCH] Accept the mobile wallet's message signature in identity auth verifyWalletSignature checked a signature only through @pezkuwi/util-crypto, whose sr25519 uses Pezkuwi's `bizinikiwi` signing context. The browser extension signs in that context. 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. Transactions are unaffected, since their payload carries the genesis hash. Measured on 2026-10-01 on dks.news, which verifies the same way: every sign-in from the app was refused with 401, and the captured signature verifies in the substrate context and in no other. This verifier has the same shape and its tests sign only with @pezkuwi/keyring, so the mobile path was never exercised here -- withdrawal and payment confirmations, identity and citizenship signed from the app would be refused the same way. The wallet is the released, store-tested build and is not changed for this. The verifier now also accepts an sr25519 signature in the substrate context, via @scure/sr25519, the upstream that @pezkuwi/scure-sr25519 is forked from with only the context string changed. The proof is unchanged: the account's own key over a canonical, single-use challenge. Tests: a substrate-context signature passes; the same signature claimed for another address, or over a different message, is refused. With the fallback disabled the first fails (26/27); restored, 27/27. --- .../_shared/__tests__/identity-auth.test.ts | 22 +++++++++++++++++++ .../functions/_shared/identity-auth.ts | 17 +++++++++++--- 2 files changed, 36 insertions(+), 3 deletions(-) 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 }