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.
*/