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,10 @@
|
||||
/*
|
||||
* harness.mjs — zero-dependency test registry (D-12).
|
||||
* Suites import { test } and register async cases; runner.mjs drains the registry.
|
||||
* Same hand-rolled shape as theme/scripts/audit.test.mjs (Phase 1 precedent).
|
||||
*/
|
||||
const tests = [];
|
||||
export function test(name, fn) {
|
||||
tests.push({ name, fn });
|
||||
}
|
||||
export const registry = tests;
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
#!/usr/bin/env node
|
||||
/*
|
||||
* runner.mjs — hand-rolled zero-dependency test runner (D-12).
|
||||
*
|
||||
* - Auto-discovers *.test.mjs in this directory (sorted) and dynamic-imports them.
|
||||
* - A suite that fails to LOAD is recorded as one failing case
|
||||
* ("suite load failed: <file>") — the run never crashes mid-way.
|
||||
* - Per case: PASS <name> / FAIL <name> (+ error detail).
|
||||
* - Summary line + exit codes: exit 0 only when every case passed.
|
||||
*/
|
||||
import { readdirSync } from "node:fs";
|
||||
import { registry } from "./harness.mjs";
|
||||
|
||||
const dir = import.meta.dirname;
|
||||
const suites = readdirSync(dir)
|
||||
.filter((f) => f.endsWith(".test.mjs"))
|
||||
.sort();
|
||||
|
||||
for (const file of suites) {
|
||||
try {
|
||||
await import(`./${file}`);
|
||||
} catch (loadErr) {
|
||||
registry.push({
|
||||
name: `suite load failed: ${file}`,
|
||||
fn: () => {
|
||||
throw loadErr;
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let passedCount = 0;
|
||||
let failedCount = 0;
|
||||
for (const { name, fn } of registry) {
|
||||
try {
|
||||
await fn();
|
||||
passedCount++;
|
||||
console.log(`PASS ${name}`);
|
||||
} catch (err) {
|
||||
failedCount++;
|
||||
console.log(`FAIL ${name}`);
|
||||
console.error(` ${err && err.stack ? err.stack : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[runner] ${passedCount} passed, ${failedCount} failed`);
|
||||
process.exitCode = failedCount > 0 ? 1 : 0;
|
||||
Reference in New Issue
Block a user