Skip to content

Commit

Permalink
Merge pull request #304 from Telegram-Mini-Apps/feature/init-data-nod…
Browse files Browse the repository at this point in the history
…e-create

Add init data sign utilities
  • Loading branch information
heyqbnk authored May 13, 2024
2 parents 51a6f99 + 6e90353 commit 006a2a1
Show file tree
Hide file tree
Showing 11 changed files with 329 additions and 58 deletions.
5 changes: 5 additions & 0 deletions .changeset/mean-nails-retire.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@tma.js/init-data-node": minor
---

Add sign utilities.
3 changes: 3 additions & 0 deletions packages/init-data-node/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
export { sign } from './sign.js';
export { signData } from './signData.js';
export type { SignData } from './types.js';
export { validate, type ValidateOptions } from './validate.js';
export {
type Chat,
Expand Down
52 changes: 52 additions & 0 deletions packages/init-data-node/src/initDataToSearchParams.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { it, expect } from 'vitest';
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),
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',
},
}).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',
);
});
71 changes: 71 additions & 0 deletions packages/init-data-node/src/initDataToSearchParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { InitDataParsed, User } from '@tma.js/sdk';

import { URLSearchParams } from 'node:url';

/**
* Removes undefined properties from the object.
* @param object - object to remove properties from.
*/
function removeUndefined(object: Record<string, string | undefined>): Record<string, string> {
const result: Record<string, string> = {};
for (const key in object) {
const v = object[key];
if (v !== undefined) {
result[key] = v;
}
}
return result;
}

/**
* Serializes a user information.
* @param user - user information.
*/
function serializeUser(user: User | undefined): string | undefined {
return user
? JSON.stringify({
added_to_attachment_menu: user.addedToAttachmentMenu,
allows_write_to_pm: user.allowsWriteToPm,
first_name: user.firstName,
id: user.id,
is_bot: user.isBot,
is_premium: user.isPremium,
language_code: user.languageCode,
last_name: user.lastName,
photo_url: user.photoUrl,
username: user.username,
})
: undefined;
}

export function initDataToSearchParams({
chat,
receiver,
user,
...data
}: Partial<InitDataParsed>): URLSearchParams {
return new URLSearchParams(
removeUndefined({
auth_date: data.authDate
? ((+data.authDate / 1000) | 0).toString()
: undefined,
can_send_after: data.canSendAfter?.toString(),
chat: chat
? JSON.stringify({
id: chat.id,
type: chat.type,
title: chat.title,
photo_url: chat.type,
username: chat.username,
})
: 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),
}),
);
}
50 changes: 50 additions & 0 deletions packages/init-data-node/src/sign.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, it } from 'vitest';

import { sign } from './sign';

it('should correctly sign data', () => {
expect(
sign(
{
canSendAfter: 10000,
chat: {
id: 1,
type: 'group',
username: 'my-chat',
title: 'chat-title',
photoUrl: 'chat-photo',
},
chatInstance: '888',
chatType: 'sender',
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',
},
},
'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');
});
28 changes: 28 additions & 0 deletions packages/init-data-node/src/sign.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { SignData } from './types.js';
import { signData } from './signData.js';
import { initDataToSearchParams } from './initDataToSearchParams.js';

/**
* Signs specified init data.
* @param data - init data to sign.
* @param authDate - date, when this init data should be signed.
* @param token - bot token.
* @returns Signed init data presented as query parameters.
*/
export function sign(data: SignData, token: string, authDate: Date): string {
// Create search parameters, which will be signed further.
const sp = initDataToSearchParams({
...data,
authDate,
});

// Convert search params to pairs and sort final array.
const pairs = [...sp.entries()]
.map(([name, value]) => `${name}=${value}`)
.sort();

// Compute sign, append it to the params and return.
sp.append('hash', signData(pairs.join('\n'), token));

return sp.toString();
}
10 changes: 10 additions & 0 deletions packages/init-data-node/src/signData.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { expect, it } from 'vitest';
import { signData } from './signData';

