feat: validate telegram initData via hmac-sha256
This commit is contained in:
parent
cf790c60df
commit
22eb41b0bc
2 changed files with 121 additions and 0 deletions
54
src/lib/initdata.test.ts
Normal file
54
src/lib/initdata.test.ts
Normal 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
67
src/lib/initdata.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue