diff --git a/.changeset/eleven-badgers-smile.md b/.changeset/eleven-badgers-smile.md new file mode 100644 index 000000000..8d682c83f --- /dev/null +++ b/.changeset/eleven-badgers-smile.md @@ -0,0 +1,5 @@ +--- +"@telegram-apps/init-data-node": minor +--- + +Add utilities related to 3-rd party validation. Git rid of Node.js `Buffer`. diff --git a/.changeset/mean-tomatoes-search.md b/.changeset/mean-tomatoes-search.md new file mode 100644 index 000000000..e6d0c8a26 --- /dev/null +++ b/.changeset/mean-tomatoes-search.md @@ -0,0 +1,5 @@ +--- +"@telegram-apps/types": minor +--- + +Define InitData.signature. diff --git a/.changeset/perfect-icons-kiss.md b/.changeset/perfect-icons-kiss.md new file mode 100644 index 000000000..e76502b80 --- /dev/null +++ b/.changeset/perfect-icons-kiss.md @@ -0,0 +1,5 @@ +--- +"@telegram-apps/transformers": minor +--- + +Add InitData.signature transformer. diff --git a/apps/docs/packages/telegram-apps-init-data-node.md b/apps/docs/packages/telegram-apps-init-data-node.md index 2631b6995..92bb63c35 100644 --- a/apps/docs/packages/telegram-apps-init-data-node.md +++ b/apps/docs/packages/telegram-apps-init-data-node.md @@ -1,3 +1,7 @@ +--- +outline: [2, 3] +--- + # @telegram-apps/init-data-node

