forked from c-fu/arah2
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user