feat: validate telegram initData via hmac-sha256

This commit is contained in:
devilreef 2026-04-29 12:14:17 +06:00
parent cf790c60df
commit 22eb41b0bc
Signed by: devilreef
SSH key fingerprint: SHA256:UZisRr4iuXx+IhkbZnR655L2RWAT6o2rgbGv5F/6m3Y
2 changed files with 121 additions and 0 deletions

54
src/lib/initdata.test.ts Normal file
View file

@ -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, string>): 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))
})
})

67
src/lib/initdata.ts Normal file
View file

@ -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 }
}