forked from c-fu/arah2
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:
@@ -10,8 +10,14 @@
|
|||||||
* RED suite's node:crypto signing path, RFC-4231-pinned):
|
* RED suite's node:crypto signing path, RFC-4231-pinned):
|
||||||
* 1. parse `raw` as a Telegram-style querystring (values URL-decoded ONCE;
|
* 1. parse `raw` as a Telegram-style querystring (values URL-decoded ONCE;
|
||||||
* `+` stays literal — do NOT use URLSearchParams)
|
* `+` 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"
|
* 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),
|
* 3. secretKey bytes = HMAC-SHA256(message = utf8(botToken),
|
||||||
* key = utf8(the salt constant, line 179))
|
* key = utf8(the salt constant, line 179))
|
||||||
* checkHex = hex(HMAC-SHA256(message = utf8(dataCheckString),
|
* checkHex = hex(HMAC-SHA256(message = utf8(dataCheckString),
|
||||||
@@ -168,9 +174,11 @@ export async function validateInitData(
|
|||||||
return { ok: false, reason: "malformed" as const };
|
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()]
|
const dataCheckString = [...fields.entries()]
|
||||||
.filter(([key]) => key !== "hash" && key !== "signature")
|
.filter(([key]) => key !== "hash")
|
||||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
.join("\n");
|
.join("\n");
|
||||||
|
|||||||
@@ -52,10 +52,12 @@ function loadValidator() {
|
|||||||
|
|
||||||
/* ————— node:crypto signing helpers (test-side, independent path) ————— */
|
/* ————— 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) {
|
function dataCheckString(fields) {
|
||||||
return fields
|
return fields
|
||||||
.filter(([key]) => key !== "hash" && key !== "signature")
|
.filter(([key]) => key !== "hash")
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
.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 ————— */
|
/* ————— 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 () => {
|
test("T2 flipped hash character → reason tampered", async () => {
|
||||||
const validateInitData = await loadValidator();
|
const validateInitData = await loadValidator();
|
||||||
const now = Math.floor(Date.now() / 1000);
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ const TEST_TOKEN = "12345:TESTTOKEN"; // same signer token as initdata.test.mjs
|
|||||||
|
|
||||||
function dataCheckString(fields) {
|
function dataCheckString(fields) {
|
||||||
return fields
|
return fields
|
||||||
.filter(([key]) => key !== "hash" && key !== "signature")
|
.filter(([key]) => key !== "hash")
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
.sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
||||||
.map(([key, value]) => `${key}=${value}`)
|
.map(([key, value]) => `${key}=${value}`)
|
||||||
|
|||||||
Reference in New Issue
Block a user