From 22eb41b0bc7a0377ab946d336457dc9e8bcfb362 Mon Sep 17 00:00:00 2001 From: devilreef Date: Wed, 29 Apr 2026 12:14:17 +0600 Subject: [PATCH] feat: validate telegram initData via hmac-sha256 --- src/lib/initdata.test.ts | 54 ++++++++++++++++++++++++++++++++ src/lib/initdata.ts | 67 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 src/lib/initdata.test.ts create mode 100644 src/lib/initdata.ts diff --git a/src/lib/initdata.test.ts b/src/lib/initdata.test.ts new file mode 100644 index 0000000..dbeccaa --- /dev/null +++ b/src/lib/initdata.test.ts @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { createHmac } from 'node:crypto' +import { describe, it } from 'node:test' +import { InitDataError, validateInitData } from '@/lib/initdata.js' + +const BOT = '123456:test-token' +const NOW = 1_700_000_000 + +function sign(fields: Record): string { + const sp = new URLSearchParams(fields) + const sorted = [...sp.entries()].sort(([a], [b]) => a.localeCompare(b)) + const dcs = sorted.map(([k, v]) => `${k}=${v}`).join('\n') + const secret = createHmac('sha256', 'WebAppData').update(BOT).digest() + const hash = createHmac('sha256', secret).update(dcs).digest('hex') + sp.set('hash', hash) + return sp.toString() +} + +describe('validateInitData', () => { + it('accepts a valid signature and returns userId', () => { + const raw = sign({ auth_date: String(NOW - 10), user: '{"id":42}' }) + const result = validateInitData(raw, BOT, 86400, NOW) + assert.equal(result.userId, 42n) + }) + + it('rejects a tampered hash', () => { + const raw = sign({ auth_date: String(NOW), user: '{"id":1}' }).replace(/hash=([0-9a-f]+)/, 'hash=$1aa') + assert.throws(() => validateInitData(raw, BOT, 86400, NOW), { code: 'bad-hash' }) + }) + + it('rejects an expired auth_date', () => { + const raw = sign({ auth_date: String(NOW - 100_000), user: '{"id":1}' }) + assert.throws(() => validateInitData(raw, BOT, 86400, NOW), { code: 'expired' }) + }) + + it('rejects missing user', () => { + const raw = sign({ auth_date: String(NOW) }) + assert.throws(() => validateInitData(raw, BOT, 86400, NOW), { code: 'malformed' }) + }) + + it('rejects missing hash', () => { + assert.throws( + () => validateInitData(`auth_date=${NOW}&user=%7B%22id%22%3A1%7D`, BOT, 86400, NOW), + (e: unknown) => e instanceof InitDataError && e.code === 'malformed', + ) + }) + + it('handles bigint user ids', () => { + const big = '12345678901234567890' + const raw = sign({ auth_date: String(NOW), user: `{"id":${big}}` }) + const result = validateInitData(raw, BOT, 86400, NOW) + assert.equal(result.userId, BigInt(big)) + }) +}) diff --git a/src/lib/initdata.ts b/src/lib/initdata.ts new file mode 100644 index 0000000..5c02fa6 --- /dev/null +++ b/src/lib/initdata.ts @@ -0,0 +1,67 @@ +import { Buffer } from 'node:buffer' +import { createHmac, timingSafeEqual } from 'node:crypto' + +const USER_ID_RE = /"id"\s*:\s*(-?\d+)/ + +export class InitDataError extends Error { + constructor(public readonly code: 'malformed' | 'bad-hash' | 'expired') { + super(code) + } +} + +export interface InitDataResult { + userId: bigint + authDate: number +} + +export function validateInitData( + raw: string, + botToken: string, + maxAgeSeconds: number, + now: number = Math.floor(Date.now() / 1000), +): InitDataResult { + const params = new URLSearchParams(raw) + const hash = params.get('hash') + if (!hash) + throw new InitDataError('malformed') + params.delete('hash') + + const sorted = [...params.entries()].sort(([a], [b]) => a.localeCompare(b)) + const dataCheckString = sorted.map(([k, v]) => `${k}=${v}`).join('\n') + + const secret = createHmac('sha256', 'WebAppData').update(botToken).digest() + const expected = createHmac('sha256', secret).update(dataCheckString).digest('hex') + + const expectedBuf = Buffer.from(expected, 'hex') + let givenBuf: Buffer + try { + givenBuf = Buffer.from(hash, 'hex') + } + catch { + throw new InitDataError('bad-hash') + } + if (expectedBuf.length !== givenBuf.length || !timingSafeEqual(expectedBuf, givenBuf)) + throw new InitDataError('bad-hash') + + const authDate = Number(params.get('auth_date')) + if (!Number.isFinite(authDate) || now - authDate > maxAgeSeconds) + throw new InitDataError('expired') + + const userField = params.get('user') + if (!userField) + throw new InitDataError('malformed') + + // extract id as raw string before JSON.parse to avoid float precision loss on large ints + const idMatch = userField.match(USER_ID_RE) + if (!idMatch) + throw new InitDataError('malformed') + + try { + JSON.parse(userField) + } + catch { + throw new InitDataError('malformed') + } + + return { userId: BigInt(idMatch[1]), authDate } +}