forked from c-fu/arah2
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