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

237 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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"
---
<objective>
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).
</objective>
<execution_context>
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
@$HOME/.config/opencode/get-shit-done/templates/summary.md
</execution_context>
<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
<interfaces>
<!-- Contracts created in plan 01 that this plan consumes; contracts this plan creates for plan 03. -->
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<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):
```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<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).
```
</interfaces>
</context>
<tasks>
<task type="auto" tdd="true">
<name>task 1: validateInitData — HMAC core with rejection classes (D-13, D-14; GREEN the RED suite)</name>
<files>bot/src/security/initdata.ts</files>
<read_first>
- .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)
</read_first>
<behavior>
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.
</behavior>
<action>
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 `<interfaces>` 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)).
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck</automated>
</verify>
<acceptance_criteria>
- `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
</acceptance_criteria>
<done>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).</done>
</task>
<task type="auto">
<name>task 2: TTL-only KV sessions with rotate-on-new + PRIV-02 storage gates (D-06, D-07, D-08, D-14)</name>
<files>bot/src/kv/sessions.ts, bot/test/sessions.test.mjs, bot/test/privacy.test.mjs</files>
<read_first>
- 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)
</read_first>
<action>
1. **bot/src/kv/sessions.ts** per the `<interfaces>` 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:<token>`; per-chat pointer under `chat:<chat_id>` 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:<claim.chat_id>` → 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:<tok1>` 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)
</action>
<verify>
<automated>npm test -w bot && npm run bot:typecheck && grep -cn "expirationTtl" bot/src/kv/sessions.ts | head -1</automated>
</verify>
<acceptance_criteria>
- `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:<token>` 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
</acceptance_criteria>
<done>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).</done>
</task>
</tasks>
<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>
<verification>
- `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)
</verification>
<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>
<output>
After completion, create `.planning/phases/02-bot-skeleton-initdata-security/02-02-SUMMARY.md`
</output>