fix(02-bot-skeleton-initdata-security): WR-03 honest rotate-on-new invariant + best-effort post-mint reconciliation (fold in IN-01b dead branch, IN-01c Phase-4 notes)

This commit is contained in:
araharah-executor
2026-10-22 20:21:11 +08:00
parent 3ea1fdd932
commit af691ae40e
3 changed files with 118 additions and 15 deletions
+44 -8
View File
@@ -10,8 +10,26 @@
* language_code. Nothing else is ever serialized to storage.
* - Tokens are opaque by construction: `crypto.randomUUID()` (T-02-05).
* - Rotate-on-new (D-07): a fresh validated identity for a chat deletes
* the previous `session:<token>` record before minting — one live
* token per chat, structurally closing the replay mint window.
* the previous `session:<token>` record before minting, then runs one
* best-effort post-mint reconciliation (WR-03).
*
* Honest invariant under KV semantics (no transactions, eventual
* consistency — WR-03 corrected the earlier "structurally closed" claim):
* - one *intended* live token per chat: the `chat:<chat_id>` pointer's
* target at any sequential moment;
* - interleaved mints (two requests racing the same pre-write pointer)
* converge at mint time or at next use — the per-mint sweep deletes
* the pointer's predecessor, the post-mint reconciliation re-reads
* and sweeps when a racing mint's pointer put won, and any record
* orphaned by a lost race is invisible to both sweeps and dies with
* the 900 s TTL (PRIV-02), never anything longer;
* - this is not a privilege escalation: minting requires an identity
* that is already valid-by-HMAC (D-13) and the claim stored is that
* same identity's own (D-08) — an extra concurrent token only extends
* validity the holder already has, 15 minutes at most;
* - Phase 4 must NOT build a nonce-store or single-session assumption
* on top of the sequential shape alone: worst case is a handful of
* concurrently live same-identity tokens, every one bounded by 900 s.
*
* Key scheme:
* session:<token> → JSON SessionClaim record
@@ -42,28 +60,46 @@ export interface MintSessionKv extends KvLike {}
/**
* Mint an opaque token for `claim` and rotate (D-07): read the chat
* pointer, delete the previous session record, write the new session
* record, write the new pointer. Both writes carry `expirationTtl`
* (PRIV-02 — see the call lines; anything less is a review-blocking gate).
* pointer, sweep the previous session record, write the new session
* record, write the new pointer — then reconcile (WR-03): re-read the
* pointer once and sweep whatever it names if it is not this mint. Under
* sequential operation the reconciliation is a no-op; if an interleaved
* mint's pointer put landed after ours, its record is swept here (that
* client re-mints on next use). Reconciliation is best-effort — it must
* never fail a mint that already succeeded; any residue TTLs out at
* 900 s regardless. Both writes carry `expirationTtl` (PRIV-02 — see the
* call lines; anything less is a review-blocking gate).
*/
export async function mintSession(kv: MintSessionKv, claim: SessionClaim): Promise<string> {
const token = crypto.randomUUID();
const previousToken = await kv.get(`chat:${claim.chat_id}`);
if (previousToken !== null && previousToken !== token) {
if (previousToken !== null) {
await kv.delete(`session:${previousToken}`); // D-07 rotate-on-new
}
await kv.put(`session:${token}`, JSON.stringify(claim), { expirationTtl: SESSION_TTL_SECONDS });
await kv.put(`chat:${claim.chat_id}`, token, { expirationTtl: SESSION_TTL_SECONDS });
try {
const settled = await kv.get(`chat:${claim.chat_id}`);
if (settled !== null && settled !== token) {
await kv.delete(`session:${settled}`); // WR-03 best-effort reconciliation sweep
}
} catch {
// best-effort: TTL (SESSION_TTL_SECONDS) bounds any unreconciled residue
}
return token;
}
/** Current live token for a chat (the pointer target), or null if none. */
/** Current live token for a chat (the pointer target), or null if none.
* Tests consume it today; the production caller is the Phase 4 Mini App
* flow's claim validation (IN-01 — do not delete as dead code). */
export async function getTokenForChat(kv: MintSessionKv, chatId: number): Promise<string | null> {
return kv.get(`chat:${chatId}`);
}
/** Claim for a token, or null when unknown/expired — callers treat null
* as the "expired" rejection class (D-14 vocabulary). Reads never write. */
* as the "expired" rejection class (D-14 vocabulary). Reads never write.
* Tests consume it today; the production caller is the Phase 4 Mini App
* flow (IN-01 — do not delete as dead code). */
export async function getClaimByToken(kv: MintSessionKv, token: string): Promise<SessionClaim | null> {
const raw = await kv.get(`session:${token}`);
if (raw === null) return null;
+5 -4
View File
@@ -16,10 +16,11 @@
* posts a verbatim Telegram.WebApp.initData string — capped at 4 KB
* (WR-01: over-cap bodies are rejected 413 before any parsing; real
* initData is ≪ 1 KB, so this touches no legitimate client); only an
* HMAC-validated,
* fresh identity obtains a one-time opaque session token (D-06). Rotation
* of any prior token for the chat is mintSession's own invariant (D-07) —
* this endpoint adds no extra branch and no extra write path (T-02-06).
* HMAC-validated, fresh identity obtains a one-time opaque session
* token (D-06). Rotation of any prior token for the chat is mintSession's
* own invariant (D-07 — best-effort under KV eventual consistency, see
* sessions.ts) — this endpoint adds no extra branch and no extra write
* path (T-02-06).
* Anything else → 404.
*
* D-14: the ONLY log statements are category-only — malformed/tampered/stale