Files
arah2/bot/test/initdata.test.mjs
T

244 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});