Files
OmniRoute/src/lib/telegram/initData.ts
benzntech 49e3cd060e feat(telegram): Mini App chat bridge — initData auth, update webhook, chat proxy
Implements the Phase-1 slice of the Telegram Mini App integration
(docs/proposals/TELEGRAM-MINIAPP.md):

- src/lib/telegram/initData.ts — dependency-free WebApp initData HMAC-SHA256
  verification (Telegram Bot API spec), with auth_date freshness check.
- src/lib/telegram/config.ts — TELEGRAM_BOT_TOKEN / model / API base / timeout
  env config; token format validation; enabled gate.
- src/lib/telegram/botApi.ts — minimal fetch-based Bot API client
  (sendMessage, editMessageText, setWebhook) + update shape helpers.
- src/lib/telegram/chatProxy.ts — maps a Telegram user to a per-user
  OmniRoute API key (createApiKey, name telegram:<userId>) and proxies
  prompts through the existing handleChat pipeline.
- src/app/api/telegram/update/route.ts — inbound endpoint serving both the
  Bot API update webhook (/start + chat replies) and the Mini App direct
  path (initData HMAC verified → 401 on mismatch). Public route prefix;
  own auth only.
- src/app/miniapp/page.tsx — Telegram WebApp SDK chat UI.
- Tests: telegram-init-data (7), telegram-botapi (5) — 12/12 pass.
- Env docs: TELEGRAM_* vars in .env.example + ENVIRONMENT.md (sync ✓).
- Route-validation check: PASS (body validated via Zod).
2026-08-09 02:23:13 -03:00

76 lines
3.0 KiB
TypeScript

/**
* Telegram WebApp initData verification.
*
* A Telegram Mini App authenticates by passing `initData` (from the
* Telegram.WebApp SDK's `initData` property) to its backend. The only
* trustworthy anchor is the `hash` field: an HMAC-SHA256 over the sorted
* `key=value` pairs (minus `hash`), keyed with SHA256 of the bot token.
*
* Reference: https://core.telegram.org/bots/webapps#validating-data-received-via-the-mini-app
*
* This module is pure and dependency-free (node:crypto only) so it is
* directly unit-testable. Never trust the client-side `initData` alone —
* verification MUST happen server-side.
*/
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
/** Parse a URLSearchParams-style initData string into a record. */
export function parseInitData(initData: string): Record<string, string> {
const out: Record<string, string> = {};
if (!initData) return out;
for (const pair of initData.split("&")) {
const eq = pair.indexOf("=");
if (eq <= 0) continue;
const key = decodeURIComponent(pair.slice(0, eq));
const value = decodeURIComponent(pair.slice(eq + 1));
if (key && !(key in out)) out[key] = value;
}
return out;
}
/**
* Verify a Telegram WebApp initData string against the bot token.
*
* @param initData raw initData string from the Mini App (or `initDataUnsafe` reconstruction)
* @param botToken Telegram bot token (`<id>:<secret>`) — the HMAC secret source
* @param maxAgeSec optional freshness bound on `auth_date` (default 24h per Telegram docs)
* @returns true when the signature matches AND (if maxAgeSec set) auth_date is fresh
*/
export function verifyInitData(
initData: string,
botToken: string,
maxAgeSec = 24 * 60 * 60
): boolean {
if (!initData || !botToken) return false;
const data = parseInitData(initData);
const providedHash = data["hash"];
if (!providedHash) return false;
// Optional freshness check on auth_date (unix seconds).
if (maxAgeSec > 0) {
const authDate = Number.parseInt(data["auth_date"] ?? "", 10);
if (!Number.isFinite(authDate) || authDate <= 0) return false;
const now = Math.floor(Date.now() / 1000);
if (now - authDate > maxAgeSec) return false;
}
// Rebuild the data-check string: sorted key=value pairs, excluding hash.
const pairs = Object.entries(data)
.filter(([k]) => k !== "hash")
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
.map(([k, v]) => `${k}=${v}`);
const dataCheckString = pairs.join("\n");
// secret_key = HMAC_SHA256(key="WebAppData", bot_token)
const secretKey = createHmac("sha256", "WebAppData").update(botToken).digest();
// expected_hash = HMAC_SHA256(secret_key, data_check_string) hex
const expectedHash = createHmac("sha256", secretKey).update(dataCheckString).digest("hex");
const provided = Buffer.from(providedHash, "utf8");
const expected = Buffer.from(expectedHash, "utf8");
if (provided.length !== expected.length) return false;
return timingSafeEqual(provided, expected);
}