Files
arah2/.planning/phases/02-bot-skeleton-initdata-security/02-02-PLAN.md
T
2026-10-23 22:37:55 +08:00

16 KiB
Raw Blame History

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
02-bot-skeleton-initdata-security 02 execute 2
02-01
bot/src/security/initdata.ts
bot/src/kv/sessions.ts
bot/test/sessions.test.mjs
bot/test/privacy.test.mjs
true
PRIV-02
truths artifacts key_links
D-13: tampered, stale (auth_date older than ~15 min), and malformed initData are rejected with typed rejection classes; known-good HMAC passes (ROADMAP SC3 — the security gate)
D-06: a validated identity mints exactly one live opaque token; D-07: re-mint rotates and deletes the previous token
D-08/PRIV-02: every KV write carries an expirationTtl — no write path can persist without TTL; the claim is exactly user_id + chat_id + issued_at + language_code, never more
D-11: rejection classes vocabulary (tampered/stale + expired-on-read) matches D-14's category-only log contract downstream
path provides exports
bot/src/security/initdata.ts validateInitData + WebAppInitUser hand-rolled types (grammy does not ship them — research-verified)
validateInitData
InitDataRejection
path provides exports
bot/src/kv/sessions.ts TTL-only KV access layer: mint/rotate/claim getters
SESSION_TTL_SECONDS
mintSession
getClaimByToken
getTokenForChat
path provides
bot/test/sessions.test.mjs Rotate-on-new invariant + D-08 key-set-equality + TTL assertions with mock KV
path provides
bot/test/privacy.test.mjs PRIV-02 architecture gate: every .put( line carries expirationTtl
from to via pattern
bot/src/security/initdata.ts npm test -w bot initdata suite validateInitData consumes the plan-01 T1–T7 suite (GREEN here) validateInitData
from to via pattern
bot/src/kv/sessions.ts bot/src/security/initdata.ts languageCode flows from validated initData into SessionClaim (D-08) language_code
from to via pattern
bot/src/kv/sessions.ts KVNamespace.put every put call line includes expirationTtl: SESSION_TTL_SECONDS expirationTtl
Implement the phase's security exit criterion as pure, testable code: HMAC-SHA256 initData validation with rejection classes, and the TTL-only KV session layer with rotate-on-new — making the RED vector suite from plan 01 GREEN and adding the PRIV-02 storage gates.

Purpose: "The initData validator is the first backend artifact" (ROADMAP goal) — provability comes from the vector suite, privacy-by-construction from the type-enforced TTL layer. Output: src/security/initdata.ts + src/kv/sessions.ts, both GREEN under npm test -w bot (sessions and privacy suites included).

<execution_context> @$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md @$HOME/.config/opencode/get-shit-done/templates/summary.md </execution_context>

@.planning/phases/02-bot-skeleton-initdata-security/02-CONTEXT.md @.planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md @.planning/phases/02-bot-skeleton-initdata-security/02-VALIDATION.md @.planning/phases/02-bot-skeleton-initdata-security/02-01-SUMMARY.md

From bot/src/config.ts (plan 01):

export interface Env {
  BOT_TOKEN: string;
  WEBHOOK_SECRET: string;
  chat_sessions: KVNamespace;
}

Test harness (plan 01): import { test } from "./harness.mjs" inside bot/test/*.test.mjs; runner auto-discovers every *.test.mjs.

API this plan creates for plan 03 (bot/src/security/initdata.ts):

export type InitDataRejection = "malformed" | "tampered" | "stale";

export interface ParsedInitData {
  user: { id: number; first_name?: string; username?: string; language_code?: string; [k: string]: unknown };
  chat?: { id: number; type: string; [k: string]: unknown };
  authDate: number;              // seconds-since-epoch (Pitfall 3: never ms)
  rawFields: Map<string, string>; // decoded field values, `hash`/`signature` excluded
}

export interface WebAppInitUser { id: number; first_name?: string; last_name?: string; username?: string; language_code?: string; photo_url?: string; is_premium?: boolean; added_to_attachment_menu?: boolean; allows_write_to_pm?: boolean; } // hand-rolled — NOT shipped by grammy 1.46.0 (research-verified gap)

export async function validateInitData(
  raw: string,
  botToken: string,
  opts?: { nowSeconds?: number; maxAgeSeconds?: number },
): Promise<
  | { ok: true; user: WebAppInitUser; chat?: { id: number; type: string }; languageCode: string | null; authDate: number }
  | { ok: false; reason: InitDataRejection }
>;
// defaults: maxAgeSeconds = 900 (D-13), nowSeconds = Math.floor(Date.now() / 1000)

API this plan creates for plan 03 (bot/src/kv/sessions.ts):

export const SESSION_TTL_SECONDS = 900; // D-13 pairing — "minutes" envelope

export interface SessionClaim {
  user_id: number;        // D-08: the entire claim surface — nothing else ever stored
  chat_id: number;
  issued_at: number;      // seconds
  language_code: string | null;
}

export async function mintSession(kv: KVNamespace, claim: SessionClaim): Promise<string>;
// mints crypto.randomUUID() token; rotates (D-07): reads chat:<chat_id> pointer,
// deletes the previous `session:<oldToken>` key, then puts session:<newToken> and chat:<chat_id> —
// both puts ALWAYS carry { expirationTtl: SESSION_TTL_SECONDS } (PRIV-02, type-enforced by usage).

export async function getTokenForChat(kv: KVNamespace, chatId: number): Promise<string | null>;

export async function getClaimByToken(kv: KVNamespace, token: string): Promise<SessionClaim | null>;
// null = expired/unknown — callers treat as "expired" rejection class (D-14 vocabulary).
task 1: validateInitData — HMAC core with rejection classes (D-13, D-14; GREEN the RED suite) bot/src/security/initdata.ts - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Code Examples + § Common Pitfalls 1–3 (algorithm, key derivation direction, sorting) - bot/test/initdata.test.mjs (the exact contract being GREENed — read before implementing) - bot/src/config.ts (existing Env; initdata.ts must not import Env — it is pure (raw, botToken) by D-11) Green conditions come from the plan 01 suite (T1–T7): known-good → ok + parsed user/languageCode; tampered hash → "tampered"; age 3600s → "stale"; 899s ok / 901s stale; out-of-order field insertion still OK; missing hash / NaN auth_date / user without id → "malformed"; RFC-4231 pin cross-check in-suite. Implement exactly per the RESEARCH.md-verified algorithm (no deviations; no third-party packages): 1. **Parse:** split `raw` on `&` into `key=value` pairs; values URL-decode once (use `decodeURIComponent` on each value after split — `+` in initData is literal per Telegram's querystring encoding; do NOT use URLSearchParams because it converts `+` to space). Exclude both `hash` and `signature` from the check-string set but parse `hash`/`auth_date`/`user` normally when present. Keep insertion map (`rawFields`). 2. **Data-check-string:** iterate field entries sorted **by field name** (default JS string sort — byte/case-sensitive), format `${key}=${value}`, join with `"\n"` (0x0A). 3. **Key derivation (Pitfall 2 — direction):** - secretKey bytes = HMAC-SHA256(message = UTF-8(botToken), key = UTF-8("WebAppData")) - checkHex = hex(HMAC-SHA256(message = UTF-8(dataCheckString), key = secretKey)) Both via `crypto.subtle.importKey("raw", …, { name: "HMAC", hash: "SHA-256" }, false, ["sign"])` + `crypto.subtle.sign` (identical API in Workers + Node 24 — research-verified). 4. **Rejection precedence and classes (D-14 vocabulary):** raw without `hash` → `malformed`; `user` absent/unparseable JSON or missing numeric `id` → `malformed`; `auth_date` absent/non-numeric → `malformed`; checkHex mismatch (timing-safe hex compare — compare via constant-time byte loop on UTF-8 bytes, mirroring grammY's shipped approach) → `tampered`; `nowSeconds - authDate > maxAgeSeconds` (default 900) → `stale`; else `{ ok: true, user, chat, languageCode: user.language_code ?? null, authDate }`. 5. **Types:** hand-roll `WebAppInitUser`/`WebAppInitChat` in this file from Telegram's WebAppInitData spec (research gap resolution — grammy does NOT ship these, verified). Export `InitDataRejection`/`ParsedInitData` per `` so plan 03's endpoint has a typed surface. 6. Erasable-only TS (plan 01 constraint). Module imports use explicit `.ts` extensions (`allowImportingTsExtensions` is on; no runtime imports needed here besides types — keep the file dependency-free of grammY so it stays testable without provider deps (D-11)). npm test -w bot && npm run bot:typecheck - `npm test -w bot` exits 0 with the initdata suite fully PASS (T1–T7 listed PASS) — RED suite is GREEN - `bot/src/security/initdata.ts` contains `export async function validateInitData`, `InitDataRejection`, and the string `"WebAppData"` exactly once (the key derivation constant) - No `import` of `grammy` inside `bot/src/security/initdata.ts` (D-11 isolation — grep returns 0) - `npm run bot:typecheck` exits 0 - Sorting is by field name: suite T5 (out-of-order insertion) passes The validator passes the pinned vector suite including tamper/stale/boundary/order/well-formed classes under SubtleCrypto (D-13 window wired via maxAgeSeconds default 900). task 2: TTL-only KV sessions with rotate-on-new + PRIV-02 storage gates (D-06, D-07, D-08, D-14) bot/src/kv/sessions.ts, bot/test/sessions.test.mjs, bot/test/privacy.test.mjs - bot/src/security/initdata.ts (just written — languageCode flows into the claim; SessionClaim contract above) - bot/test/harness.mjs + bot/test/runner.mjs (auto-discovery + `test()` API from plan 01) - .planning/phases/02-bot-skeleton-initdata-security/02-RESEARCH.md § Pattern 3 (TTL-only KV access layer) + Open Questions resolution (rotate mechanics: delete-then-mint via chat pointer) 1. **bot/src/kv/sessions.ts** per the `` contract: - `SESSION_TTL_SECONDS = 900` (D-13 pairing — a session cannot outlive the freshness proof that minted it; "minutes" envelope per CONTEXT). - Key scheme: claim record under `session:`; per-chat pointer under `chat:` holding the current token string. Both keys ALWAYS written with `{ expirationTtl: SESSION_TTL_SECONDS }`. - `mintSession(kv, claim)`: `token = crypto.randomUUID()` (opaque by construction, D-06); read `chat:` → oldToken; `kv.delete("session:" + oldToken)` if found (D-07 rotate-on-new — the structural close of the recent-stale-replay mint window); `kv.put("session:" + token, JSON.stringify(claim), { expirationTtl: SESSION_TTL_SECONDS })`; `kv.put("chat:" + claim.chat_id, token, { expirationTtl: SESSION_TTL_SECONDS })`; return token. - `getTokenForChat(kv, chatId)`: read pointer; `getClaimByToken(kv, token)`: read record, JSON.parse, return or null. Reads never write. 2. **bot/test/sessions.test.mjs** — in-memory 20-line KV fake (Map-backed KVNamespace subset: `get`, `put(key, value, opts)` asserting `opts.expirationTtl` is present and finite, `delete`, plus a `ttlLog` of writes): - mints return a token whose record contains EXACTLY the D-08 keys `{user_id, chat_id, issued_at, language_code}` (assert key-set equality — no drift toward profiles) - every `put` recorded in ttlLog carries expirationTtl === 900 (PRIV-02 by construction) - rotate: mint twice for same chat → older `session:` key is gone, pointer target is tok2, exactly one live session record (D-07 invariant) - `getClaimByToken` round-trips; token unknown → null (expired-path class, D-14) - claim survives with `language_code: "ms"` and null both 3. **bot/test/privacy.test.mjs** — PRIV-02 architecture gate, zero-KV: - Read `src/kv/sessions.ts` + `src/webhook.ts` (latter may not exist yet — filter existence) and assert every `.put(` occurrence on any line also contains `expirationTtl` on the same line (grep gate: no TTL-free write path can ship) - Assert `bot/src/kv/sessions.ts` exports nothing named like persistence (e.g., `saveProfile`, `storeLocation` do not exist) npm test -w bot && npm run bot:typecheck && grep -cn "expirationTtl" bot/src/kv/sessions.ts | head -1 - `npm test -w bot` exits 0 — three suites PASS (initdata, sessions, privacy) - `sessions.test.mjs` contains a case named like `rotate` / D-07 that asserts the old `session:` key no longer exists after re-mint - Every KV write in `src/kv/sessions.ts` occurs with `{ expirationTtl: SESSION_TTL_SECONDS }` visible on the call line (privacy suite enforces) - `SessionClaim` interface contains only `user_id`, `chat_id`, `issued_at`, `language_code` (D-08 — grep the file for a 5th property: none) - `npm run bot:typecheck` exits 0 Opaque tokens mint only alongside TTL-expiring claims, rotate-on-new invalidates the previous token, PRIV-02 gates are in the automated suite (this closes the plan-01→02 dependency: full GREEN).

<threat_model>

Trust Boundaries

Boundary Description
client → /api/session entry (built in plan 03) initData arrives untrusted; this plan defines the ONLY accepted path through validation
KV writes → durable storage any schema-less KV write could persist PII indefinitely

STRIDE Threat Register

Threat ID Category Component Disposition Mitigation Plan
T-02-01 Tampering / Spoofing (forged initData) validateInitData mitigate HMAC-SHA256 over data-check-string with derived secret key (Telegram-verified algorithm); constant-time hex compare
T-02-02 Replay of stale-but-valid hash sessions.ts + validateInitData(900s) mitigate auth_date freshness 900s (D-13) + rotate-on-new invalidates any token minted from a replay (D-07); suite pins both
T-02-05 Spoofing (token prediction) mintSession mitigate crypto.randomUUID() — 122-bit RNG, not Math.random
T-02-06 Information Disclosure (persistent claim drift) sessions.ts mitigate D-08 key-set-equality test + same-line expirationTtl grep gate in privacy.test.mjs
</threat_model>
- `npm test -w bot` exit 0 (three suites green: initdata + sessions + privacy) - `npm run bot:typecheck` exit 0 - grep gate: every `.put(` in src/kv/sessions.ts carries `expirationTtl` on its line - No grammy import inside src/security/initdata.ts or src/kv/sessions.ts (isolation per D-11)

<success_criteria>

  • Security exit criterion (ROADMAP SC3 logic): tampered/stale/malformed initData vectors all rejected; known-good HMAC passes — in automated CI-style suite
  • Privacy by construction (ROADMAP SC4 logic): TTL-only namespace written without exception; claim is exactly D-08-minimal; rotate-on-new holds the one-live-token-per-chat invariant
  • All bots suites pass without network, account, or Provider dependencies (pure Node 24) </success_criteria>
After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-02-SUMMARY.md`