chore: moved to session based score security b716d431
Steve · 2026-03-27 18:59 8 file(s) · +185 −169
.gitignore +1 −0
21 21
.env.test.local
22 22
.env.production.local
23 23
.env.local
24 +
.dev.vars
24 25
25 26
# caches
26 27
.eslintcache
functions/api/_hmac.js (added) +65 −0
1 +
function base64UrlEncode(buffer) {
2 +
  const bytes = new Uint8Array(buffer)
3 +
  let binary = ''
4 +
  for (const byte of bytes) {
5 +
    binary += String.fromCharCode(byte)
6 +
  }
7 +
  return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
8 +
}
9 +
10 +
function base64UrlDecode(str) {
11 +
  const padded = str.replace(/-/g, '+').replace(/_/g, '/') + '=='.slice(0, (4 - (str.length % 4)) % 4)
12 +
  const binary = atob(padded)
13 +
  const bytes = new Uint8Array(binary.length)
14 +
  for (let i = 0; i < binary.length; i++) {
15 +
    bytes[i] = binary.charCodeAt(i)
16 +
  }
17 +
  return bytes.buffer
18 +
}
19 +
20 +
async function getHmacKey(secret) {
21 +
  return crypto.subtle.importKey(
22 +
    'raw',
23 +
    new TextEncoder().encode(secret),
24 +
    { name: 'HMAC', hash: 'SHA-256' },
25 +
    false,
26 +
    ['sign', 'verify']
27 +
  )
28 +
}
29 +
30 +
export async function createToken(secret) {
31 +
  const nonce = base64UrlEncode(crypto.getRandomValues(new Uint8Array(16)))
32 +
  const issuedAt = Date.now()
33 +
  const payload = base64UrlEncode(new TextEncoder().encode(JSON.stringify({ nonce, issuedAt })))
34 +
  const key = await getHmacKey(secret)
35 +
  const signature = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(payload))
36 +
  return { token: payload + '.' + base64UrlEncode(signature), nonce, issuedAt }
37 +
}
38 +
39 +
export async function verifyToken(tokenString, secret) {
40 +
  if (typeof tokenString !== 'string') return null
41 +
  const parts = tokenString.split('.')
42 +
  if (parts.length !== 2) return null
43 +
44 +
  const [payloadB64, signatureB64] = parts
45 +
46 +
  try {
47 +
    const key = await getHmacKey(secret)
48 +
    const signatureBuffer = base64UrlDecode(signatureB64)
49 +
    const valid = await crypto.subtle.verify(
50 +
      'HMAC',
51 +
      key,
52 +
      signatureBuffer,
53 +
      new TextEncoder().encode(payloadB64)
54 +
    )
55 +
    if (!valid) return null
56 +
57 +
    const payloadJson = new TextDecoder().decode(base64UrlDecode(payloadB64))
58 +
    const payload = JSON.parse(payloadJson)
59 +
    if (typeof payload.nonce !== 'string' || typeof payload.issuedAt !== 'number') return null
60 +
61 +
    return payload
62 +
  } catch {
63 +
    return null
64 +
  }
65 +
}
functions/api/challenge.js (added) +22 −0
1 +
import { createToken } from './_hmac.js'
2 +
3 +
function jsonResponse(data, status = 200) {
4 +
  return new Response(JSON.stringify(data), {
5 +
    status,
6 +
    headers: { 'Content-Type': 'application/json' },
7 +
  })
8 +
}
9 +
10 +
export const onRequestGet = async ({ env }) => {
11 +
  if (!env.HMAC_SECRET) {
12 +
    return jsonResponse({ error: 'Server misconfigured' }, 500)
13 +
  }
14 +
15 +
  try {
16 +
    const { token } = await createToken(env.HMAC_SECRET)
17 +
    return jsonResponse({ token })
18 +
  } catch (err) {
19 +
    console.error('Failed to create challenge token:', err)
20 +
    return jsonResponse({ error: 'Failed to create challenge' }, 500)
21 +
  }
22 +
}
functions/api/scores.js +68 −145
1 -
import { GAME_CONFIG } from '../../src/config.js'
1 +
import { GAME_CONFIG, FRAME_RATE } from '../../src/config.js'
2 +
import { verifyToken } from './_hmac.js'
2 3
3 -
// Helper functions for consistent JSON responses
4 4
function jsonResponse(data, status = 200) {
5 5
  return new Response(JSON.stringify(data), {
6 6
    status,
25 25
  }
26 26
}
27 27
28 -
export const onRequestPost = async ({ request, env }) => {
28 +
export const onRequestPost = async ({ request, env, waitUntil }) => {
29 29
  let body
30 30
  try {
31 31
    body = await request.json()
33 33
    return errorResponse('Invalid JSON')
34 34
  }
35 35
36 -
  const { playerName, score, eventLog } = body
36 +
  const { playerName, score, token } = body
37 37
38 -
  // Basic type validation
39 38
  if (
40 39
    typeof playerName !== 'string' ||
41 40
    typeof score !== 'number' ||
42 -
    !Array.isArray(eventLog)
41 +
    typeof token !== 'string'
43 42
  ) {
44 43
    return errorResponse('Invalid request body')
45 44
  }
46 45
47 -
  // Validate and get server-computed score
48 -
  const validation = validateEventLog(eventLog, score, playerName)
49 -
  if (!validation.ok) {
50 -
    // Log validation failure for debugging (visible in Cloudflare logs)
51 -
    console.warn('Score validation failed:', {
52 -
      reason: validation.reason,
53 -
      playerName,
54 -
      claimedScore: score,
55 -
      eventLogLength: eventLog.length,
56 -
      details: validation.details || {},
57 -
    })
58 -
    return errorResponse('Score validation failed')
46 +
  // Name validation
47 +
  if (playerName.length > GAME_CONFIG.MAX_PLAYER_NAME_LENGTH || playerName.length === 0) {
48 +
    return errorResponse('Invalid player name')
59 49
  }
60 50
61 -
  try {
62 -
    const endEvent = eventLog[eventLog.length - 1]
63 -
    const startEvent = eventLog[0]
64 -
65 -
    await env.DB
66 -
      .prepare(
67 -
        `INSERT INTO scores (player_name, score, end_frame, duration_ms) VALUES (?, ?, ?, ?)`
68 -
      )
69 -
      .bind(
70 -
        playerName.slice(0, GAME_CONFIG.MAX_PLAYER_NAME_LENGTH),
71 -
        score,
72 -
        endEvent.frame,
73 -
        endEvent.ts - startEvent.ts
74 -
      )
75 -
      .run()
76 -
77 -
    return jsonResponse({ ok: true, score: score })
78 -
  } catch (err) {
79 -
    console.error('Database error:', err)
80 -
    return errorResponse('Database error', 500)
51 +
  // Score bounds
52 +
  if (score < 0 || score > GAME_CONFIG.MAX_SCORE || !Number.isInteger(score)) {
53 +
    return errorResponse('Invalid score')
81 54
  }
82 -
}
83 55
84 -
function validateEventLog(eventLog, claimedScore, playerName) {
85 -
  // Name validation
86 -
  if (playerName.length > GAME_CONFIG.MAX_PLAYER_NAME_LENGTH) {
87 -
    return { ok: false, reason: 'INVALID_NAME_LENGTH' }
56 +
  // Verify token signature
57 +
  const payload = await verifyToken(token, env.HMAC_SECRET)
58 +
  if (!payload) {
59 +
    return errorResponse('Invalid session token')
88 60
  }
89 61
90 -
  // Score bounds validation
91 -
  if (claimedScore > GAME_CONFIG.MAX_SCORE || claimedScore < 0) {
92 -
    return { ok: false, reason: 'INVALID_SCORE_RANGE' }
93 -
  }
62 +
  const now = Date.now()
63 +
  const elapsed = now - payload.issuedAt
94 64
95 -
  // Event log structure validation
96 -
  if (!eventLog || eventLog.length < 2) {
97 -
    return { ok: false, reason: 'INSUFFICIENT_EVENTS' }
65 +
  // Session age check
66 +
  if (elapsed > GAME_CONFIG.MAX_SESSION_AGE_MS || elapsed < 0) {
67 +
    return errorResponse('Session expired')
98 68
  }
99 69
100 -
  // Validate event structure before processing
101 -
  for (const event of eventLog) {
102 -
    if (
103 -
      !event ||
104 -
      typeof event.frame !== 'number' ||
105 -
      typeof event.ts !== 'number' ||
106 -
      typeof event.type !== 'string'
107 -
    ) {
108 -
      return { ok: false, reason: 'MALFORMED_EVENT' }
109 -
    }
70 +
  // Minimum game duration: score requires at least this many ms
71 +
  const minDuration = Math.max(
72 +
    GAME_CONFIG.MIN_GAME_DURATION_MS,
73 +
    score * GAME_CONFIG.SCORE_INCREASE_RATE * (1000 / FRAME_RATE) * GAME_CONFIG.WALL_CLOCK_TOLERANCE
74 +
  )
75 +
  if (elapsed < minDuration) {
76 +
    return errorResponse('Score validation failed')
110 77
  }
111 78
112 -
  // Validate bookends
113 -
  if (eventLog[0].type !== 'START' || eventLog[eventLog.length - 1].type !== 'END') {
114 -
    return { ok: false, reason: 'INVALID_BOOKENDS' }
115 -
  }
116 -
117 -
  let lastFrame = -1
118 -
  let lastTs = -1
119 -
  let lastJumpFrame = -Infinity
120 -
  let prevLevel = 0
79 +
  // Replay prevention
80 +
  try {
81 +
    const existing = await env.DB
82 +
      .prepare('SELECT nonce FROM used_tokens WHERE nonce = ?')
83 +
      .bind(payload.nonce)
84 +
      .first()
121 85
122 -
  for (let i = 0; i < eventLog.length; i++) {
123 -
    const event = eventLog[i]
124 -
125 -
    // Monotonic frame validation
126 -
    if (event.frame < lastFrame) {
127 -
      return { ok: false, reason: 'FRAME_NOT_MONOTONIC' }
86 +
    if (existing) {
87 +
      return errorResponse('Session already used')
128 88
    }
129 89
130 -
    // Monotonic timestamp validation
131 -
    if (event.ts < lastTs) {
132 -
      return { ok: false, reason: 'TIMESTAMP_NOT_MONOTONIC' }
133 -
    }
134 -
135 -
    lastFrame = event.frame
136 -
    lastTs = event.ts
137 -
138 -
    // Jump physics validation
139 -
    if (event.type === 'JUMP') {
140 -
      if (lastFrame - lastJumpFrame < GAME_CONFIG.JUMP_AIRBORNE_FRAMES) {
141 -
        return { ok: false, reason: 'INVALID_JUMP_TIMING' }
142 -
      }
143 -
      lastJumpFrame = lastFrame
144 -
    }
145 -
146 -
    // Level timing validation
147 -
    if (event.type === 'LEVEL') {
148 -
      const expectedFrame = event.value * GAME_CONFIG.LEVEL_SCORE_THRESHOLD * GAME_CONFIG.SCORE_INCREASE_RATE
149 -
      const tolerance = expectedFrame * GAME_CONFIG.LEVEL_FRAME_TOLERANCE
150 -
      if (Math.abs(event.frame - expectedFrame) > tolerance) {
151 -
        return { ok: false, reason: 'INVALID_LEVEL_TIMING' }
152 -
      }
153 -
154 -
      // Level progression validation (no skipping levels)
155 -
      if (event.value > prevLevel + 1) {
156 -
        return { ok: false, reason: 'LEVEL_SKIP_DETECTED' }
157 -
      }
158 -
      prevLevel = event.value
159 -
    }
90 +
    await env.DB
91 +
      .prepare('INSERT INTO used_tokens (nonce) VALUES (?)')
92 +
      .bind(payload.nonce)
93 +
      .run()
94 +
  } catch (err) {
95 +
    console.error('Token check error:', err)
96 +
    return errorResponse('Database error', 500)
160 97
  }
161 98
162 -
  const endEvent = eventLog[eventLog.length - 1]
163 -
  const endFrame = endEvent.frame
164 -
  const endTs = endEvent.ts
165 -
  const startEvent = eventLog[0]
166 -
  const startTs = startEvent.ts
167 -
168 -
  // Score calculation validation
169 -
  const expectedScore = Math.floor(endFrame / GAME_CONFIG.SCORE_INCREASE_RATE)
170 -
  if (Math.abs(expectedScore - claimedScore) > 1) {
171 -
    return { ok: false, reason: 'SCORE_MISMATCH' }
172 -
  }
99 +
  // Insert score
100 +
  try {
101 +
    const durationMs = elapsed
102 +
    const endFrame = score * GAME_CONFIG.SCORE_INCREASE_RATE
173 103
174 -
  // Wall clock timing validation
175 -
  const minDuration = endFrame * (1000 / 60) * GAME_CONFIG.WALL_CLOCK_TOLERANCE
176 -
  if (endTs - startTs < minDuration) {
177 -
    return { ok: false, reason: 'WALL_CLOCK_TOO_FAST' }
178 -
  }
104 +
    await env.DB
105 +
      .prepare(
106 +
        'INSERT INTO scores (player_name, score, end_frame, duration_ms) VALUES (?, ?, ?, ?)'
107 +
      )
108 +
      .bind(
109 +
        playerName.slice(0, GAME_CONFIG.MAX_PLAYER_NAME_LENGTH),
110 +
        score,
111 +
        endFrame,
112 +
        durationMs
113 +
      )
114 +
      .run()
179 115
180 -
  // Timestamp sanity checks
181 -
  const now = Date.now()
182 -
  const oldestAllowed = now - GAME_CONFIG.MAX_SESSION_AGE_MS
183 -
  if (startTs < oldestAllowed) {
184 -
    return { ok: false, reason: 'TIMESTAMP_TOO_OLD', details: { age: now - startTs } }
185 -
  }
186 -
  if (startTs > now + GAME_CONFIG.CLOCK_DRIFT_ALLOWANCE_MS) {
187 -
    return { 
188 -
      ok: false, 
189 -
      reason: 'TIMESTAMP_IN_FUTURE', 
190 -
      details: { 
191 -
        clientStartTs: startTs, 
192 -
        serverNow: now, 
193 -
        driftMs: startTs - now 
194 -
      } 
195 -
    }
196 -
  }
116 +
    // Clean up old used tokens in the background
117 +
    waitUntil(
118 +
      env.DB
119 +
        .prepare("DELETE FROM used_tokens WHERE used_at < datetime('now', '-1 day')")
120 +
        .run()
121 +
        .catch(() => {})
122 +
    )
197 123
198 -
  // Level cap validation (use claimedScore — it's validated within ±1 of expectedScore
199 -
  // and avoids off-by-one at level boundaries from frame-counting timing)
200 -
  const maxLevel = Math.floor(claimedScore / GAME_CONFIG.LEVEL_SCORE_THRESHOLD)
201 -
  if (prevLevel > maxLevel) {
202 -
    return { ok: false, reason: 'LEVEL_TOO_HIGH' }
124 +
    return jsonResponse({ ok: true, score })
125 +
  } catch (err) {
126 +
    console.error('Database error:', err)
127 +
    return errorResponse('Database error', 500)
203 128
  }
204 -
205 -
  return { ok: true, score: expectedScore }
206 129
}
migrations/0003_create_used_tokens.sql (added) +6 −0
1 +
CREATE TABLE IF NOT EXISTS used_tokens (
2 +
  nonce   TEXT PRIMARY KEY,
3 +
  used_at DATETIME DEFAULT CURRENT_TIMESTAMP
4 +
);
5 +
6 +
CREATE INDEX IF NOT EXISTS idx_used_tokens_used_at ON used_tokens (used_at);
src/config.js +1 −4
2 2
// Used by both client (DinoGame) and server (validation)
3 3
export const GAME_CONFIG = {
4 4
  SCORE_INCREASE_RATE: 6,        // frames per score point
5 -
  JUMP_AIRBORNE_FRAMES: 40,      // minimum frames between jumps
6 5
  WALL_CLOCK_TOLERANCE: 0.75,    // 75% of expected real-time duration
7 -
  LEVEL_SCORE_THRESHOLD: 100,    // score points per level
8 -
  LEVEL_FRAME_TOLERANCE: 0.05,   // 5% tolerance for level-up timing
9 6
  MAX_PLAYER_NAME_LENGTH: 3,     // enforced on both client and server
10 7
  MAX_SCORE: 99999,              // reasonable upper bound
11 8
  MAX_SESSION_AGE_MS: 24 * 60 * 60 * 1000, // 24 hours
12 -
  CLOCK_DRIFT_ALLOWANCE_MS: 60000, // 60 seconds future tolerance (handles clock drift)
9 +
  MIN_GAME_DURATION_MS: 1000,    // absolute minimum game duration (1 second)
13 10
}
14 11
15 12
export const FRAME_RATE = 60
src/game/DinoGame.js +6 −13
68 68
      },
69 69
    }
70 70
71 -
    this.eventLog = []
71 +
    this.gameToken = null
72 72
    this.onGameOver = null
73 -
  }
74 -
75 -
  _recordEvent(data) {
76 -
    this.eventLog.push({ ...data, frame: this.frameCount, ts: Date.now() })
77 73
  }
78 74
79 75
  createCanvas(width, height) {
146 142
      case 'jump': {
147 143
        if (state.isRunning) {
148 144
          if (state.dino.jump()) {
149 -
            this._recordEvent({ type: 'JUMP' })
150 145
            playSound('jump')
151 146
          }
152 147
        } else {
160 155
      case 'duck': {
161 156
        if (state.isRunning) {
162 157
          state.dino.duck(true)
163 -
          this._recordEvent({ type: 'DUCK' })
164 158
        }
165 159
        break
166 160
      }
168 162
      case 'stop-duck': {
169 163
        if (state.isRunning) {
170 164
          state.dino.duck(false)
171 -
          this._recordEvent({ type: 'STAND' })
172 165
        }
173 166
        break
174 167
      }
177 170
178 171
  resetGame() {
179 172
    this.frameCount = 0
180 -
    this.eventLog = []
181 -
    this._recordEvent({ type: 'START' })
173 +
    this.gameToken = fetch('/api/challenge')
174 +
      .then(r => r.json())
175 +
      .then(d => d.token)
176 +
      .catch(() => null)
182 177
183 178
    this.state.dino.reset()
184 179
    Object.assign(this.state, {
200 195
  }
201 196
202 197
  endGame() {
203 -
    this._recordEvent({ type: 'END', score: this.state.score.value })
204 198
    if (typeof this.onGameOver === 'function') {
205 -
      this.onGameOver(this.state.score.value, this.eventLog)
199 +
      this.onGameOver(this.state.score.value, this.gameToken)
206 200
    }
207 201
208 202
    const iconSprite = sprites.replayIcon
275 269
      state.level = Math.floor(state.score.value / 100)
276 270
277 271
      if (state.level !== oldLevel) {
278 -
        this._recordEvent({ type: 'LEVEL', value: state.level })
279 272
        playSound('level-up')
280 273
        this.increaseDifficulty()
281 274
        state.score.isBlinking = true
src/index.js +16 −7
90 90
  submitting: false,
91 91
  submittedInitials: '',
92 92
  currentScore: 0,
93 -
  currentEventLog: [],
93 +
  currentToken: null,
94 94
}
95 95
96 96
function escapeHtml(text) {
107 107
  return score > lowestTopScore
108 108
}
109 109
110 -
function showOverlay(score, eventLog) {
110 +
function showOverlay(score, tokenPromise) {
111 111
  overlayState.visible = true
112 112
  overlayState.submitted = false
113 113
  overlayState.submitting = false
114 114
  overlayState.submittedInitials = ''
115 115
  overlayState.currentScore = score
116 -
  overlayState.currentEventLog = eventLog
116 +
  overlayState.currentToken = tokenPromise
117 117
118 118
  modalTitle.textContent = 'GAME OVER'
119 119
  finalScoreDisplay.textContent = `SCORE: ${score}`
138 138
function hideOverlay() {
139 139
  overlayState.visible = false
140 140
  overlayState.currentScore = 0
141 -
  overlayState.currentEventLog = []
141 +
  overlayState.currentToken = null
142 142
  overlay.style.display = 'none'
143 143
}
144 144
158 158
  submitBtn.disabled = true
159 159
160 160
  try {
161 +
    const token = await overlayState.currentToken
162 +
    if (!token) {
163 +
      modalTitle.textContent = 'SESSION ERROR'
164 +
      overlayState.submitting = false
165 +
      submitBtn.disabled = false
166 +
      submitBtn.textContent = 'SUBMIT'
167 +
      return
168 +
    }
169 +
161 170
    const res = await fetch('/api/scores', {
162 171
      method: 'POST',
163 172
      headers: { 'Content-Type': 'application/json' },
164 173
      body: JSON.stringify({
165 174
        playerName: initials,
166 175
        score: overlayState.currentScore,
167 -
        eventLog: overlayState.currentEventLog,
176 +
        token,
168 177
      }),
169 178
    })
170 179
272 281
  }
273 282
}
274 283
275 -
game.onGameOver = (score, eventLog) => {
276 -
  setTimeout(() => showOverlay(score, eventLog), 300)
284 +
game.onGameOver = (score, tokenPromise) => {
285 +
  setTimeout(() => showOverlay(score, tokenPromise), 300)
277 286
}
278 287
279 288
document.addEventListener('keydown', (e) => {