diff --git a/.changeset/fresh-redirects-dance.md b/.changeset/fresh-redirects-dance.md new file mode 100644 index 000000000..d01d28799 --- /dev/null +++ b/.changeset/fresh-redirects-dance.md @@ -0,0 +1,5 @@ +--- +'@0xsequence/dapp-client': patch +--- + +Fix redirect transport payload encoding so Unicode characters are handled correctly in redirect requests and responses. diff --git a/packages/wallet/dapp-client/src/DappTransport.ts b/packages/wallet/dapp-client/src/DappTransport.ts index 090b1070b..44f8492f6 100644 --- a/packages/wallet/dapp-client/src/DappTransport.ts +++ b/packages/wallet/dapp-client/src/DappTransport.ts @@ -14,9 +14,26 @@ import { const isBrowserEnvironment = typeof window !== 'undefined' && typeof document !== 'undefined' +const bytesToBinaryString = (bytes: Uint8Array) => { + let binary = '' + const chunkSize = 0x8000 + for (let i = 0; i < bytes.length; i += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(i, i + chunkSize)) + } + return binary +} + +const binaryStringToBytes = (value: string) => { + const bytes = new Uint8Array(value.length) + for (let i = 0; i < value.length; i += 1) { + bytes[i] = value.charCodeAt(i) + } + return bytes +} + const base64Encode = (value: string) => { - if (typeof btoa !== 'undefined') { - return btoa(value) + if (typeof btoa !== 'undefined' && typeof TextEncoder !== 'undefined') { + return btoa(bytesToBinaryString(new TextEncoder().encode(value))) } if (typeof Buffer !== 'undefined') { return Buffer.from(value, 'utf-8').toString('base64') @@ -25,8 +42,13 @@ const base64Encode = (value: string) => { } const base64Decode = (value: string) => { - if (typeof atob !== 'undefined') { - return atob(value) + if (typeof atob !== 'undefined' && typeof TextDecoder !== 'undefined') { + const decoded = atob(value) + try { + return new TextDecoder('utf-8', { fatal: true }).decode(binaryStringToBytes(decoded)) + } catch { + return decoded + } } if (typeof Buffer !== 'undefined') { return Buffer.from(value, 'base64').toString('utf-8') diff --git a/packages/wallet/dapp-client/test/DappTransport.test.ts b/packages/wallet/dapp-client/test/DappTransport.test.ts new file mode 100644 index 000000000..9bacdc1d3 --- /dev/null +++ b/packages/wallet/dapp-client/test/DappTransport.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from 'vitest' + +import { DappTransport } from '../src/DappTransport.js' +import { TransportMode } from '../src/types/index.js' + +const encodeBase64Utf8 = (value: string) => { + let binary = '' + for (const byte of new TextEncoder().encode(value)) { + binary += String.fromCharCode(byte) + } + return btoa(binary) +} + +const decodeBase64Utf8 = (value: string) => { + const binary = atob(value) + const bytes = new Uint8Array(binary.length) + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i) + } + return new TextDecoder().decode(bytes) +} + +const createSessionStorage = () => { + const values = new Map() + return { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => { + values.set(key, value) + }, + removeItem: (key: string) => { + values.delete(key) + }, + } +} + +describe('DappTransport redirect URLs', () => { + it('encodes unicode payloads as UTF-8 base64', async () => { + const transport = new DappTransport('https://wallet.example', TransportMode.REDIRECT, {}, createSessionStorage()) + const payload = { message: 'Sign in to Sequence 🌍' } + + const redirectUrl = await transport.getRequestRedirectUrl('signMessage', payload, 'https://dapp.example/callback') + const encodedPayload = new URL(redirectUrl).searchParams.get('payload') + + if (!encodedPayload) { + throw new Error('Expected redirect URL to include a payload') + } + expect(JSON.parse(decodeBase64Utf8(encodedPayload))).toEqual(payload) + }) + + it('decodes unicode redirect response payloads', async () => { + const storage = createSessionStorage() + const transport = new DappTransport('https://wallet.example', TransportMode.REDIRECT, {}, storage) + const requestUrl = await transport.getRequestRedirectUrl('signMessage', {}, 'https://dapp.example/callback') + const id = new URL(requestUrl).searchParams.get('id') + const payload = { message: 'Signed by Sequence 🌍' } + const responseUrl = new URL('https://dapp.example/callback') + + if (!id) { + throw new Error('Expected redirect URL to include an id') + } + responseUrl.searchParams.set('id', id) + responseUrl.searchParams.set('payload', encodeBase64Utf8(JSON.stringify(payload))) + + await expect(transport.getRedirectResponse(false, responseUrl.toString())).resolves.toEqual({ + action: 'signMessage', + payload, + }) + }) + + it('decodes legacy Latin-1 redirect response payloads', async () => { + const storage = createSessionStorage() + const transport = new DappTransport('https://wallet.example', TransportMode.REDIRECT, {}, storage) + const requestUrl = await transport.getRequestRedirectUrl('signMessage', {}, 'https://dapp.example/callback') + const id = new URL(requestUrl).searchParams.get('id') + const payload = { message: 'Signed by Sequence Café' } + const responseUrl = new URL('https://dapp.example/callback') + + if (!id) { + throw new Error('Expected redirect URL to include an id') + } + responseUrl.searchParams.set('id', id) + responseUrl.searchParams.set('payload', btoa(JSON.stringify(payload))) + + await expect(transport.getRedirectResponse(false, responseUrl.toString())).resolves.toEqual({ + action: 'signMessage', + payload, + }) + }) +})