// Проверка данных запуска мини-приложения Falcon Black (initData) на сервере автора. // Node.js 18+, без библиотек: только node:crypto. // // FB_BOT_TOKEN=<ключ бота> node validate-initdata.mjs '' // // Страница мини-приложения берёт строку из WebApp.initData и шлёт её на ваш сервер // В ТЕЛЕ запроса (не в адресе: адреса оседают в журналах). Здесь она проверяется // двумя независимыми способами: // hash — HMAC ключом вашего бота (есть, когда у живого ключа есть ключ приложений: // после любого запроса к Bot API или у ключа, выпущенного заново); // signature — Ed25519 ключом Falcon Black (есть всегда); открытые ключи — // https://falconblack.org/sdk/keys.json, проверять всеми (текущий и прежний). // «WebAppData» — постоянная строка схемы, а не имя: готовые библиотеки проверки // работают без правок. import crypto from 'node:crypto'; import { pathToFileURL } from 'node:url'; export const CONSTANT = 'WebAppData'; /** Данные старше часа не принимать; жёсткий предел — сутки. Допуск на часы — минута. */ export const MAX_AGE_SECONDS = 3600; const SPKI_ED25519 = Buffer.from('302a300506032b6570032100', 'hex'); /** Строка проверки: поля, кроме исключённых, по ключу (байтовый порядок), «k=v» с раскодированным v, через \n. */ function dataCheckString(pairs, exclude) { return pairs .filter(([k]) => !exclude.includes(k)) .sort(([a], [b]) => (Buffer.compare(Buffer.from(a), Buffer.from(b)))) .map(([k, v]) => `${k}=${v}`) .join('\n'); } /** Ключ HMAC из ключа бота: HMAC_SHA256(key = "WebAppData", msg = весь ключ «<номер>:fb1_…»). */ export function webAppKey(botToken) { return crypto.createHmac('sha256', CONSTANT).update(botToken, 'utf8').digest(); } /** Открытые ключи из /sdk/keys.json → [{kid, raw32}]. */ export async function fetchPublicKeys(url = 'https://falconblack.org/sdk/keys.json') { const r = await fetch(url); if (!r.ok) throw new Error(`keys.json: HTTP ${r.status}`); const body = await r.json(); return (body.keys ?? []).map((k) => ({ kid: k.kid, raw32: Buffer.from(k.public_key_hex, 'hex') })); } /** * Проверить initData. * botToken — весь ключ бота (или null: тогда hash не проверяется); * publicKeys — [{kid, raw32}] из keys.json (или []: тогда не проверяется signature); * botId — номер бота (getMe.id, левая часть ключа); по умолчанию — из ключа; * now — секунды (для проверок); maxAge — секунды. * Итог: { ok, hash: ok|bad|absent|nokey, signature: ok|bad|absent|nokeys, fresh, age, data }. * ok — подпись верна, hash верен (или его нет), данные свежие. */ export function validateInitData(initData, { botToken = null, publicKeys = [], botId = null, now = Math.floor(Date.now() / 1000), maxAge = MAX_AGE_SECONDS } = {}) { // Разбирать только декодером форм: «+» — это пробел. const pairs = [...new URLSearchParams(String(initData))]; const seen = new Set(); for (const [k] of pairs) { // Повтор поля — подмена: строку отвергаем целиком. if (seen.has(k)) return { ok: false, reason: 'duplicate_key', hash: 'bad', signature: 'bad', fresh: false }; seen.add(k); } const data = Object.fromEntries(pairs); const id = botId ?? (botToken ? String(botToken).split(':')[0] : null); let hash = 'absent'; if (data.hash !== undefined) { if (!botToken) hash = 'nokey'; else { const expected = crypto.createHmac('sha256', webAppKey(botToken)).update(dataCheckString(pairs, ['hash']), 'utf8').digest(); const wellFormed = /^[0-9a-f]{64}$/.test(data.hash); const got = wellFormed ? Buffer.from(data.hash, 'hex') : Buffer.alloc(32); // Сравнение за постоянное время. hash = crypto.timingSafeEqual(expected, got) && wellFormed ? 'ok' : 'bad'; } } let signature = 'absent'; if (data.signature !== undefined) { if (!publicKeys.length || id === null) signature = 'nokeys'; else { const message = Buffer.from(`${id}:${CONSTANT}\n${dataCheckString(pairs, ['hash', 'signature'])}`, 'utf8'); const sig = Buffer.from(data.signature, 'base64url'); signature = sig.length === 64 && publicKeys.some((k) => { const key = crypto.createPublicKey({ key: Buffer.concat([SPKI_ED25519, k.raw32]), format: 'der', type: 'spki' }); return crypto.verify(null, message, key, sig); }) ? 'ok' : 'bad'; } } const authDate = Number(data.auth_date); const age = Number.isFinite(authDate) ? now - authDate : Infinity; const fresh = age >= -60 && age <= Math.min(maxAge, 86400); let user = null; try { user = data.user ? JSON.parse(data.user) : null; } catch { user = null; } const ok = signature === 'ok' && (hash === 'ok' || hash === 'absent' || hash === 'nokey') && fresh; return { ok, hash, signature, fresh, age, data: { ...data, user } }; } // Запуск из командной строки: node validate-initdata.mjs '' if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { const initData = process.argv[2]; if (!initData) { console.error("usage: FB_BOT_TOKEN=<ключ> node validate-initdata.mjs ''"); process.exit(2); } const result = validateInitData(initData, { botToken: process.env.FB_BOT_TOKEN || null, publicKeys: await fetchPublicKeys() }); // Сами данные запуска в журнал не пишите: печатаем только итог и номер человека. console.log(JSON.stringify({ ok: result.ok, hash: result.hash, signature: result.signature, fresh: result.fresh, user_id: result.data?.user?.id ?? null })); process.exit(result.ok ? 0 : 1); }