forked from c-fu/arah2
244 lines
10 KiB
JavaScript
244 lines
10 KiB
JavaScript
#!/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 — ALL fields except `hash`; the signature field participates in the
|
||
* bot's own HMAC check, device-matrix round 6 erratum). */
|
||
function dataCheckString(fields) {
|
||
return fields
|
||
.filter(([key]) => key !== "hash")
|
||
.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 ————— */
|
||
|
||
/* T2b — ERRATUM regression (device-matrix round 6): real webviews append the
|
||
* Ed25519 `signature` field; per the official HMAC spec the data-check-string
|
||
* excludes ONLY `hash`, so a signed body carrying `signature` must validate.
|
||
* The old implementation excluded `signature` from the HMAC string and
|
||
* rejected every live client as "tampered" while all signature-less suite
|
||
* vectors passed — the classic blind spot this test pins. */
|
||
|
||
test("T2b real-shaped initData WITH a signature field → ok:true", async () => {
|
||
const validateInitData = await loadValidator();
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const raw = buildInitData([
|
||
["query_id", "AAH3xSample"],
|
||
["signature", "c2FtcGxlX3NpZ25hdHVyZV9icnVmX2Jhc2U2NA"],
|
||
["user", JSON.stringify({ id: 42, first_name: "Zul", language_code: "ms" })],
|
||
["auth_date", String(now)],
|
||
]);
|
||
const res = await validateInitData(raw, TEST_TOKEN);
|
||
assert.equal(res.ok, true, `expected ok:true with signature field, got ${JSON.stringify(res)}`);
|
||
});
|
||
|
||
test("T2b flipped hash with signature present → still tampered", async () => {
|
||
const validateInitData = await loadValidator();
|
||
const now = Math.floor(Date.now() / 1000);
|
||
const raw = buildInitData([
|
||
["query_id", "AAH3xSample"],
|
||
["signature", "c2FtcGxlX3NpZ25hdHVyZV9icnVmX2Jhc2U2NA"],
|
||
["user", JSON.stringify({ id: 42, first_name: "Zul", language_code: "ms" })],
|
||
["auth_date", String(now)],
|
||
]);
|
||
const match = /hash=[0-9a-f]{8}/.exec(raw);
|
||
assert.ok(match !== null);
|
||
const tampered = raw.slice(0, match.index) + `hash=${(createHmac("sha256", "seed").update("x").digest("hex"))}${raw.slice(match.index + "hash=".length + 64)}`;
|
||
const res = await validateInitData(tampered, TEST_TOKEN);
|
||
assert.equal(res.ok, false);
|
||
assert.equal(res.reason, "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);
|
||
});
|