#!/usr/bin/env node /* * initdata.test.mjs — Telegram WebApp initData HMAC validation vector suite. * ------------------------------------------------------------------ * RED phase (plan 02-01, D-15/D-17 + RESEARCH Pitfalls 1–3): * every validator case below fails while ../src/security/initdata.ts * does not exist — the module import happens lazily inside each case, * so T1–T7 report one FAIL per vector instead of a lost suite load. * Plan 02 goes GREEN against exactly this contract. * * Run: npm test -w bot (exit 0 = all green) * * Contract under test (D-13 freshness window = 900 s): * T1 known-good initData (signed here via node:crypto) → ok:true, * user.id === 42, languageCode === "ms" * T2 any character of hash flipped → { ok:false, reason:"tampered" } * T3 auth_date = now − 3600 with default 900 s window → reason:"stale" * T4 freshness boundary: age 899 s → ok:true; age 901 s → "stale" * T5 order-independence: fields arriving out of alphabetical order * (user before auth_date, start_param mixed in) still validate * T6 malformed rejects: (a) hash field missing, (b) auth_date * non-numeric, (c) user JSON missing id * T7 suite self-check: node:crypto HMAC(key="key", data="data") * equals the RFC-4231 test-case-1 vector — pins the test suite's * HMAC primitive independent of the SubtleCrypto path under test * * The signing helper lives IN this file (createHmac twice: secret = * HMAC(key="WebAppData", data=botToken); hash = HMAC(key=secretBytes, * data=checkString)) — independent of the implementation path. * Fixtures stay in-process; nothing is written into the repo. */ import { strict as assert } from "node:assert"; import { createHmac } from "node:crypto"; import { test } from "./harness.mjs"; const TEST_TOKEN = "12345:TESTTOKEN"; /** RFC-4231 test case 1 expected digest (self-check pin). */ const RFC4231_CASE1 = "5031fe3d989c6d1537a013fa6e739da23463fdaec3b70137d828e36ace221bd0"; // Lazy validator load — missing module => per-case ERR_MODULE_NOT_FOUND (the RED state). let validatorPromise = null; function loadValidator() { if (!validatorPromise) { validatorPromise = import("../src/security/initdata.ts").then( (m) => m.validateInitData, ); } return validatorPromise; } /* ————— node:crypto signing helpers (test-side, independent path) ————— */ /** Data-check-string: fields sorted by key, key=value, LF-joined (Telegram * spec — ALL fields except `hash`; the signature field participates in the * bot's own HMAC check, device-matrix round 6 erratum). */ function dataCheckString(fields) { return fields .filter(([key]) => key !== "hash") .slice() .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([key, value]) => `${key}=${value}`) .join("\n"); } /** secret_key = HMAC_SHA256(key="WebAppData", data=botToken), as digest bytes. */ function telegramSecretKey(botToken) { return createHmac("sha256", "WebAppData").update(botToken, "utf8").digest(); } /** * Build a wire-format initData string signed with the test token. * `fields` is [key, plainValue] in arrival ORDER (wire order preserved); * the check-string is computed over the key-sorted decoded values. */ function buildInitData(fields) { const hash = createHmac("sha256", telegramSecretKey(TEST_TOKEN)) .update(dataCheckString(fields), "utf8") .digest("hex"); const wire = fields .map(([key, value]) => `${key}=${encodeURIComponent(value)}`) .join("&"); return `${wire}&hash=${hash}`; } function signedUser(authDateSeconds, extraFields = []) { return buildInitData([ ...extraFields, ["user", JSON.stringify({ id: 42, first_name: "Zul", language_code: "ms" })], ["auth_date", String(authDateSeconds)], ]); } /* ————— T1 — known-good signed initData ————— */ test("T1 known-good signed initData → ok:true, user.id 42, languageCode ms", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = signedUser(now); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, true, `expected ok:true, got ${JSON.stringify(res)}`); assert.equal(res.user.id, 42); assert.equal(res.languageCode, "ms"); }); /* ————— T2 — flipped hash character → tampered ————— */ /* T2b — ERRATUM regression (device-matrix round 6): real webviews append the * Ed25519 `signature` field; per the official HMAC spec the data-check-string * excludes ONLY `hash`, so a signed body carrying `signature` must validate. * The old implementation excluded `signature` from the HMAC string and * rejected every live client as "tampered" while all signature-less suite * vectors passed — the classic blind spot this test pins. */ test("T2b real-shaped initData WITH a signature field → ok:true", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = buildInitData([ ["query_id", "AAH3xSample"], ["signature", "c2FtcGxlX3NpZ25hdHVyZV9icnVmX2Jhc2U2NA"], ["user", JSON.stringify({ id: 42, first_name: "Zul", language_code: "ms" })], ["auth_date", String(now)], ]); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, true, `expected ok:true with signature field, got ${JSON.stringify(res)}`); }); test("T2b flipped hash with signature present → still tampered", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = buildInitData([ ["query_id", "AAH3xSample"], ["signature", "c2FtcGxlX3NpZ25hdHVyZV9icnVmX2Jhc2U2NA"], ["user", JSON.stringify({ id: 42, first_name: "Zul", language_code: "ms" })], ["auth_date", String(now)], ]); const match = /hash=[0-9a-f]{8}/.exec(raw); assert.ok(match !== null); const tampered = raw.slice(0, match.index) + `hash=${(createHmac("sha256", "seed").update("x").digest("hex"))}${raw.slice(match.index + "hash=".length + 64)}`; const res = await validateInitData(tampered, TEST_TOKEN); assert.equal(res.ok, false); assert.equal(res.reason, "tampered"); }); test("T2 flipped hash character → reason tampered", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = signedUser(now); const match = raw.match(/hash=([0-9a-f]{64})$/); assert.ok(match, "signed fixture must carry a 64-hex-char hash"); const flippedHash = String.fromCharCode( match[1].charCodeAt(0) ^ 1, ) + match[1].slice(1); const tampered = raw.slice(0, match.index) + `hash=${flippedHash}`; const res = await validateInitData(tampered, TEST_TOKEN); assert.equal(res.ok, false, `expected ok:false, got ${JSON.stringify(res)}`); assert.equal(res.reason, "tampered"); }); /* ————— T3 — stale beyond the 900 s window ————— */ test("T3 auth_date 3600 s old with default 900 s window → reason stale", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = signedUser(now - 3600); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, false, `expected ok:false, got ${JSON.stringify(res)}`); assert.equal(res.reason, "stale"); }); /* ————— T4 — freshness boundary (899 ok / 901 stale) ————— */ test("T4 freshness boundary: age 899 s → ok:true", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = signedUser(now - 899); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, true, `expected ok:true at 899 s, got ${JSON.stringify(res)}`); }); test("T4 freshness boundary: age 901 s → reason stale", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = signedUser(now - 901); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, false, `expected ok:false at 901 s, got ${JSON.stringify(res)}`); assert.equal(res.reason, "stale"); }); /* ————— T5 — field order independence (RESEARCH Pitfall 1) ————— */ test("T5 fields arriving out of alphabetical order still validate", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); // Arrival order: user, start_param, auth_date — none alphabetical. // Signed check-string must still be built over the sorted keys. const raw = signedUser(now, [["start_param", "klcc"]]); const res = await validateInitData(raw, TEST_TOKEN); assert.equal( res.ok, true, `out-of-order fields must validate (Pitfall 1), got ${JSON.stringify(res)}`, ); assert.equal(res.user.id, 42); }); /* ————— T6 — malformed rejection classes ————— */ test("T6a initData without hash field → reason malformed", async () => { const validateInitData = await loadValidator(); const now = Math.floor(Date.now() / 1000); const raw = signedUser(now).replace(/&hash=[0-9a-f]{64}$/, ""); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, false, `expected ok:false, got ${JSON.stringify(res)}`); assert.equal(res.reason, "malformed"); }); test("T6b non-numeric auth_date → reason malformed", async () => { const validateInitData = await loadValidator(); const raw = signedUser("forty-two"); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, false, `expected ok:false, got ${JSON.stringify(res)}`); assert.equal(res.reason, "malformed"); }); test("T6c user JSON missing id → reason malformed", async () => { const validateInitData = await loadValidator(); const raw = buildInitData([ ["user", JSON.stringify({ first_name: "Zul", language_code: "ms" })], ["auth_date", String(Math.floor(Date.now() / 1000))], ]); const res = await validateInitData(raw, TEST_TOKEN); assert.equal(res.ok, false, `expected ok:false, got ${JSON.stringify(res)}`); assert.equal(res.reason, "malformed"); }); /* ————— T7 — RFC-4231 pin (independence self-check) ————— */ test("T7 node:crypto HMAC(key='key', data='data') equals RFC-4231 case 1 vector", () => { const hex = createHmac("sha256", "key").update("data", "utf8").digest("hex"); assert.equal(hex, RFC4231_CASE1); });