From 22fda6d12a8d6030d0c90dae648b0d7bafaf0874 Mon Sep 17 00:00:00 2001 From: araharah-executor Date: Fri, 23 Oct 2026 01:34:19 +0800 Subject: [PATCH] =?UTF-8?q?fix(03-04):=20HMAC=20data-check-string=20exclud?= =?UTF-8?q?es=20only=20hash=20=E2=80=94=20signature=20field=20participates?= =?UTF-8?q?=20(real=20clients=20rejected=20as=20tampered;=20erratum=20M6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- bot/src/security/initdata.ts | 14 +++++++++--- bot/test/initdata.test.mjs | 43 ++++++++++++++++++++++++++++++++++-- bot/test/webhook.test.mjs | 2 +- 3 files changed, 53 insertions(+), 6 deletions(-) diff --git a/bot/src/security/initdata.ts b/bot/src/security/initdata.ts index 732c78b..de2efc1 100644 --- a/bot/src/security/initdata.ts +++ b/bot/src/security/initdata.ts @@ -10,8 +10,14 @@ * 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 + * 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), @@ -168,9 +174,11 @@ export async function validateInitData( return { ok: false, reason: "malformed" as const }; } - // — data-check-string: hash/signature excluded, sorted by field name — + // — 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" && key !== "signature") + .filter(([key]) => key !== "hash") .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)) .map(([key, value]) => `${key}=${value}`) .join("\n"); diff --git a/bot/test/initdata.test.mjs b/bot/test/initdata.test.mjs index 84359ad..728022c 100644 --- a/bot/test/initdata.test.mjs +++ b/bot/test/initdata.test.mjs @@ -52,10 +52,12 @@ function loadValidator() { /* ————— node:crypto signing helpers (test-side, independent path) ————— */ -/** Data-check-string: fields sorted by key, key=value, LF-joined (Telegram spec). */ +/** 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" && key !== "signature") + .filter(([key]) => key !== "hash") .slice() .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([key, value]) => `${key}=${value}`) @@ -104,6 +106,43 @@ test("T1 known-good signed initData → ok:true, user.id 42, languageCode ms", a /* ————— 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); diff --git a/bot/test/webhook.test.mjs b/bot/test/webhook.test.mjs index 86efe2b..b314987 100644 --- a/bot/test/webhook.test.mjs +++ b/bot/test/webhook.test.mjs @@ -38,7 +38,7 @@ const TEST_TOKEN = "12345:TESTTOKEN"; // same signer token as initdata.test.mjs function dataCheckString(fields) { return fields - .filter(([key]) => key !== "hash" && key !== "signature") + .filter(([key]) => key !== "hash") .slice() .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) .map(([key, value]) => `${key}=${value}`)