fix(03-04): HMAC data-check-string excludes only hash — signature field participates (real clients rejected as tampered; erratum M6)

This commit is contained in:
araharah-executor
2026-10-23 01:34:19 +08:00
parent c062ebaee1
commit 22fda6d12a
3 changed files with 53 additions and 6 deletions
+41 -2
View File
@@ -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);
+1 -1
View File
@@ -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}`)