Files
arah2/bot/test/initdata.test.mjs
T
araharah-executor fc5001a4eb test(02-01): hand-rolled harness + RED initdata vector suite (D-12, TDD RED)
- bot/test/harness.mjs: zero-dep registry (test(name, fn))
- bot/test/runner.mjs: auto-discovers *.test.mjs, suite-load failure -> FAIL case, exit 0 iff all green
- bot/test/initdata.test.mjs: T1-T7 vector contract signed via node:crypto in-suite
  (T1 known-good, T2 tampered, T3 stale 3600s, T4 boundary 899/901, T5 order-independence,
  T6a-c malformed, T7 RFC-4231 node:crypto independence pin)
- demonstrated RED: 8 FAILs / exit 1 against missing src/security/initdata.ts; GREEN lands in plan 02
2026-10-22 11:38:06 +08:00

205 lines
8.4 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). */
function dataCheckString(fields) {
return fields
.filter(([key]) => key !== "hash" && key !== "signature")
.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 ————— */
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);
});