---
phase: 02-bot-skeleton-initdata-security
plan: 02
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
- bot/src/security/initdata.ts
- bot/src/kv/sessions.ts
- bot/test/sessions.test.mjs
- bot/test/privacy.test.mjs
autonomous: true
requirements: [PRIV-02]
must_haves:
truths:
- "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"
artifacts:
- path: "bot/src/security/initdata.ts"
provides: "validateInitData + WebAppInitUser hand-rolled types (grammy does not ship them — research-verified)"
exports: ["validateInitData", "InitDataRejection"]
- path: "bot/src/kv/sessions.ts"
provides: "TTL-only KV access layer: mint/rotate/claim getters"
exports: ["SESSION_TTL_SECONDS", "mintSession", "getClaimByToken", "getTokenForChat"]
- path: "bot/test/sessions.test.mjs"
provides: "Rotate-on-new invariant + D-08 key-set-equality + TTL assertions with mock KV"
- path: "bot/test/privacy.test.mjs"
provides: "PRIV-02 architecture gate: every .put( line carries expirationTtl"
key_links:
- from: "bot/src/security/initdata.ts"
to: "npm test -w bot initdata suite"
via: "validateInitData consumes the plan-01 T1–T7 suite (GREEN here)"
pattern: "validateInitData"
- from: "bot/src/kv/sessions.ts"
to: "bot/src/security/initdata.ts"
via: "languageCode flows from validated initData into SessionClaim (D-08)"
pattern: "language_code"
- from: "bot/src/kv/sessions.ts"
to: "KVNamespace.put"
via: "every put call line includes expirationTtl: SESSION_TTL_SECONDS"
pattern: "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).
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
@.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):
```typescript
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):
```typescript
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; // 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):
```typescript
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;
// mints crypto.randomUUID() token; rotates (D-07): reads chat: pointer,
// deletes the previous `session:` key, then puts session: and chat: —
// both puts ALWAYS carry { expirationTtl: SESSION_TTL_SECONDS } (PRIV-02, type-enforced by usage).
export async function getTokenForChat(kv: KVNamespace, chatId: number): Promise;
export async function getClaimByToken(kv: KVNamespace, token: string): Promise;
// 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).
## 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 |
- `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)
- 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)