feat(02-02): validateInitData — SubtleCrypto HMAC core with rejection classes (D-13, D-14)

- GREENs the plan-01 RED vector suite: T1-T7 all PASS (10/10 cases)
- Telegram-verified two-step key derivation: secretKey = HMAC(botToken, key=salt), checkHex = HMAC(dataCheckString, secretKey bytes)
- Sort by field name, hash/signature excluded, values decoded once with '+' literal (no URLSearchParams)
- Rejection precedence: malformed (hash/user.id/auth_date) -> tampered (timing-safe hex compare) -> stale (>900s, D-13)
- Hand-rolled WebAppInitUser/WebAppInitChat types (grammy ships none); zero deps of any kind — pure (raw, botToken, opts)
This commit is contained in:
araharah-executor
2026-10-22 11:41:46 +08:00
parent 1f15ad202b
commit 54bffe728c
+215
View File
@@ -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<string, string>;
}
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<string, string> {
const fields = new Map<string, string>();
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<Uint8Array> {
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<string> {
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<InitDataResult> {
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,
};
}