it(
'should use HMAC-SHA256 algorithm with key, based on HMAC-SHA256 keyed with the "WebAppData" value, applied to the secret token',
() => {
expect(signData('abc', 'my-secret-token'))
.toBe('6ecc2e9b51f30dde6877ce374ede54eb626c84e78a5d9a9dcac54d2d248f6bde');
},
);
17 changes: 17 additions & 0 deletions packages/init-data-node/src/signData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { createHmac } from 'node:crypto';

/**
* Signs specified data with the passed token.
* @param data - data to sign.
* @param token - bot token.
* @returns Data sign.
*/
export function signData(data: string, token: string): string {
return createHmac(
'sha256',
createHmac('sha256', 'WebAppData').update(token).digest(),
)
.update(data)
.digest()
.toString('hex');
}
4 changes: 4 additions & 0 deletions packages/init-data-node/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { InitDataParsed } from '@tma.js/sdk';

export interface SignData extends Omit<InitDataParsed, 'authDate' | 'hash'> {
}
73 changes: 60 additions & 13 deletions packages/init-data-node/src/validate.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,88 @@ import { URLSearchParams } from 'node:url';
import { expect, it } from 'vitest';

import { validate } from './validate.js';
import { InitDataParsed } from '@tma.js/sdk';

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 = 'query_id=AAHdF6IQAAAAAN0XohDhrOrc&user=%7B%22id%22%3A279058397%2C%22first_name%22%3A%22Vladislav%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%7D&auth_date=1662771648&hash=c501b71e775f74ce10e377dea85a7ea24ecd640b223ea86dfe453e0eaed2e2b2';
const secretToken = '5768337691:AAH5YkoiEuPk8-FZa32hStHTqXiLPtAEhx8';

const initData: InitDataParsed = {
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',
},
};

it('should throw missing hash error in case, it is not in search params', () => {
expect(() => validate('auth_date=1', secretToken))
.toThrowError('"hash" is empty or not found');
});

it('should throw an error on case, auth_date is not passed, equal to 0 or does not represent integer', () => {
expect(() => validate('auth_date=0&hash=HHH', secretToken))
.toThrowError('"auth_date" is empty or not found');
it('should throw an error on case, auth_date is not passed or does not represent integer', () => {
expect(() => validate('hash=HHH', secretToken))
.toThrowError('"auth_date" is empty or not found');
expect(() => validate('auth_date=AAA&hash=HHH', secretToken))
.toThrowError('"auth_date" should present integer');
});

it('should throw an error in case, parameters are expired', () => {
expect(() => validate(sp, secretToken, {
expiresIn: 1,
})).toThrowError('Init data expired');
expect(() => validate(sp, secretToken, { expiresIn: 1 }))
.toThrowError('Init data expired');
expect(() => validate(initData, secretToken, { expiresIn: 1 }))
.toThrowError('Init data expired');
});

it('should throw an error in case, sign is invalid', () => {
expect(() => validate(sp, `${secretToken}A`, {
expiresIn: 0,
})).toThrowError('Signature is invalid');
expect(() => validate(sp, `${secretToken}A`, { expiresIn: 0 }))
.toThrowError('Signature is invalid');
expect(() => validate(initData, `${secretToken}A`, { expiresIn: 0 }))
.toThrowError('Signature is invalid');
});

it('should correctly validate parameters in case, they are valid', () => {
expect(() => validate(sp, secretToken, { expiresIn: 0 })).not.toThrow();
expect(() => validate(initData, secretToken, { expiresIn: 0 })).not.toThrow();
expect(() => validate(new URLSearchParams(sp), secretToken, { expiresIn: 0 })).not.toThrow();
});

it('should throw an error in case, expiration time is not passed, parameters were created more than 1 day ago and already expired', () => {
expect(() => validate(sp, secretToken)).toThrow('Init data expired');
});
it(
'should throw an error in case, expiration time is not passed, parameters were created more than 1 day ago and already expired',
() => {
expect(() => validate(sp, secretToken)).toThrow('Init data expired');
},
);
Loading

0 comments on commit 006a2a1

Please sign in to comment.