@@ -133,6 +137,45 @@ duration is set to 1 day (86,400 seconds). It is recommended to always check the initialization data, as it could be stolen but still remain valid. To disable this feature, pass `{ expiresIn: 0 }` as the third argument. +### `validate3rd` + +The `validate3rd` function is used to check if the passed init data was signed by Telegram. As +well as the `validate` function, this one accepts the init data in the same format. + +As the second argument, it accepts the Telegram Bot identifier that was used to sign this +init data. + +The third argument is an object with the following properties: + +- `expiresIn` is responsible for init data expiration validation +- `test: boolean`: should be equal `true` if the init data was received in the test Telegram + environment + +Here is the usage example: + +```ts +const initData = + 'user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%20%2B%20-%20%3F%20%5C%2F%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%2C%22photo_url%22%3A%22https%3A%5C%2F%5C%2Ft.me%5C%2Fi%5C%2Fuserpic%5C%2F320%5C%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%7D' + + '&chat_instance=8134722200314281151' + + '&chat_type=private' + + '&auth_date=1733584787' + + '&signature=zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ' + + '&hash=2174df5b000556d044f3f020384e879c8efcab55ddea2ced4eb752e93e7080d6'; +const botId = 7342037359; + +await validate3rd(initData, botId); +``` + +Function will throw an error in one of these cases: + +- `ERR_AUTH_DATE_INVALID`: `auth_date` is empty or not found +- `ERR_SIGNATURE_MISSING`: `signature` is empty or not found +- `ERR_SIGN_INVALID`: signature is invalid +- `ERR_EXPIRED`: init data expired + +> [!WARNING] +> This function uses **Web Crypto API** instead of Node.js modules. + ### `isValid` Alternatively, to check the init data validity, a developer could use the `isValid` function. @@ -141,11 +184,26 @@ It doesn't throw an error, but returns a boolean value indicating the init data ```ts import { isValid } from '@telegram-apps/init-data-node'; -if (isValid('init-data')) { +if (isValid('init-data', 'my-bot-token')) { console.log('Init data is fine'); } ``` +### `isValid3rd` + +Does the same as the `isValid` function, but checks if the init data was signed by Telegram: + +```ts +import { isValid3rd } from '@telegram-apps/init-data-node'; + +if (await isValid3rd('init-data')) { + console.log('Init data is fine'); +} +``` + +> [!WARNING] +> This function uses **Web Crypto API** instead of Node.js modules. + ## Signing There could be some cases when a developer needs to create their own init data. For instance, diff --git a/packages/bridge/src/env/mockTelegramEnv.test.ts b/packages/bridge/src/env/mockTelegramEnv.test.ts index 22568f807..435b9b03e 100644 --- a/packages/bridge/src/env/mockTelegramEnv.test.ts +++ b/packages/bridge/src/env/mockTelegramEnv.test.ts @@ -43,15 +43,16 @@ const lp: LaunchParams = { lastName: 'Rogue', username: 'rogue', }, + signature: 'abc', }, - initDataRaw: 'user=%7B%22id%22%3A99281932%2C%22first_name%22%3A%22Andrew%22%2C%22last_name%22%3A%22Rogue%22%2C%22username%22%3A%22rogue%22%2C%22language_code%22%3A%22en%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%7D&chat_instance=8428209589180549439&chat_type=sender&start_param=debug&auth_date=1716922846&hash=89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31', + initDataRaw: 'user=%7B%22id%22%3A99281932%2C%22first_name%22%3A%22Andrew%22%2C%22last_name%22%3A%22Rogue%22%2C%22username%22%3A%22rogue%22%2C%22language_code%22%3A%22en%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%7D&chat_instance=8428209589180549439&chat_type=sender&start_param=debug&auth_date=1716922846&hash=89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31&signature=abc', version: '7.2', platform: 'tdesktop', botInline: false, showSettings: false }; -const lpString = 'tgWebAppPlatform=tdesktop&tgWebAppThemeParams=%7B%22accent_text_color%22%3A%22%236ab2f2%22%2C%22bg_color%22%3A%22%2317212b%22%2C%22button_color%22%3A%22%235288c1%22%2C%22button_text_color%22%3A%22%23ffffff%22%2C%22destructive_text_color%22%3A%22%23ec3942%22%2C%22header_bg_color%22%3A%22%2317212b%22%2C%22hint_color%22%3A%22%23708499%22%2C%22link_color%22%3A%22%236ab3f3%22%2C%22secondary_bg_color%22%3A%22%23232e3c%22%2C%22section_bg_color%22%3A%22%2317212b%22%2C%22section_header_text_color%22%3A%22%236ab3f3%22%2C%22subtitle_text_color%22%3A%22%23708499%22%2C%22text_color%22%3A%22%23f5f5f5%22%7D&tgWebAppVersion=7.2&tgWebAppData=user%3D%257B%2522id%2522%253A99281932%252C%2522first_name%2522%253A%2522Andrew%2522%252C%2522last_name%2522%253A%2522Rogue%2522%252C%2522username%2522%253A%2522rogue%2522%252C%2522language_code%2522%253A%2522en%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%257D%26chat_instance%3D8428209589180549439%26chat_type%3Dsender%26start_param%3Ddebug%26auth_date%3D1716922846%26hash%3D89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31&tgWebAppShowSettings=0&tgWebAppBotInline=0'; +const lpString = 'tgWebAppPlatform=tdesktop&tgWebAppThemeParams=%7B%22accent_text_color%22%3A%22%236ab2f2%22%2C%22bg_color%22%3A%22%2317212b%22%2C%22button_color%22%3A%22%235288c1%22%2C%22button_text_color%22%3A%22%23ffffff%22%2C%22destructive_text_color%22%3A%22%23ec3942%22%2C%22header_bg_color%22%3A%22%2317212b%22%2C%22hint_color%22%3A%22%23708499%22%2C%22link_color%22%3A%22%236ab3f3%22%2C%22secondary_bg_color%22%3A%22%23232e3c%22%2C%22section_bg_color%22%3A%22%2317212b%22%2C%22section_header_text_color%22%3A%22%236ab3f3%22%2C%22subtitle_text_color%22%3A%22%23708499%22%2C%22text_color%22%3A%22%23f5f5f5%22%7D&tgWebAppVersion=7.2&tgWebAppData=user%3D%257B%2522id%2522%253A99281932%252C%2522first_name%2522%253A%2522Andrew%2522%252C%2522last_name%2522%253A%2522Rogue%2522%252C%2522username%2522%253A%2522rogue%2522%252C%2522language_code%2522%253A%2522en%2522%252C%2522is_premium%2522%253Atrue%252C%2522allows_write_to_pm%2522%253Atrue%257D%26chat_instance%3D8428209589180549439%26chat_type%3Dsender%26start_param%3Ddebug%26auth_date%3D1716922846%26hash%3D89d6079ad6762351f38c6dbbc41bb53048019256a9443988af7a48bcad16ba31%26signature%3Dabc&tgWebAppShowSettings=0&tgWebAppBotInline=0'; it('should save passed launch parameters in session storage', () => { const setItem = mockSessionStorageSetItem(); diff --git a/packages/bridge/src/launch-params/parseLaunchParams.test.ts b/packages/bridge/src/launch-params/parseLaunchParams.test.ts deleted file mode 100644 index 07bd5b45e..000000000 --- a/packages/bridge/src/launch-params/parseLaunchParams.test.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { toSearchParams } from 'test-utils'; -import { expect, it } from 'vitest'; - -import { parseLaunchParams } from './parseLaunchParams.js'; - -const baseLaunchParams = { - tgWebAppPlatform: 'desktop', - tgWebAppThemeParams: {}, - tgWebAppVersion: '7.0', -}; - -it(`should not throw if ${['tgWebAppBotInline', 'tgWebAppData', 'tgWebAppShowSettings', 'tgWebAppStartParam'].join(', ')} parameters are missing`, () => { - expect(() => parseLaunchParams(toSearchParams(baseLaunchParams))).not.toThrow(); -}); - -it('should create "botInline" property from the "tgWebAppBotInline" as boolean', () => { - expect( - parseLaunchParams(toSearchParams({ ...baseLaunchParams, tgWebAppBotInline: false })), - ).toMatchObject({ botInline: false }); - expect( - () => parseLaunchParams(toSearchParams({ ...baseLaunchParams, tgWebAppBotInline: 'str' })), - ).toThrow(); -}); - -it('should create "initData" property from the "tgWebAppData" as init data', () => { - expect( - parseLaunchParams(toSearchParams({ - ...baseLaunchParams, - tgWebAppData: toSearchParams({ auth_date: 1, hash: 'abc' }), - })), - ).toMatchObject({ - initData: { - authDate: new Date(1000), - hash: 'abc', - }, - }); - // TODO: err -}); - -it('should create "initDataRaw" property from the "tgWebAppData" as string', () => { - expect( - parseLaunchParams(toSearchParams({ - ...baseLaunchParams, - tgWebAppData: toSearchParams({ auth_date: 1, hash: 'abc' }), - })), - ).toMatchObject({ initDataRaw: 'auth_date=1&hash=abc' }); - // todo: err -}); - -it('should create "platform" property from the "tgWebAppPlatform" as string', () => { - expect( - parseLaunchParams(toSearchParams({ ...baseLaunchParams, tgWebAppPlatform: 'tdesktop' })), - ).toMatchObject({ platform: 'tdesktop' }); -}); - -it('should create "showSettings" property from the "tgWebAppShowSettings" as boolean', () => { - expect( - parseLaunchParams(toSearchParams({ ...baseLaunchParams, tgWebAppShowSettings: false })), - ).toMatchObject({ showSettings: false }); - expect( - () => parseLaunchParams(toSearchParams({ ...baseLaunchParams, tgWebAppShowSettings: {} })), - ).toThrow(); -}); - -it('should create "startParam" property from the "tgWebAppPlatform" as string', () => { - expect( - parseLaunchParams(toSearchParams({ ...baseLaunchParams, tgWebAppStartParam: 'start-param' })), - ).toMatchObject({ startParam: 'start-param' }); -}); - -it('should create "themeParams" property from the "tgWebAppThemeParams" as theme params', () => { - expect( - parseLaunchParams(toSearchParams({ - ...baseLaunchParams, - tgWebAppThemeParams: JSON.stringify({ bg_color: '#000' }), - })), - ).toMatchObject({ - themeParams: { - bgColor: '#000000', - }, - }); - expect( - () => parseLaunchParams(toSearchParams({ ...baseLaunchParams, tgWebAppThemeParams: '' })), - ).toThrow(); -}); diff --git a/packages/bridge/src/utils/request.test.ts b/packages/bridge/src/utils/request.test.ts index de109bebb..34f9962ee 100644 --- a/packages/bridge/src/utils/request.test.ts +++ b/packages/bridge/src/utils/request.test.ts @@ -85,18 +85,18 @@ describe('options', () => { expect(globalPostEvent).toHaveBeenCalledWith('web_app_request_phone', undefined); }); - it('should reject promise if postEvent threw an error', () => { + it('should reject promise if postEvent threw an error', async () => { const promise = request('web_app_request_phone', 'phone_requested', { postEvent() { throw new Error('Nope!'); }, }); - void expect(promise).rejects.toStrictEqual(new Error('Nope!')); + await expect(promise).rejects.toStrictEqual(new Error('Nope!')); }); }); describe('capture', () => { - it('should capture an event in case, capture method returned true', () => { + it('should capture an event in case, capture method returned true', async () => { const promise = request('web_app_request_phone', 'phone_requested', { timeout: 1000, capture: ({ status }) => status === 'allowed', diff --git a/packages/init-data-node/src/converters/arrayBufferToHex.ts b/packages/init-data-node/src/converters/arrayBufferToHex.ts new file mode 100644 index 000000000..05bac6bbb --- /dev/null +++ b/packages/init-data-node/src/converters/arrayBufferToHex.ts @@ -0,0 +1,10 @@ +/** + * Converts array buffer to hex. + * @param buffer - buffer to convert + */ +export function arrayBufferToHex(buffer: ArrayBuffer): string { + return new Uint8Array(buffer).reduce((acc, byte) => { + // Convert byte to hex and pad with zero if needed (e.g., "0a" instead of "a") + return acc + byte.toString(16).padStart(2, '0'); + }, ''); +} diff --git a/packages/init-data-node/src/converters/hexToArrayBuffer.ts b/packages/init-data-node/src/converters/hexToArrayBuffer.ts new file mode 100644 index 000000000..c26566e8d --- /dev/null +++ b/packages/init-data-node/src/converters/hexToArrayBuffer.ts @@ -0,0 +1,17 @@ +/** + * Converts a hex string to ArrayBuffer. + * @param hexString - value to convert. + */ +export function hexToArrayBuffer(hexString: string): ArrayBuffer { + if (hexString.length % 2 !== 0) { + throw new Error('Hex string must have an even number of characters'); + } + + const buffer = new ArrayBuffer(hexString.length / 2); + const uint8Array = new Uint8Array(buffer); + for (let i = 0; i < hexString.length; i += 2) { + uint8Array[i / 2] = parseInt(hexString.substring(i, i + 2), 16); + } + + return buffer; +} \ No newline at end of file diff --git a/packages/init-data-node/src/entries/node.test.ts b/packages/init-data-node/src/entries/node.test.ts index 97f85bdd0..981501177 100644 --- a/packages/init-data-node/src/entries/node.test.ts +++ b/packages/init-data-node/src/entries/node.test.ts @@ -1,55 +1,23 @@ import { describe, expect, it } from 'vitest'; import { TypedError } from '@telegram-apps/toolkit'; -import type { InitData } from '@telegram-apps/types'; -import { validate, sign, signData, isValid } from './node'; +import { validate, sign, signData, isValid, hashToken } from './node'; +import { arrayBufferToHex } from '../converters/arrayBufferToHex'; -const sp = 'auth_date=1&can_send_after=10000&chat=%7B%22id%22%3A1%2C%22type%22%3A%22group%22%2C%22title%22%3A%22chat-title%22%2C%22photo_url%22%3A%22group%22%2C%22username%22%3A%22my-chat%22%7D&chat_instance=888&chat_type=sender&hash=47cfa22e72b887cba90c9cb833c5ea0f599975b6ce7193741844b5c4a4228b40&query_id=QUERY&receiver=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22receiver-first-name%22%2C%22id%22%3A991%2C%22is_bot%22%3Afalse%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22receiver-last-name%22%2C%22photo_url%22%3A%22receiver-photo%22%2C%22username%22%3A%22receiver-username%22%7D&start_param=debug&user=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Afalse%2C%22first_name%22%3A%22user-first-name%22%2C%22id%22%3A222%2C%22is_bot%22%3Atrue%2C%22is_premium%22%3Afalse%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22user-last-name%22%2C%22photo_url%22%3A%22user-photo%22%2C%22username%22%3A%22user-username%22%7D'; +const sp = 'user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%20%2B%20-%20%3F%20%5C%2F%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%2C%22photo_url%22%3A%22https%3A%5C%2F%5C%2Ft.me%5C%2Fi%5C%2Fuserpic%5C%2F320%5C%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%7D&chat_instance=8134722200314281151&chat_type=private&auth_date=1733584787&signature=zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ&hash=2174df5b000556d044f3f020384e879c8efcab55ddea2ced4eb752e93e7080d6'; +const spOld = 'auth_date=1&chat_instance=8134722200314281151&chat_type=private&user=%7B%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22Vladislav+%2B+-+%3F+%2F%22%2C%22id%22%3A279058397%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22Kibenko%22%2C%22photo_url%22%3A%22https%3A%2F%2Ft.me%2Fi%2Fuserpic%2F320%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%2C%22username%22%3A%22vdkfrost%22%7D&signature=zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ&hash=b2e387ba89f433607d2492aad63bb04328b0f3d28585fd149617c8ef129566a2'; const spObject = new URLSearchParams(sp); -const secretToken = '5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8'; -const secretTokenHashed = 'a5c609aa52f63cb5e6d8ceb6e4138726ea82bbc36bb786d64482d445ea38ee5f'; - -const initData: InitData = { - authDate: new Date(1000), - canSendAfter: 10000, - chat: { - id: 1, - type: 'group', - username: 'my-chat', - title: 'chat-title', - photoUrl: 'chat-photo', - }, - chatInstance: '888', - chatType: 'sender', - hash: '47cfa22e72b887cba90c9cb833c5ea0f599975b6ce7193741844b5c4a4228b40', - queryId: 'QUERY', - receiver: { - addedToAttachmentMenu: false, - allowsWriteToPm: true, - firstName: 'receiver-first-name', - id: 991, - isBot: false, - isPremium: true, - languageCode: 'ru', - lastName: 'receiver-last-name', - photoUrl: 'receiver-photo', - username: 'receiver-username', - }, - startParam: 'debug', - user: { - addedToAttachmentMenu: false, - allowsWriteToPm: false, - firstName: 'user-first-name', - id: 222, - isBot: true, - isPremium: false, - languageCode: 'en', - lastName: 'user-last-name', - photoUrl: 'user-photo', - username: 'user-username', - }, -}; +const botId = 7342037359; +const secretToken = `${botId}:AAFZehRPBRs8Seg40oDjTMIW8uTGPuW1zfQ`; +const secretTokenHashed = 'c0881d6d547967540dcb06d7378a2c2b2c79a8f915ae70a4a37a8a04de0da32b'; + +describe('hashToken', () => { + it('should properly hash token', () => { + expect(arrayBufferToHex(hashToken('my-secret-token'))) + .toBe('fe37f490481d351837ed49f3b369c886c61013d6d036656fc3c9c92e163e3477'); + }); +}); describe('isValid', () => { it('should return false if "hash" param is missing', () => { @@ -63,12 +31,10 @@ describe('isValid', () => { it('should return false if parameters are expired', () => { expect(isValid(sp, secretToken, { expiresIn: 1 })).toBe(false); - expect(isValid(initData, secretToken, { expiresIn: 1 })).toBe(false); }); it('should return false if sign is invalid', () => { expect(isValid(sp, `${secretToken}A`, { expiresIn: 0 })).toBe(false); - expect(isValid(initData, `${secretToken}A`, { expiresIn: 0 })).toBe(false); }); it('should return true if init data is valid', () => { @@ -77,15 +43,12 @@ describe('isValid', () => { expect(isValid(sp, secretToken, basicOptions)).toBe(true); expect(isValid(sp, secretTokenHashed, hashedOptions)).toBe(true); - expect(isValid(initData, secretToken, basicOptions)).toBe(true); - expect(isValid(initData, secretTokenHashed, hashedOptions)).toBe(true); - expect(isValid(spObject, secretToken, basicOptions)).toBe(true); expect(isValid(spObject, secretTokenHashed, hashedOptions)).toBe(true); }); it('should return false if "expiresIn" is not passed and parameters were created more than 1 day ago', () => { - expect(isValid(sp, secretToken)).toBe(false); + expect(isValid(spOld, secretToken)).toBe(false); }); }); @@ -109,17 +72,11 @@ describe('validate', () => { expect(() => validate(sp, secretToken, { expiresIn: 1 })).toThrowError( new TypedError('ERR_EXPIRED', 'Init data is expired'), ); - expect(() => validate(initData, secretToken, { expiresIn: 1 })).toThrowError( - new TypedError('ERR_EXPIRED', 'Init data is expired'), - ); }); it('should throw if sign is invalid', () => { expect(() => validate(sp, `${secretToken}A`, { expiresIn: 0 })).toThrowError( - new TypedError('ERR_SIGN_INVALID', 'Sign is invalid') - ); - expect(() => validate(initData, `${secretToken}A`, { expiresIn: 0 })).toThrowError( - new TypedError('ERR_SIGN_INVALID', 'Sign is invalid') + new TypedError('ERR_SIGN_INVALID', 'Sign is invalid'), ); }); @@ -129,16 +86,13 @@ describe('validate', () => { expect(() => validate(sp, secretToken, basicOptions)).not.toThrow(); expect(() => validate(sp, secretTokenHashed, hashedOptions)).not.toThrow(); - expect(() => validate(initData, secretToken, basicOptions)).not.toThrow(); - expect(() => validate(initData, secretTokenHashed, hashedOptions)).not.toThrow(); - expect(() => validate(spObject, secretToken, basicOptions)).not.toThrow(); expect(() => validate(spObject, secretTokenHashed, hashedOptions)).not.toThrow(); }); it('should throw if "expiresIn" is not passed and parameters were created more than 1 day ago', () => { - expect(() => validate(sp, secretToken)).toThrow( - new TypedError('ERR_EXPIRED', 'Init data is expired') + expect(() => validate(spOld, secretToken)).toThrow( + new TypedError('ERR_EXPIRED', 'Init data is expired'), ); }); }); @@ -184,11 +138,12 @@ describe('sign', () => { photoUrl: 'user-photo', username: 'user-username', }, + signature: 'abc', }, '5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8', new Date(1000), ), - ).toBe('auth_date=1&can_send_after=10000&chat=%7B%22id%22%3A1%2C%22type%22%3A%22group%22%2C%22title%22%3A%22chat-title%22%2C%22photo_url%22%3A%22group%22%2C%22username%22%3A%22my-chat%22%7D&chat_instance=888&chat_type=sender&query_id=QUERY&receiver=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22receiver-first-name%22%2C%22id%22%3A991%2C%22is_bot%22%3Afalse%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22receiver-last-name%22%2C%22photo_url%22%3A%22receiver-photo%22%2C%22username%22%3A%22receiver-username%22%7D&start_param=debug&user=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Afalse%2C%22first_name%22%3A%22user-first-name%22%2C%22id%22%3A222%2C%22is_bot%22%3Atrue%2C%22is_premium%22%3Afalse%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22user-last-name%22%2C%22photo_url%22%3A%22user-photo%22%2C%22username%22%3A%22user-username%22%7D&hash=47cfa22e72b887cba90c9cb833c5ea0f599975b6ce7193741844b5c4a4228b40'); + ).toBe('auth_date=1&can_send_after=10000&chat=%7B%22id%22%3A1%2C%22type%22%3A%22group%22%2C%22title%22%3A%22chat-title%22%2C%22photo_url%22%3A%22group%22%2C%22username%22%3A%22my-chat%22%7D&chat_instance=888&chat_type=sender&query_id=QUERY&receiver=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22receiver-first-name%22%2C%22id%22%3A991%2C%22is_bot%22%3Afalse%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22receiver-last-name%22%2C%22photo_url%22%3A%22receiver-photo%22%2C%22username%22%3A%22receiver-username%22%7D&start_param=debug&user=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Afalse%2C%22first_name%22%3A%22user-first-name%22%2C%22id%22%3A222%2C%22is_bot%22%3Atrue%2C%22is_premium%22%3Afalse%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22user-last-name%22%2C%22photo_url%22%3A%22user-photo%22%2C%22username%22%3A%22user-username%22%7D&signature=abc&hash=2213454f386e43228e9642d643d72017eb713e9ca5f8e8470bb66b88d643bcc0'); }); }); @@ -197,5 +152,13 @@ describe('signData', () => { expect(signData('abc', 'my-secret-token')).toBe( '6ecc2e9b51f30dde6877ce374ede54eb626c84e78a5d9a9dcac54d2d248f6bde', ); + expect( + signData( + 'abc', + 'fe37f490481d351837ed49f3b369c886c61013d6d036656fc3c9c92e163e3477', + { tokenHashed: true }, + ), + ) + .toBe('6ecc2e9b51f30dde6877ce374ede54eb626c84e78a5d9a9dcac54d2d248f6bde'); }); }); diff --git a/packages/init-data-node/src/entries/node.ts b/packages/init-data-node/src/entries/node.ts index 8533752ea..02c248343 100644 --- a/packages/init-data-node/src/entries/node.ts +++ b/packages/init-data-node/src/entries/node.ts @@ -7,8 +7,18 @@ import { validate as _validate, type ValidateOptions, type ValidateValue } from import { isValid as _isValid } from '../isValid.js'; import type { CreateHmacFn, SignData, Text } from '../types.js'; +/** + * Converts Text to Node.js Buffer. + * @param text - text to convert + */ +function textToBuffer(text: Text): Buffer { + return Buffer.from(typeof text === 'string' ? text : new Uint8Array(text)); +} + const createHmac: CreateHmacFn = (data, key) => { - return nodeCreateHmac('sha256', key).update(data).digest(); + return nodeCreateHmac('sha256', textToBuffer(key)) + .update(textToBuffer(data)) + .digest(); }; /** @@ -16,7 +26,7 @@ const createHmac: CreateHmacFn = (data, key) => { * @param token - token to hash. */ export function hashToken(token: Text): Buffer { - return _hashToken(token, createHmac); + return Buffer.from(_hashToken(token, createHmac)); } /** diff --git a/packages/init-data-node/src/entries/shared.test.ts b/packages/init-data-node/src/entries/shared.test.ts new file mode 100644 index 000000000..fb7f65d68 --- /dev/null +++ b/packages/init-data-node/src/entries/shared.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest'; +import { TypedError } from '@telegram-apps/toolkit'; + +import { isValid3rd, validate3rd } from './shared'; + +const sp = 'user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%20%2B%20-%20%3F%20%5C%2F%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%2C%22photo_url%22%3A%22https%3A%5C%2F%5C%2Ft.me%5C%2Fi%5C%2Fuserpic%5C%2F320%5C%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%7D&chat_instance=8134722200314281151&chat_type=private&auth_date=1733584787&signature=zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ&hash=2174df5b000556d044f3f020384e879c8efcab55ddea2ced4eb752e93e7080d6'; +const spOld = 'auth_date=1&chat_instance=8134722200314281151&chat_type=private&user=%7B%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22Vladislav+%2B+-+%3F+%2F%22%2C%22id%22%3A279058397%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22Kibenko%22%2C%22photo_url%22%3A%22https%3A%2F%2Ft.me%2Fi%2Fuserpic%2F320%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%2C%22username%22%3A%22vdkfrost%22%7D&signature=zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ&hash=b2e387ba89f433607d2492aad63bb04328b0f3d28585fd149617c8ef129566a2'; +const spObject = new URLSearchParams(sp); +const botId = 7342037359; + +describe('isValid3rd', () => { + it('should return false if "signature" param is missing', async () => { + await expect(isValid3rd('auth_date=1', botId)).resolves.toBe(false); + }); + + it('should return false if "auth_date" param is missing or does not represent integer', async () => { + await expect(isValid3rd('hash=HHH', botId)).resolves.toBe(false); + await expect(isValid3rd('auth_date=AAA&hash=HHH', botId)).resolves.toBe(false); + }); + + it('should return false if parameters are expired', async () => { + await expect(isValid3rd(sp, botId, { expiresIn: 1 })).resolves.toBe(false); + }); + + it('should return false if sign is invalid', async () => { + await expect(isValid3rd(sp, botId + 1, { expiresIn: 0 })).resolves.toBe(false); + }); + + it('should return true if init data is valid', async () => { + const basicOptions = { expiresIn: 0 }; + await expect(isValid3rd(sp, botId, basicOptions)).resolves.toBe(true); + await expect(isValid3rd(spObject, botId, basicOptions)).resolves.toBe(true); + }); + + it('should return false if "expiresIn" is not passed and parameters were created more than 1 day ago', async () => { + await expect(isValid3rd(spOld, botId)).resolves.toBe(false); + }); +}); + +describe('validate3rd', () => { + it('should throw missing hash error if "signature" param is missing', async () => { + await expect(validate3rd('auth_date=1', botId)).rejects.toThrowError( + new TypedError('ERR_SIGNATURE_MISSING', 'Signature is missing'), + ); + }); + + it('should throw if "auth_date" param is missing or does not represent integer', async () => { + await expect(validate3rd('signature=HHH', botId)).rejects.toThrowError( + new TypedError('ERR_AUTH_DATE_INVALID', 'Auth date is invalid'), + ); + await expect(validate3rd('auth_date=AAA&signature=HHH', botId)).rejects.toThrowError( + new TypedError('ERR_AUTH_DATE_INVALID', 'Auth date is invalid'), + ); + }); + + it('should throw if parameters are expired', async () => { + await expect(validate3rd(sp, botId, { expiresIn: 1 })).rejects.toThrowError( + new TypedError('ERR_EXPIRED', 'Init data is expired'), + ); + }); + + it('should throw if sign is invalid', async () => { + await expect(validate3rd(sp, botId + 1, { expiresIn: 0 })).rejects.toThrowError( + new TypedError('ERR_SIGN_INVALID', 'Sign is invalid'), + ); + }); + + it('should correctly validate parameters in case, they are valid', async () => { + const basicOptions = { expiresIn: 0 }; + await expect(validate3rd(sp, botId, basicOptions)).resolves.toBeUndefined(); + await expect(validate3rd(spObject, botId, basicOptions)).resolves.toBeUndefined(); + }); + + it('should throw if "expiresIn" is not passed and parameters were created more than 1 day ago', async () => { + await expect(validate3rd(spOld, botId)).rejects.toThrow( + new TypedError('ERR_EXPIRED', 'Init data is expired'), + ); + }); +}); \ No newline at end of file diff --git a/packages/init-data-node/src/entries/shared.ts b/packages/init-data-node/src/entries/shared.ts index 75fd7c9c1..cbcab2b75 100644 --- a/packages/init-data-node/src/entries/shared.ts +++ b/packages/init-data-node/src/entries/shared.ts @@ -1,3 +1,11 @@ +import { + validate3rd as _validate3rd, + type Validate3rdValue, + type Validate3rdOptions, +} from '../validate3rd.js'; +import { isValid3rd as _isValid3rd } from '../isValid3rd.js'; +import type { Verify3rdFn } from '../types.js'; + export type { Chat, ChatType, InitData, User } from '@telegram-apps/types'; export { TypedError, isErrorOfType } from '@telegram-apps/toolkit'; export { ERR_PARSE, ERR_UNEXPECTED_VALUE } from '@telegram-apps/transformers'; @@ -11,4 +19,49 @@ export { ERR_AUTH_DATE_INVALID, ERR_EXPIRED, ERR_SIGN_INVALID, -} from '../errors.js'; \ No newline at end of file +} from '../errors.js'; + +export type { Validate3rdValue, Validate3rdOptions, Verify3rdFn }; + +const verify3rd: Verify3rdFn = async (data, key, signature) => { + return crypto.subtle.verify( + 'Ed25519', + await crypto + .subtle + .importKey('raw', Buffer.from(key, 'hex'), 'Ed25519', false, ['verify']), + Buffer.from(signature, 'base64'), + Buffer.from(data), + ); +}; + +/** + * Validates passed init data using a publicly known Ee25519 key. + * @param value - value to check. + * @param botId - bot identifier + * @param options - additional validation options. + * @throws {Error} ERR_SIGN_INVALID + * @throws {Error} ERR_AUTH_DATE_INVALID + * @throws {Error} ERR_SIGNATURE_MISSING + * @throws {Error} ERR_EXPIRED + */ +export async function validate3rd( + value: Validate3rdValue, + botId: number, + options?: Validate3rdOptions, +): Promise { + return _validate3rd(value, botId, verify3rd, options); +} + +/** + * @param value - value to check. + * @param botId - bot identifier + * @param options - additional validation options. + * @returns True is specified init data is signed by Telegram. + */ +export function isValid3rd( + value: Validate3rdValue, + botId: number, + options?: Validate3rdOptions, +): Promise { + return _isValid3rd(value, botId, validate3rd, options); +} \ No newline at end of file diff --git a/packages/init-data-node/src/entries/web.test.ts b/packages/init-data-node/src/entries/web.test.ts index 95bb7d39d..47b9d414a 100644 --- a/packages/init-data-node/src/entries/web.test.ts +++ b/packages/init-data-node/src/entries/web.test.ts @@ -1,55 +1,21 @@ import { describe, expect, it } from 'vitest'; import { TypedError } from '@telegram-apps/toolkit'; -import type { InitData } from '@telegram-apps/types'; -import { validate, sign, signData, isValid } from './web'; +import { validate, sign, signData, isValid, hashToken } from './web'; +import { arrayBufferToHex } from '../converters/arrayBufferToHex'; -const sp = 'auth_date=1&can_send_after=10000&chat=%7B%22id%22%3A1%2C%22type%22%3A%22group%22%2C%22title%22%3A%22chat-title%22%2C%22photo_url%22%3A%22group%22%2C%22username%22%3A%22my-chat%22%7D&chat_instance=888&chat_type=sender&hash=47cfa22e72b887cba90c9cb833c5ea0f599975b6ce7193741844b5c4a4228b40&query_id=QUERY&receiver=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22receiver-first-name%22%2C%22id%22%3A991%2C%22is_bot%22%3Afalse%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22receiver-last-name%22%2C%22photo_url%22%3A%22receiver-photo%22%2C%22username%22%3A%22receiver-username%22%7D&start_param=debug&user=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Afalse%2C%22first_name%22%3A%22user-first-name%22%2C%22id%22%3A222%2C%22is_bot%22%3Atrue%2C%22is_premium%22%3Afalse%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22user-last-name%22%2C%22photo_url%22%3A%22user-photo%22%2C%22username%22%3A%22user-username%22%7D'; +const sp = 'user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%20%2B%20-%20%3F%20%5C%2F%22%2C%22last_name%22%3A%22Kibenko%22%2C%22username%22%3A%22vdkfrost%22%2C%22language_code%22%3A%22ru%22%2C%22is_premium%22%3Atrue%2C%22allows_write_to_pm%22%3Atrue%2C%22photo_url%22%3A%22https%3A%5C%2F%5C%2Ft.me%5C%2Fi%5C%2Fuserpic%5C%2F320%5C%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%7D&chat_instance=8134722200314281151&chat_type=private&auth_date=1733584787&signature=zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ&hash=2174df5b000556d044f3f020384e879c8efcab55ddea2ced4eb752e93e7080d6'; +const spOld = 'auth_date=1&chat_instance=8134722200314281151&chat_type=private&user=%7B%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22Vladislav+%2B+-+%3F+%2F%22%2C%22id%22%3A279058397%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22Kibenko%22%2C%22photo_url%22%3A%22https%3A%2F%2Ft.me%2Fi%2Fuserpic%2F320%2F4FPEE4tmP3ATHa57u6MqTDih13LTOiMoKoLDRG4PnSA.svg%22%2C%22username%22%3A%22vdkfrost%22%7D&signature=zL-ucjNyREiHDE8aihFwpfR9aggP2xiAo3NSpfe-p7IbCisNlDKlo7Kb6G4D0Ao2mBrSgEk4maLSdv6MLIlADQ&hash=b2e387ba89f433607d2492aad63bb04328b0f3d28585fd149617c8ef129566a2'; const spObject = new URLSearchParams(sp); -const secretToken = '5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8'; -const secretTokenHashed = 'a5c609aa52f63cb5e6d8ceb6e4138726ea82bbc36bb786d64482d445ea38ee5f'; - -const initData: InitData = { - authDate: new Date(1000), - canSendAfter: 10000, - chat: { - id: 1, - type: 'group', - username: 'my-chat', - title: 'chat-title', - photoUrl: 'chat-photo', - }, - chatInstance: '888', - chatType: 'sender', - hash: '47cfa22e72b887cba90c9cb833c5ea0f599975b6ce7193741844b5c4a4228b40', - queryId: 'QUERY', - receiver: { - addedToAttachmentMenu: false, - allowsWriteToPm: true, - firstName: 'receiver-first-name', - id: 991, - isBot: false, - isPremium: true, - languageCode: 'ru', - lastName: 'receiver-last-name', - photoUrl: 'receiver-photo', - username: 'receiver-username', - }, - startParam: 'debug', - user: { - addedToAttachmentMenu: false, - allowsWriteToPm: false, - firstName: 'user-first-name', - id: 222, - isBot: true, - isPremium: false, - languageCode: 'en', - lastName: 'user-last-name', - photoUrl: 'user-photo', - username: 'user-username', - }, -}; +const secretToken = '7342037359:AAFZehRPBRs8Seg40oDjTMIW8uTGPuW1zfQ'; +const secretTokenHashed = 'c0881d6d547967540dcb06d7378a2c2b2c79a8f915ae70a4a37a8a04de0da32b'; + +describe('hashToken', () => { + it('should properly hash token', async () => { + await expect(hashToken(secretToken).then(arrayBufferToHex)).resolves.toBe(secretTokenHashed); + }); +}); describe('isValid', () => { it('should return false if "hash" param is missing', async () => { @@ -63,12 +29,10 @@ describe('isValid', () => { it('should return false if parameters are expired', async () => { await expect(isValid(sp, secretToken, { expiresIn: 1 })).resolves.toBe(false); - await expect(isValid(initData, secretToken, { expiresIn: 1 })).resolves.toBe(false); }); it('should return false if sign is invalid', async () => { await expect(isValid(sp, `${secretToken}A`, { expiresIn: 0 })).resolves.toBe(false); - await expect(isValid(initData, `${secretToken}A`, { expiresIn: 0 })).resolves.toBe(false); }); it('should return true if init data is valid', async () => { @@ -77,15 +41,12 @@ describe('isValid', () => { await expect(isValid(sp, secretToken, basicOptions)).resolves.toBe(true); await expect(isValid(sp, secretTokenHashed, hashedOptions)).resolves.toBe(true); - await expect(isValid(initData, secretToken, basicOptions)).resolves.toBe(true); - await expect(isValid(initData, secretTokenHashed, hashedOptions)).resolves.toBe(true); - await expect(isValid(spObject, secretToken, basicOptions)).resolves.toBe(true); await expect(isValid(spObject, secretTokenHashed, hashedOptions)).resolves.toBe(true); }); it('should return false if "expiresIn" is not passed and parameters were created more than 1 day ago', async () => { - await expect(isValid(sp, secretToken)).resolves.toBe(false); + await expect(isValid(spOld, secretToken)).resolves.toBe(false); }); }); @@ -109,18 +70,12 @@ describe('validate', () => { await expect(validate(sp, secretToken, { expiresIn: 1 })).rejects.toThrowError( new TypedError('ERR_EXPIRED', 'Init data is expired'), ); - await expect(validate(initData, secretToken, { expiresIn: 1 })).rejects.toThrowError( - new TypedError('ERR_EXPIRED', 'Init data is expired'), - ); }); it('should throw if sign is invalid', async () => { await expect(validate(sp, `${secretToken}A`, { expiresIn: 0 })) .rejects .toThrowError(new TypedError('ERR_SIGN_INVALID', 'Sign is invalid')); - await expect(validate(initData, `${secretToken}A`, { expiresIn: 0 })) - .rejects - .toThrowError(new TypedError('ERR_SIGN_INVALID', 'Sign is invalid')); }); it('should correctly validate parameters in case, they are valid', async () => { @@ -129,15 +84,12 @@ describe('validate', () => { await expect(validate(sp, secretToken, basicOptions)).resolves.toBeUndefined(); await expect(validate(sp, secretTokenHashed, hashedOptions)).resolves.toBeUndefined(); - await expect(validate(initData, secretToken, basicOptions)).resolves.toBeUndefined(); - await expect(validate(initData, secretTokenHashed, hashedOptions)).resolves.toBeUndefined(); - await expect(validate(spObject, secretToken, basicOptions)).resolves.toBeUndefined(); await expect(validate(spObject, secretTokenHashed, hashedOptions)).resolves.toBeUndefined(); }); it('should throw if "expiresIn" is not passed and parameters were created more than 1 day ago', async () => { - await expect(validate(sp, secretToken)).rejects.toThrow( + await expect(validate(spOld, secretToken)).rejects.toThrow( new TypedError('ERR_EXPIRED', 'Init data is expired'), ); }); @@ -184,11 +136,12 @@ describe('sign', () => { photoUrl: 'user-photo', username: 'user-username', }, + signature: 'abc', }, '5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8', new Date(1000), ), - ).toBe('auth_date=1&can_send_after=10000&chat=%7B%22id%22%3A1%2C%22type%22%3A%22group%22%2C%22title%22%3A%22chat-title%22%2C%22photo_url%22%3A%22group%22%2C%22username%22%3A%22my-chat%22%7D&chat_instance=888&chat_type=sender&query_id=QUERY&receiver=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22receiver-first-name%22%2C%22id%22%3A991%2C%22is_bot%22%3Afalse%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22receiver-last-name%22%2C%22photo_url%22%3A%22receiver-photo%22%2C%22username%22%3A%22receiver-username%22%7D&start_param=debug&user=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Afalse%2C%22first_name%22%3A%22user-first-name%22%2C%22id%22%3A222%2C%22is_bot%22%3Atrue%2C%22is_premium%22%3Afalse%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22user-last-name%22%2C%22photo_url%22%3A%22user-photo%22%2C%22username%22%3A%22user-username%22%7D&hash=47cfa22e72b887cba90c9cb833c5ea0f599975b6ce7193741844b5c4a4228b40'); + ).toBe('auth_date=1&can_send_after=10000&chat=%7B%22id%22%3A1%2C%22type%22%3A%22group%22%2C%22title%22%3A%22chat-title%22%2C%22photo_url%22%3A%22group%22%2C%22username%22%3A%22my-chat%22%7D&chat_instance=888&chat_type=sender&query_id=QUERY&receiver=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22receiver-first-name%22%2C%22id%22%3A991%2C%22is_bot%22%3Afalse%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22receiver-last-name%22%2C%22photo_url%22%3A%22receiver-photo%22%2C%22username%22%3A%22receiver-username%22%7D&start_param=debug&user=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Afalse%2C%22first_name%22%3A%22user-first-name%22%2C%22id%22%3A222%2C%22is_bot%22%3Atrue%2C%22is_premium%22%3Afalse%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22user-last-name%22%2C%22photo_url%22%3A%22user-photo%22%2C%22username%22%3A%22user-username%22%7D&signature=abc&hash=2213454f386e43228e9642d643d72017eb713e9ca5f8e8470bb66b88d643bcc0'); }); }); @@ -196,5 +149,13 @@ describe('signData', () => { it('should use HMAC-SHA256 algorithm with key, based on HMAC-SHA256 keyed with the "WebAppData" value, applied to the secret token', async () => { expect(await signData('abc', 'my-secret-token')) .toBe('6ecc2e9b51f30dde6877ce374ede54eb626c84e78a5d9a9dcac54d2d248f6bde'); + expect( + await signData( + 'abc', + 'fe37f490481d351837ed49f3b369c886c61013d6d036656fc3c9c92e163e3477', + { tokenHashed: true }, + ), + ) + .toBe('6ecc2e9b51f30dde6877ce374ede54eb626c84e78a5d9a9dcac54d2d248f6bde'); }); }); diff --git a/packages/init-data-node/src/entries/web.ts b/packages/init-data-node/src/entries/web.ts index 871429985..1f130531c 100644 --- a/packages/init-data-node/src/entries/web.ts +++ b/packages/init-data-node/src/entries/web.ts @@ -8,18 +8,16 @@ import type { CreateHmacFn, SignData, Text } from '../types.js'; const createHmac: CreateHmacFn = async (data, key) => { const encoder = new TextEncoder(); - return Buffer.from( - await crypto.subtle.sign( - 'HMAC', - await crypto.subtle.importKey( - 'raw', - typeof key === 'string' ? encoder.encode(key) : key, - { name: 'HMAC', hash: 'SHA-256' }, - false, - ['sign', 'verify'], - ), - encoder.encode(data.toString()), + return crypto.subtle.sign( + 'HMAC', + await crypto.subtle.importKey( + 'raw', + typeof key === 'string' ? encoder.encode(key) : key, + { name: 'HMAC', hash: 'SHA-256' }, + false, + ['sign', 'verify'], ), + typeof data === 'string' ? encoder.encode(data) : data, ); }; @@ -28,7 +26,7 @@ const createHmac: CreateHmacFn = async (data, key) => { * Hashes specified token using a string, expected during init data sign. * @param token - token to hash. */ -export function hashToken(token: Text): Promise { +export function hashToken(token: Text): Promise { return _hashToken(token, createHmac); } diff --git a/packages/init-data-node/src/errors.ts b/packages/init-data-node/src/errors.ts index 4192a4530..008a61e6e 100644 --- a/packages/init-data-node/src/errors.ts +++ b/packages/init-data-node/src/errors.ts @@ -1,4 +1,5 @@ export const ERR_AUTH_DATE_INVALID = 'ERR_AUTH_DATE_INVALID'; export const ERR_HASH_INVALID = 'ERR_HASH_INVALID'; +export const ERR_SIGNATURE_MISSING = 'ERR_SIGNATURE_MISSING'; export const ERR_EXPIRED = 'ERR_EXPIRED'; export const ERR_SIGN_INVALID = 'ERR_SIGN_INVALID'; diff --git a/packages/init-data-node/src/hashToken.ts b/packages/init-data-node/src/hashToken.ts index 833f4de95..36503fc52 100644 --- a/packages/init-data-node/src/hashToken.ts +++ b/packages/init-data-node/src/hashToken.ts @@ -1,8 +1,5 @@ -import type { CreateHmacFn } from './types.js'; +import type { CreateHmacFn, Text } from './types.js'; -export function hashToken>( - token: string | Buffer, - createHmac: H -): ReturnType { +export function hashToken>(token: Text, createHmac: H): ReturnType { return createHmac(token, 'WebAppData') as ReturnType; } diff --git a/packages/init-data-node/src/initDataToSearchParams.test.ts b/packages/init-data-node/src/initDataToSearchParams.test.ts index bddeee4a5..ae2c05667 100644 --- a/packages/init-data-node/src/initDataToSearchParams.test.ts +++ b/packages/init-data-node/src/initDataToSearchParams.test.ts @@ -4,7 +4,11 @@ import { initDataToSearchParams } from './initDataToSearchParams'; it('should correctly parse any set of parameters', () => { expect(initDataToSearchParams({}).toString()).toBe(''); expect(initDataToSearchParams({ hash: 'HASH' }).toString()).toBe('hash=HASH'); - expect(initDataToSearchParams({ authDate: new Date(1000), hash: 'HASH' }).toString()).toBe('auth_date=1&hash=HASH'); + expect(initDataToSearchParams({ + authDate: new Date(1000), + hash: 'HASH', + signature: 'aabb' + }).toString()).toBe('auth_date=1&signature=aabb&hash=HASH'); expect( initDataToSearchParams({ authDate: new Date(1000), @@ -45,8 +49,9 @@ it('should correctly parse any set of parameters', () => { photoUrl: 'user-photo', username: 'user-username', }, + signature: 'aabb', }).toString(), ).toBe( - 'auth_date=1&can_send_after=10000&chat=%7B%22id%22%3A1%2C%22type%22%3A%22group%22%2C%22title%22%3A%22chat-title%22%2C%22photo_url%22%3A%22group%22%2C%22username%22%3A%22my-chat%22%7D&chat_instance=888&chat_type=sender&hash=47cfa22e72b887cba90c9cb833c5ea0f599975b6ce7193741844b5c4a4228b40&query_id=QUERY&receiver=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22receiver-first-name%22%2C%22id%22%3A991%2C%22is_bot%22%3Afalse%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22receiver-last-name%22%2C%22photo_url%22%3A%22receiver-photo%22%2C%22username%22%3A%22receiver-username%22%7D&start_param=debug&user=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Afalse%2C%22first_name%22%3A%22user-first-name%22%2C%22id%22%3A222%2C%22is_bot%22%3Atrue%2C%22is_premium%22%3Afalse%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22user-last-name%22%2C%22photo_url%22%3A%22user-photo%22%2C%22username%22%3A%22user-username%22%7D', + 'auth_date=1&can_send_after=10000&chat=%7B%22id%22%3A1%2C%22type%22%3A%22group%22%2C%22title%22%3A%22chat-title%22%2C%22photo_url%22%3A%22group%22%2C%22username%22%3A%22my-chat%22%7D&chat_instance=888&chat_type=sender&query_id=QUERY&receiver=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Atrue%2C%22first_name%22%3A%22receiver-first-name%22%2C%22id%22%3A991%2C%22is_bot%22%3Afalse%2C%22is_premium%22%3Atrue%2C%22language_code%22%3A%22ru%22%2C%22last_name%22%3A%22receiver-last-name%22%2C%22photo_url%22%3A%22receiver-photo%22%2C%22username%22%3A%22receiver-username%22%7D&start_param=debug&user=%7B%22added_to_attachment_menu%22%3Afalse%2C%22allows_write_to_pm%22%3Afalse%2C%22first_name%22%3A%22user-first-name%22%2C%22id%22%3A222%2C%22is_bot%22%3Atrue%2C%22is_premium%22%3Afalse%2C%22language_code%22%3A%22en%22%2C%22last_name%22%3A%22user-last-name%22%2C%22photo_url%22%3A%22user-photo%22%2C%22username%22%3A%22user-username%22%7D&signature=aabb&hash=47cfa22e72b887cba90c9cb833c5ea0f599975b6ce7193741844b5c4a4228b40', ); }); diff --git a/packages/init-data-node/src/initDataToSearchParams.ts b/packages/init-data-node/src/initDataToSearchParams.ts index 57c50f4d2..6475a5603 100644 --- a/packages/init-data-node/src/initDataToSearchParams.ts +++ b/packages/init-data-node/src/initDataToSearchParams.ts @@ -59,11 +59,12 @@ export function initDataToSearchParams({ : undefined, chat_instance: data.chatInstance, chat_type: data.chatType || undefined, - hash: data.hash, query_id: data.queryId, receiver: serializeUser(receiver), start_param: data.startParam || undefined, user: serializeUser(user), + signature: data.signature, + hash: data.hash, }), ); } \ No newline at end of file diff --git a/packages/init-data-node/src/isValid3rd.ts b/packages/init-data-node/src/isValid3rd.ts new file mode 100644 index 000000000..524093591 --- /dev/null +++ b/packages/init-data-node/src/isValid3rd.ts @@ -0,0 +1,57 @@ +import type { Validate3rdOptions, Validate3rdValue } from './validate3rd.js'; + +type ValidateSyncFn = ( + value: Validate3rdValue, + botId: number, + options?: Validate3rdOptions, +) => void | never; + +type ValidateAsyncFn = ( + value: Validate3rdValue, + botId: number, + options?: Validate3rdOptions, +) => Promise; + +/** + * @param value - value to check. + * @param botId - bot identifier + * @param validate - function validating the init data. + * @param options - additional validation options. + * @returns True is specified init data is signed by Telegram. + */ +export function isValid3rd( + value: Validate3rdValue, + botId: number, + validate: ValidateAsyncFn, + options?: Validate3rdOptions, +): Promise; + +/** + * @param value - value to check. + * @param botId - bot identifier + * @param validate - function validating the init data. + * @param options - additional validation options. + * @returns True is specified init data is signed by Telegram. + */ +export function isValid3rd( + value: Validate3rdValue, + botId: number, + validate: ValidateSyncFn, + options?: Validate3rdOptions, +): boolean; + +export function isValid3rd( + value: Validate3rdValue, + botId: number, + validate: ValidateSyncFn | ValidateAsyncFn, + options?: Validate3rdOptions, +): boolean | Promise { + try { + const maybePromise = validate(value, botId, options); + return maybePromise + ? maybePromise.then(() => true, () => false) + : true; + } catch { + return false; + } +} diff --git a/packages/init-data-node/src/parse.test.ts b/packages/init-data-node/src/parse.test.ts index 9f1bcfa74..b01bc1592 100644 --- a/packages/init-data-node/src/parse.test.ts +++ b/packages/init-data-node/src/parse.test.ts @@ -9,7 +9,7 @@ describe('auth_date', () => { }); it('should parse source property as Date and pass it to the "authDate" property', () => { - expect(parse(toSearchParams({ auth_date: 1, hash: 'abcd' }))).toMatchObject({ + expect(parse(toSearchParams({ auth_date: 1, hash: 'abcd', signature: 'aa' }))).toMatchObject({ authDate: new Date(1000), }); }); @@ -22,6 +22,7 @@ describe('can_send_after', () => { auth_date: 1, hash: 'abcd', can_send_after: 8882, + signature: 'aa', })), ).toMatchObject({ canSendAfter: 8882, @@ -42,6 +43,7 @@ describe('chat', () => { photo_url: 'https://johny.com', username: 'Johny Chat', }, + signature: 'aa' })), ).toMatchObject({ chat: { @@ -60,6 +62,7 @@ describe('hash', () => { expect( () => parse(toSearchParams({ auth_date: 1, + signature: 'aa' })), ).toThrow(); }); @@ -69,6 +72,7 @@ describe('hash', () => { parse(toSearchParams({ auth_date: 1, hash: 'abcd', + signature: 'aa' })), ).toMatchObject({ hash: 'abcd', @@ -87,6 +91,7 @@ describe.each([ parse(toSearchParams({ auth_date: 1, hash: 'abcd', + signature: 'aa', [from]: 'my custom property', })), ).toMatchObject({ @@ -101,6 +106,7 @@ describe.each(['user', 'receiver'])('%s', (property) => { parse(toSearchParams({ auth_date: 1, hash: 'abcd', + signature: 'aa', [property]: { added_to_attachment_menu: true, allows_write_to_pm: false, diff --git a/packages/init-data-node/src/signData.ts b/packages/init-data-node/src/signData.ts index 38da8310b..391f0be2a 100644 --- a/packages/init-data-node/src/signData.ts +++ b/packages/init-data-node/src/signData.ts @@ -1,4 +1,6 @@ import { hashToken } from './hashToken.js'; +import { arrayBufferToHex } from './converters/arrayBufferToHex.js'; +import { hexToArrayBuffer } from './converters/hexToArrayBuffer.js'; import type { CreateHmacFn, SharedOptions, Text } from './types.js'; export type SignDataOptions = SharedOptions; @@ -39,17 +41,19 @@ export function signData( createHmac: CreateHmacFn, options: SignDataOptions = {}, ): string | Promise { - const keyHmac = options.tokenHashed ? key : hashToken(key, createHmac); + const keyHmac = options.tokenHashed + ? typeof key === 'string' + // If a hashed token was passed, we assume that it is a HEX string. Not to mess with + // the createHmac function, we should convert this HEX string to ArrayBuffer. Otherwise, + // incorrect behavior will be met. + ? hexToArrayBuffer(key) + : key + : hashToken(key, createHmac); + if (keyHmac instanceof Promise) { - return keyHmac - .then(v => createHmac(data, v)) - .then(v => v.toString('hex')); + return keyHmac.then(v => createHmac(data, v)).then(arrayBufferToHex); } - const hmac = createHmac(data, typeof keyHmac === 'string' - ? Buffer.from(keyHmac, 'hex') - : keyHmac); - return hmac instanceof Promise - ? hmac.then(v => v.toString('hex')) - : hmac.toString('hex'); + const hmac = createHmac(data, keyHmac); + return hmac instanceof Promise ? hmac.then(arrayBufferToHex) : arrayBufferToHex(hmac); } diff --git a/packages/init-data-node/src/types.ts b/packages/init-data-node/src/types.ts index d179747dc..4670e5cca 100644 --- a/packages/init-data-node/src/types.ts +++ b/packages/init-data-node/src/types.ts @@ -2,7 +2,7 @@ import type { InitData } from '@telegram-apps/types'; import type { SignDataOptions } from './signData.js'; -export type Text = string | Buffer; +export type Text = string | ArrayBuffer; export type SignData = Omit; @@ -18,9 +18,14 @@ export interface SignDataAsyncFn { * SHA-256 hashing function. */ export interface CreateHmacFn { - (data: Text, key: Text): Async extends true - ? Promise - : Buffer; + (data: Text, key: Text): Async extends true ? Promise : ArrayBuffer; +} + +/** + * 3-rd party verification function. + */ +export interface Verify3rdFn { + (data: string, key: string, signature: string): Async extends true ? Promise : boolean; } export interface SharedOptions { diff --git a/packages/init-data-node/src/validate.ts b/packages/init-data-node/src/validate.ts index 981ae4094..80ede8695 100644 --- a/packages/init-data-node/src/validate.ts +++ b/packages/init-data-node/src/validate.ts @@ -1,7 +1,5 @@ import { TypedError } from '@telegram-apps/toolkit'; -import type { InitData } from '@telegram-apps/types'; -import { initDataToSearchParams } from './initDataToSearchParams.js'; import type { SharedOptions, SignDataAsyncFn, SignDataSyncFn, Text } from './types.js'; import { ERR_AUTH_DATE_INVALID, @@ -23,7 +21,7 @@ export interface ValidateOptions extends SharedOptions { expiresIn?: number; } -export type ValidateValue = InitData | string | URLSearchParams; +export type ValidateValue = string | URLSearchParams; function processSign(actual: string, expected: string): void | never { if (actual !== expected) { @@ -83,11 +81,7 @@ export function validate( // Iterate over all key-value pairs of parsed parameters and find required // parameters. - new URLSearchParams( - typeof value === 'string' || value instanceof URLSearchParams - ? value - : initDataToSearchParams(value), - ).forEach((value, key) => { + (typeof value === 'string' ? new URLSearchParams(value) : value).forEach((value, key) => { if (key === 'hash') { hash = value; return; diff --git a/packages/init-data-node/src/validate3rd.ts b/packages/init-data-node/src/validate3rd.ts new file mode 100644 index 000000000..2bbfda9b5 --- /dev/null +++ b/packages/init-data-node/src/validate3rd.ts @@ -0,0 +1,136 @@ +import { TypedError } from '@telegram-apps/toolkit'; + +import type { Verify3rdFn } from './types.js'; +import { + ERR_AUTH_DATE_INVALID, + ERR_EXPIRED, + ERR_SIGN_INVALID, + ERR_SIGNATURE_MISSING, +} from './errors.js'; + +export interface Validate3rdOptions { + /** + * Time in seconds which states, how long from creation time init data is considered valid. + * + * In other words, in case when authDate + expiresIn is before current time, init data is + * recognized as expired. + * + * In case this value is equal to 0, the function does not check init data expiration. + * @default 86400 (1 day) + */ + expiresIn?: number; + /** + * When true, uses the test environment public key to validate init data. + * @default false + */ + test?: boolean; +} + +export type Validate3rdValue = string | URLSearchParams; + +function processResult(verified: boolean): void | never { + if (!verified) { + throw new TypedError(ERR_SIGN_INVALID, 'Sign is invalid'); + } + return; +} + +/** + * Validates passed init data using a publicly known Ee25519 key. + * @param value - value to check. + * @param botId - bot identifier + * @param verify - function to verify sign + * @param options - additional validation options. + * @throws {Error} ERR_SIGN_INVALID + * @throws {Error} ERR_AUTH_DATE_INVALID + * @throws {Error} ERR_SIGNATURE_MISSING + * @throws {Error} ERR_EXPIRED + */ +export function validate3rd( + value: Validate3rdValue, + botId: number, + verify: Verify3rdFn, + options?: Validate3rdOptions, +): void | never; + +/** + * Validates passed init data using a publicly known Ee25519 key. + * @param value - value to check. + * @param botId - bot identifier + * @param verify - function to verify sign + * @param options - additional validation options. + * @throws {Error} ERR_SIGN_INVALID + * @throws {Error} ERR_AUTH_DATE_INVALID + * @throws {Error} ERR_SIGNATURE_MISSING + * @throws {Error} ERR_EXPIRED + */ +export function validate3rd( + value: Validate3rdValue, + botId: number, + verify: Verify3rdFn, + options?: Validate3rdOptions, +): Promise; + +export function validate3rd( + value: Validate3rdValue, + botId: number, + verify: Verify3rdFn, + options: Validate3rdOptions = {}, +): void | never | Promise { + // Init data required params. + let authDate: Date | undefined; + let signature: string | undefined; + + // All search params pairs presented as `k=v`. + const pairs: string[] = []; + + // Iterate over all key-value pairs of parsed parameters and find required + // parameters. + (typeof value === 'string' ? new URLSearchParams(value) : value).forEach((value, key) => { + if (key === 'hash') { + return; + } + + if (key === 'signature') { + signature = value; + return; + } + + if (key === 'auth_date') { + const authDateNum = parseInt(value, 10); + if (!Number.isNaN(authDateNum)) { + authDate = new Date(authDateNum * 1000); + } + } + + pairs.push(`${key}=${value}`); + }); + + // Signature and auth date always required. + if (!signature) { + throw new TypedError(ERR_SIGNATURE_MISSING, 'Signature is missing'); + } + + if (!authDate) { + throw new TypedError(ERR_AUTH_DATE_INVALID, 'Auth date is invalid'); + } + + // In case, expiration time passed, we do additional parameters check. + const { expiresIn = 86400 } = options; + if (expiresIn > 0) { + // Check if init data expired. + if (+authDate + expiresIn * 1000 < Date.now()) { + throw new TypedError(ERR_EXPIRED, 'Init data is expired'); + } + } + + const verified = verify( + `${botId}:WebAppData\n${pairs.sort().join('\n')}`, + options.test + ? '40055058a4ee38156a06562e52eece92a771bcd8346a8c4615cb7376eddf72ec' + : 'e7bf03a2fa4602af4580703d88dda5bb59f32ed8b02a56c187fe7d34caed242d', + signature, + ); + + return typeof verified === 'boolean' ? processResult(verified) : verified.then(processResult); +} diff --git a/packages/sdk/src/scopes/components/init-data/parseInitData.test.ts b/packages/sdk/src/scopes/components/init-data/parseInitData.test.ts deleted file mode 100644 index 936e7b5f8..000000000 --- a/packages/sdk/src/scopes/components/init-data/parseInitData.test.ts +++ /dev/null @@ -1,144 +0,0 @@ -import { beforeEach, describe, expect, it } from 'vitest'; -import { toSearchParams } from 'test-utils'; - -import { resetPackageState } from '@test-utils/reset/reset.js'; - -import { parseInitData } from './parseInitData.js'; - -beforeEach(() => { - resetPackageState(); -}); - -describe('parse', () => { - describe('auth_date', () => { - it('should throw an error in case, this property is missing', () => { - expect(() => parseInitData(toSearchParams({ hash: 'abcd' }))).toThrow(); - }); - - it('should parse source property as Date and pass it to the "authDate" property', () => { - expect(parseInitData(toSearchParams({ auth_date: 1, hash: 'abcd' }))).toMatchObject({ - authDate: new Date(1000), - }); - }); - }); - - describe('can_send_after', () => { - it('should parse source property as Date and pass it to the "canSendAfter" property', () => { - expect( - parseInitData(toSearchParams({ - auth_date: 1, - hash: 'abcd', - can_send_after: 8882, - })), - ).toMatchObject({ - canSendAfter: 8882, - }); - }); - }); - - describe('chat', () => { - it('should parse source property as Chat and pass it to the "chat" property', () => { - expect( - parseInitData(toSearchParams({ - auth_date: 1, - hash: 'abcd', - chat: { - id: 5, - type: 'group chat', - title: 'My Chat', - photo_url: 'https://johny.com', - username: 'Johny Chat', - }, - })), - ).toMatchObject({ - chat: { - id: 5, - type: 'group chat', - title: 'My Chat', - photoUrl: 'https://johny.com', - username: 'Johny Chat', - }, - }); - }); - }); - - describe('hash', () => { - it('should throw an error in case, this property is missing', () => { - expect( - () => parseInitData(toSearchParams({ - auth_date: 1, - })), - ).toThrow(); - }); - - it('should parse source property as string and pass it to the "hash" property', () => { - expect( - parseInitData(toSearchParams({ - auth_date: 1, - hash: 'abcd', - })), - ).toMatchObject({ - hash: 'abcd', - }); - }); - }); - - [ - ['chat_instance', 'chatInstance'], - ['chat_type', 'chatType'], - ['query_id', 'queryId'], - ['start_param', 'startParam'], - ].forEach(([from, to]) => { - describe(from, () => { - it(`should parse source property as string and pass it to the "${to}" property`, () => { - expect( - parseInitData(toSearchParams({ - auth_date: 1, - hash: 'abcd', - [from]: 'my custom property', - })), - ).toMatchObject({ - [to]: 'my custom property', - }); - }); - }); - }); - - ['user', 'receiver'].forEach((property) => { - describe(property, () => { - it('should parse source property as User and pass it to the property with the same name', () => { - expect( - parseInitData(toSearchParams({ - auth_date: 1, - hash: 'abcd', - [property]: { - added_to_attachment_menu: true, - allows_write_to_pm: false, - first_name: 'Johny', - id: 333, - is_bot: false, - is_premium: true, - language_code: 'en', - last_name: 'Bravo', - photo_url: 'https://johny.com', - username: 'johnybravo', - }, - })), - ).toMatchObject({ - [property]: { - addedToAttachmentMenu: true, - allowsWriteToPm: false, - firstName: 'Johny', - id: 333, - isBot: false, - isPremium: true, - languageCode: 'en', - lastName: 'Bravo', - photoUrl: 'https://johny.com', - username: 'johnybravo', - }, - }); - }); - }); - }); -}); diff --git a/packages/transformers/src/complex/initData.test.ts b/packages/transformers/src/complex/initData.test.ts index 093150525..a386131ea 100644 --- a/packages/transformers/src/complex/initData.test.ts +++ b/packages/transformers/src/complex/initData.test.ts @@ -5,11 +5,15 @@ import { initData } from './initData.js'; describe('auth_date', () => { it('should throw an error in case, this property is missing', () => { - expect(() => initData()(toSearchParams({ hash: 'abcd' }))).toThrow(); + expect(() => initData()(toSearchParams({ hash: 'abcd', signature: 'aabb' }))).toThrow(); }); it('should parse source property as Date and pass it to the "authDate" property', () => { - expect(initData()(toSearchParams({ auth_date: 1, hash: 'abcd' }))).toMatchObject({ + expect(initData()(toSearchParams({ + auth_date: 1, + hash: 'abcd', + signature: 'aabb', + }))).toMatchObject({ authDate: new Date(1000), }); }); @@ -22,6 +26,7 @@ describe('can_send_after', () => { auth_date: 1, hash: 'abcd', can_send_after: 8882, + signature: 'aabb', })), ).toMatchObject({ canSendAfter: 8882, @@ -35,6 +40,7 @@ describe('chat', () => { initData()(toSearchParams({ auth_date: 1, hash: 'abcd', + signature: 'aabb', chat: { id: 5, type: 'group chat', @@ -59,6 +65,7 @@ describe('hash', () => { it('should throw an error in case, this property is missing', () => { expect( () => initData()(toSearchParams({ + signature: 'aabb', auth_date: 1, })), ).toThrow(); @@ -67,6 +74,7 @@ describe('hash', () => { it('should parse source property as string and pass it to the "hash" property', () => { expect( initData()(toSearchParams({ + signature: 'aabb', auth_date: 1, hash: 'abcd', })), @@ -87,6 +95,7 @@ describe.each([ initData()(toSearchParams({ auth_date: 1, hash: 'abcd', + signature: 'aabb', [from]: 'my custom property', })), ).toMatchObject({ @@ -101,6 +110,7 @@ describe.each(['user', 'receiver'])('%s', (property) => { initData()(toSearchParams({ auth_date: 1, hash: 'abcd', + signature: 'aabb', [property]: { added_to_attachment_menu: true, allows_write_to_pm: false, diff --git a/packages/transformers/src/complex/initData.ts b/packages/transformers/src/complex/initData.ts index 27697601f..e9cfe50c9 100644 --- a/packages/transformers/src/complex/initData.ts +++ b/packages/transformers/src/complex/initData.ts @@ -57,6 +57,7 @@ export const initData: TransformerGen = (optional) => { queryId: stringOptional, receiver: user, startParam: stringOptional, + signature: string, user, }), 'initData', diff --git a/packages/transformers/src/complex/launch-params.test.ts b/packages/transformers/src/complex/launch-params.test.ts index f776d7da3..c71a9d83c 100644 --- a/packages/transformers/src/complex/launch-params.test.ts +++ b/packages/transformers/src/complex/launch-params.test.ts @@ -10,7 +10,10 @@ describe('launchParams', () => { tgWebAppVersion: '7.0', }; - it(`should not throw if ${['tgWebAppBotInline', 'tgWebAppData', 'tgWebAppShowSettings', 'tgWebAppStartParam'].join(', ')} parameters are missing`, () => { + it(`should not throw if ${['tgWebAppBotInline', + 'tgWebAppData', + 'tgWebAppShowSettings', + 'tgWebAppStartParam'].join(', ')} parameters are missing`, () => { expect(() => launchParams()(toSearchParams(baseLaunchParams))).not.toThrow(); }); @@ -27,12 +30,13 @@ describe('launchParams', () => { expect( launchParams()(toSearchParams({ ...baseLaunchParams, - tgWebAppData: toSearchParams({ auth_date: 1, hash: 'abc' }), + tgWebAppData: toSearchParams({ auth_date: 1, hash: 'abc', signature: 'aabb' }), })), ).toMatchObject({ initData: { authDate: new Date(1000), hash: 'abc', + signature: 'aabb', }, }); // TODO: err @@ -42,9 +46,9 @@ describe('launchParams', () => { expect( launchParams()(toSearchParams({ ...baseLaunchParams, - tgWebAppData: toSearchParams({ auth_date: 1, hash: 'abc' }), + tgWebAppData: toSearchParams({ auth_date: 1, hash: 'abc', signature: 'aabb' }), })), - ).toMatchObject({ initDataRaw: 'auth_date=1&hash=abc' }); + ).toMatchObject({ initDataRaw: 'auth_date=1&hash=abc&signature=aabb' }); // todo: err }); diff --git a/packages/types/src/init-data.ts b/packages/types/src/init-data.ts index 71aa57b27..bd14bdfde 100644 --- a/packages/types/src/init-data.ts +++ b/packages/types/src/init-data.ts @@ -134,6 +134,10 @@ export interface InitData { * The value of the `startattach` or `startapp` parameter, passed via link. */ startParam?: string; + /** + * Init data signature used during 3-rd party validation. + */ + signature: string; /** * An object containing data about the current user. */