diff --git a/bot/src/security/initdata.ts b/bot/src/security/initdata.ts new file mode 100644 index 0000000..732c78b --- /dev/null +++ b/bot/src/security/initdata.ts @@ -0,0 +1,215 @@ +/* + * 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`/`signature`, sorted by FIELD + * NAME (byte order, JS default sort), `key=value`, joined with "\n" + * 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/signature excluded, sorted by field name — + const dataCheckString = [...fields.entries()] + .filter(([key]) => key !== "hash" && key !== "signature") + .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, + }; +}