diff --git a/bot/test/harness.mjs b/bot/test/harness.mjs new file mode 100644 index 0000000..bebcfdc --- /dev/null +++ b/bot/test/harness.mjs @@ -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; diff --git a/bot/test/initdata.test.mjs b/bot/test/initdata.test.mjs new file mode 100644 index 0000000..84359ad --- /dev/null +++ b/bot/test/initdata.test.mjs @@ -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); +}); diff --git a/bot/test/runner.mjs b/bot/test/runner.mjs new file mode 100644 index 0000000..f1f2740 --- /dev/null +++ b/bot/test/runner.mjs @@ -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: ") — the run never crashes mid-way. + * - Per case: PASS / FAIL (+ 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;