/* * initdata.ts — Telegram WebApp initData HMAC validation (D-11/D-13/D-14). * --------------------------------------------------------------------- * Pure, isolation-first: depends only on the runtime's global * `crypto.subtle` — the identical API in Cloudflare Workers and Node ≥18. * No bot-framework imports, no third-party packages (D-11) so the module runs * unchanged in the Worker AND in the bare-node vector suite (D-12). * * Algorithm (verified against core.telegram.org/bots/webapps + the plan-01 * RED suite's node:crypto signing path, RFC-4231-pinned): * 1. parse `raw` as a Telegram-style querystring (values URL-decoded ONCE; * `+` stays literal — do NOT use URLSearchParams) * 2. dataCheckString = fields minus `hash` ONLY, sorted by FIELD * NAME (byte order, JS default sort), `key=value`, joined with "\n" * (ERRATUM 2026-10-23, device-matrix round 6: the HMAC path must NOT * exclude `signature` — per core.telegram.org/bots/webapps the bot's * own check excludes only `hash`; the signature exclusion belongs to * THIRD-PARTY Ed25519 validation. Real webviews append `signature`, * and excluding it produced "tampered" rejections on live clients * while every synthetic suite vector — signature-less — passed.) * 3. secretKey bytes = HMAC-SHA256(message = utf8(botToken), * key = utf8(the salt constant, line 179)) * checkHex = hex(HMAC-SHA256(message = utf8(dataCheckString), * key = secretKey bytes)) * 4. rejection precedence: malformed → tampered → stale → ok (D-14 * vocabulary; callers log categories only, never the raw payload) * * Freshness window D-13: `auth_date` older than `maxAgeSeconds` (default * 900 s) is rejected as "stale"; the Mini App re-validates on every launch. */ /** Rejection classes (D-14 vocabulary, category-only logging downstream). */ export type InitDataRejection = "malformed" | "tampered" | "stale"; /** Hand-rolled Telegram WebApp user object — no bot framework ships * WebAppInitData typings (research-verified gap); keep the fields minimal * and per the official spec. */ export interface WebAppInitUser { id: number; first_name?: string; last_name?: string; username?: string; language_code?: string; photo_url?: string; is_premium?: boolean; added_to_attachment_menu?: boolean; allows_write_to_pm?: boolean; } /** WebApp chat object (chat_type = "group" / "supergroup" launch context). */ export interface WebAppInitChat { id: number; type: string; title?: string; username?: string; photo_url?: string; } /** Shape of a fully parsed initData payload (hash/signature excluded from * rawFields — they are auth metadata, not payload fields). */ export interface ParsedInitData { user: WebAppInitUser; chat?: WebAppInitChat; authDate: number; rawFields: Map; } export interface ValidateInitDataOptions { /** "Now" in seconds-since-epoch — injectable for deterministic tests. */ nowSeconds?: number; /** Freshness window in seconds (D-13 default: 900). */ maxAgeSeconds?: number; } export type InitDataResult = | { ok: true; user: WebAppInitUser; chat?: WebAppInitChat; languageCode: string | null; authDate: number; } | { ok: false; reason: InitDataRejection }; /** Parse `key=value&…` into an ordered insertion map (values decoded once). * `+` is literal per Telegram's initData encoding — never URLSearchParams. */ function parseFields(raw: string): Map { const fields = new Map(); for (const pair of raw.split("&")) { if (pair.length === 0) continue; const eq = pair.indexOf("="); if (eq === -1) continue; // bare key with no value: not a field const key = pair.slice(0, eq); const value = pair.slice(eq + 1); try { fields.set(key, decodeURIComponent(value)); } catch { // Undecodable percent-escape — keep raw so HMAC compare fails ("tampered") // rather than silently dropping the field. fields.set(key, value); } } return fields; } /** UTF-8 bytes of a string (SubtleCrypto needs bytes, not JS strings). */ function utf8(value: string): Uint8Array { return new TextEncoder().encode(value); } async function hmacBytesBytes(message: Uint8Array, key: Uint8Array): Promise { const cryptoKey = await crypto.subtle.importKey( "raw", key as unknown as ArrayBuffer, // TS DOM/lib types differ; runtime wants raw bytes { name: "HMAC", hash: "SHA-256" }, false, ["sign"], ); const sig = await crypto.subtle.sign("HMAC", cryptoKey, message as unknown as ArrayBuffer); return new Uint8Array(sig); } async function hmacHexBytes(message: Uint8Array, key: Uint8Array): Promise { const bytes = await hmacBytesBytes(message, key); return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); } /** Constant-time comparison of two equal-role hex strings * (mirrors grammY's shipped approach — bytes compared, XOR accumulated). */ function timingSafeHexEqual(a: string, b: string): boolean { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) { diff |= a.charCodeAt(i) ^ b.charCodeAt(i); } return diff === 0; } export async function validateInitData( raw: string, botToken: string, opts: ValidateInitDataOptions = {}, ): Promise { const nowSeconds = opts.nowSeconds ?? Math.floor(Date.now() / 1000); const maxAgeSeconds = opts.maxAgeSeconds ?? 900; // D-13 const fields = parseFields(raw); const hash = fields.get("hash"); // — malformed: Telegram always carries the first-party `hash` field — if (hash === undefined) return { ok: false, reason: "malformed" as const }; // — malformed: user payload must parse and carry a numeric id — const userJson = fields.get("user"); let user: WebAppInitUser | undefined; if (userJson !== undefined) { try { const parsed: unknown = JSON.parse(userJson); if (parsed && typeof parsed === "object" && "id" in parsed) { user = parsed as WebAppInitUser; } } catch { user = undefined; } } if (user === undefined || typeof user.id !== "number") { return { ok: false, reason: "malformed" as const }; } // — malformed: auth_date must be present and numeric (seconds, Pitfall 3) — const authDateRaw = fields.get("auth_date"); const authDate = authDateRaw === undefined ? NaN : Number(authDateRaw); if (authDateRaw === undefined || !Number.isFinite(authDate)) { return { ok: false, reason: "malformed" as const }; } // — data-check-string: hash excluded ONLY (signature participates — the // HMAC spec excludes just `hash`; excluding it broke real clients, // device-matrix round 6) — sorted by field name — const dataCheckString = [...fields.entries()] .filter(([key]) => key !== "hash") .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([key, value]) => `${key}=${value}`) .join("\n"); // — key derivation (Pitfall 2): key = the platform salt constant, message = botToken — const secretKeyBytes = await hmacBytesBytes(utf8(botToken), utf8("WebAppData")); const checkHex = await hmacHexBytes(utf8(dataCheckString), secretKeyBytes); if (!timingSafeHexEqual(checkHex, hash)) { return { ok: false, reason: "tampered" as const }; } // — stale: age strictly beyond the D-13 window (899 ok / 900 ok / 901 stale) — if (nowSeconds - authDate > maxAgeSeconds) { return { ok: false, reason: "stale" as const }; } const chatJson = fields.get("chat"); let chat: WebAppInitChat | undefined; if (chatJson !== undefined) { try { const parsedChat: unknown = JSON.parse(chatJson); if ( parsedChat && typeof parsedChat === "object" && "id" in parsedChat && typeof (parsedChat as { id?: unknown }).id === "number" ) { chat = parsedChat as WebAppInitChat; } } catch { chat = undefined; } } return { ok: true, user, ...(chat !== undefined ? { chat } : {}), languageCode: user.language_code ?? null, authDate, }; }