forked from c-fu/arah2
237 lines
16 KiB
Markdown
237 lines
16 KiB
Markdown
---
|
||
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>
|