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
This commit is contained in:
@@ -0,0 +1,204 @@
|
||||
#!/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);
|
||||
});
|
||||
Reference in New Issue
Block a user