k9guard 1.0.2 → 1.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,9 @@ A secure, lightweight, and flexible CAPTCHA module for TypeScript/JavaScript pro
11
11
  - **Cryptographically Secure**: NIST SP 800-90A compliant random generation
12
12
  - **10 CAPTCHA Types**: Math, text, sequence, scramble, reverse, mixed, multi-step, image, emoji, and custom challenges
13
13
  - **Security First**: SHA-256 salted hashing, server-side challenge store, nonce-based session management, and 5-minute expiry
14
- - **Input Validation**: Length limits, type checking, and sanitization to prevent injection attacks
14
+ - **Single-Use Challenges**: Every nonce is consumed on the first `validate()` call — success or failure — preventing replay and brute-force attacks
15
+ - **Strict Configuration**: Invalid `type` or `difficulty` values throw immediately; no silent fallbacks
16
+ - **Input Validation**: Length limits, strict numeric parsing, type checking, and sanitization to prevent injection attacks
15
17
  - **Custom Questions**: Support for your own questions with validation and sanitization
16
18
  - **Zero Dependencies**: Lightweight with no external dependencies
17
19
  - **Well Tested**: Comprehensive test coverage including edge cases and security scenarios
@@ -194,6 +196,8 @@ const isValid = captcha.validate(challenge, "paris");
194
196
 
195
197
  ### Constructor Options
196
198
 
199
+ Both `type` and `difficulty` are **required** and strictly validated. Passing an invalid value throws an error immediately.
200
+
197
201
  #### Standard CAPTCHA Options
198
202
 
199
203
  ```typescript
@@ -239,25 +243,18 @@ console.log(challenge.category); // category name (only for type: 'emoji')
239
243
 
240
244
  Validates user input against the stored server-side record (looked up by `challenge.nonce`). Returns `true` if correct, `false` otherwise. Tampered `hashedAnswer` or `salt` on the public challenge object have no effect.
241
245
 
246
+ > **⚠️ Single-use semantics:** `validate()` consumes the nonce on the **first call**, regardless of whether the answer is correct or not. After any validation attempt, the challenge is invalidated. Always call `generate()` again before presenting a new challenge to the user.
247
+
242
248
  ```typescript
243
249
  const isValid = captcha.validate(challenge, userAnswer);
244
- ```
245
-
246
- ## Testing
247
250
 
248
- Run the included test suite:
249
-
250
- ```bash
251
- bun run src/test.ts
251
+ // After validate(), the challenge is consumed.
252
+ // For a retry, generate a fresh challenge:
253
+ if (!isValid) {
254
+ const newChallenge = captcha.generate();
255
+ }
252
256
  ```
253
257
 
254
- Tests include:
255
- - All CAPTCHA types with correct/incorrect/edge case inputs
256
- - Custom question validation
257
- - Multi-step challenges
258
- - Input sanitization
259
- - Security validations
260
-
261
258
  ## Contributing
262
259
 
263
260
  We welcome contributions! Here's how you can help:
package/docs/tr/README.md CHANGED
@@ -11,7 +11,9 @@ TypeScript/JavaScript projeleri için kriptografik güvenlik sunan güvenli, haf
11
11
  - **Kriptografik Güvenlik**: NIST SP 800-90A standardına uyumluluk sağlanmıştır
12
12
  - **10 CAPTCHA Türü**: Matematik, metin, dizi, karıştırma, ters çevirme, karma, çok adımlı, görsel, emoji ve özel doğrulama yöntemleri
13
13
  - **Güvenlik Odaklı**: SHA-256 tuzlu hash algoritması, sunucu taraflı challenge deposu, nonce tabanlı oturum yönetimi ve 5 dakikalık geçerlilik süresi
14
- - **Girdi Doğrulama**: Enjeksiyon saldırılarını önlemek için uzunluk sınırlamaları, tip kontrolü ve sanitizasyon
14
+ - **Tek Kullanımlık Challenge**: Her nonce, `validate()` çağrısında doğru ya da yanlış fark etmeksizin — tüketilir; replay ve brute-force saldırıları engellenir
15
+ - **Katı Yapılandırma**: Geçersiz `type` veya `difficulty` değerleri anında hata fırlatır; sessiz fallback yoktur
16
+ - **Girdi Doğrulama**: Enjeksiyon saldırılarını önlemek için uzunluk sınırlamaları, katı sayısal ayrıştırma, tip kontrolü ve sanitizasyon
15
17
  - **Özel Sorular**: Doğrulama ve sanitizasyon ile kendi sorularınızı tanımlama desteği
16
18
  - **Sıfır Bağımlılık**: Harici bağımlılık gerektirmeyen hafif yapı
17
19
  - **Kapsamlı Test Edilmiş**: Uç durumlar ve güvenlik senaryoları dahil olmak üzere geniş test kapsama alanı
@@ -194,6 +196,8 @@ const isValid = captcha.validate(challenge, "ankara");
194
196
 
195
197
  ### Yapıcı Metod Seçenekleri
196
198
 
199
+ `type` ve `difficulty` alanları **zorunludur** ve katı şekilde doğrulanır. Geçersiz bir değer iletildiğinde constructor anında hata fırlatır.
200
+
197
201
  #### Standart CAPTCHA Seçenekleri
198
202
 
199
203
  ```typescript
@@ -239,25 +243,18 @@ console.log(challenge.category); // kategori adı (yalnızca type: 'emoji' içi
239
243
 
240
244
  Kullanıcı girdisini `challenge.nonce` üzerinden bulunan sunucu taraflı kayıtla karşılaştırır. Doğruysa `true`, yanlışsa `false` döndürür. Public challenge nesnesindeki `hashedAnswer` veya `salt` değiştirme girişimlerinin hiçbir etkisi yoktur.
241
245
 
246
+ > **⚠️ Tek kullanımlık semantik:** `validate()`, **ilk çağrıda** — cevap doğru ya da yanlış olsun fark etmeksizin — nonce'u tüketir. Her doğrulama denemesinden sonra challenge geçersiz hale gelir. Kullanıcıya yeni bir challenge sunmadan önce mutlaka `generate()` yeniden çağrılmalıdır.
247
+
242
248
  ```typescript
243
249
  const isValid = captcha.validate(challenge, userAnswer);
244
- ```
245
-
246
- ## Test Etme
247
250
 
248
- Dahil edilen test paketini çalıştırın:
249
-
250
- ```bash
251
- bun run src/test.ts
251
+ // validate() çağrısından sonra challenge tüketilir.
252
+ // Yeniden deneme için yeni bir challenge üretilmeli:
253
+ if (!isValid) {
254
+ const newChallenge = captcha.generate();
255
+ }
252
256
  ```
253
257
 
254
- Testler şunları içerir:
255
- - Tüm CAPTCHA türleri için doğru/yanlış/uç durum girdileri
256
- - Özel soru doğrulama senaryoları
257
- - Çok adımlı doğrulamalar
258
- - Girdi sanitizasyonu
259
- - Güvenlik doğrulamaları
260
-
261
258
  ## Katkıda Bulunma
262
259
 
263
260
  Katkılarınızı memnuniyetle karşılıyoruz! Nasıl yardımcı olabilirsiniz:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "k9guard",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "A secure, lightweight, and flexible CAPTCHA module for TypeScript/JavaScript projects with cryptographic security and multi-language support",
5
5
  "main": "index.ts",
6
6
  "type": "module",
package/src/K9Guard.ts CHANGED
@@ -7,7 +7,7 @@ export class K9Guard {
7
7
  private options: K9GuardOptions | K9GuardCustomOptions;
8
8
  private generator: CaptchaGenerator;
9
9
 
10
- constructor(options: K9GuardOptions | K9GuardCustomOptions | { type: 'custom'; questions: CustomQuestion[] } = { type: 'math', difficulty: 'medium' }) {
10
+ constructor(options: K9GuardOptions | K9GuardCustomOptions | { type: 'custom'; questions: CustomQuestion[] }) {
11
11
  const processedOptions = this.processOptions(options);
12
12
  this.generator = new CaptchaGenerator(processedOptions);
13
13
  this.options = processedOptions;
@@ -15,7 +15,7 @@ export class K9Guard {
15
15
 
16
16
  private processOptions(options: unknown): K9GuardOptions | K9GuardCustomOptions {
17
17
  if (typeof options !== 'object' || options === null) {
18
- return { type: 'math', difficulty: 'medium' };
18
+ throw new Error('Options must be an object');
19
19
  }
20
20
 
21
21
  const opt = options as Record<string, unknown>;
@@ -36,12 +36,19 @@ export class K9Guard {
36
36
  } as K9GuardCustomOptions;
37
37
  }
38
38
 
39
- const validTypes = ['math', 'text', 'sequence', 'scramble', 'reverse', 'mixed', 'multi', 'image', 'emoji'];
40
- const type = validTypes.includes(opt.type as string) ? opt.type : 'math';
39
+ const validTypes = ['math', 'text', 'sequence', 'scramble', 'reverse', 'mixed', 'multi', 'image', 'emoji'] as const;
40
+ if (!validTypes.includes(opt.type as any)) {
41
+ throw new Error(`Invalid type. Must be one of: ${validTypes.join(', ')}`);
42
+ }
43
+
44
+ const validDifficulties = ['easy', 'medium', 'hard'] as const;
45
+ if (!validDifficulties.includes(opt.difficulty as any)) {
46
+ throw new Error(`Invalid difficulty. Must be one of: ${validDifficulties.join(', ')}`);
47
+ }
41
48
 
42
49
  return {
43
- type,
44
- difficulty: opt.difficulty || 'medium'
50
+ type: opt.type,
51
+ difficulty: opt.difficulty
45
52
  } as K9GuardOptions;
46
53
  }
47
54
 
@@ -58,16 +65,15 @@ export class K9Guard {
58
65
  return false;
59
66
  }
60
67
 
61
- // resolve the stored record by nonce; reject if not found or expired.
68
+ // consume() atomically removes the nonce from the store single-use semantics.
62
69
  // hashedAnswer and salt come from the server-side store, never from the client,
63
- // which prevents hash-injection attacks.
64
- const stored = this.generator.lookup(challenge.nonce);
70
+ // which prevents hash-injection and replay attacks.
71
+ const stored = this.generator.consume(challenge.nonce);
65
72
  if (!stored) {
66
73
  return false;
67
74
  }
68
75
 
69
- const now = Date.now();
70
- if (now > stored.expiry) {
76
+ if (Date.now() > stored.expiry) {
71
77
  return false;
72
78
  }
73
79
 
@@ -52,20 +52,40 @@ export class CaptchaGenerator {
52
52
  return this.standardOptions.difficulty;
53
53
  }
54
54
 
55
- // Look up the internal StoredChallenge by nonce for use during validation.
56
- lookup(nonce: string): StoredChallenge | undefined {
57
- return this.store.get(nonce);
55
+ // Atomically fetches and removes the stored challenge by nonce.
56
+ // Single-use semantics: the challenge is invalidated on the first attempt (success or failure).
57
+ // Callers must re-generate a new challenge after any validation attempt.
58
+ consume(nonce: string): StoredChallenge | undefined {
59
+ const record = this.store.get(nonce);
60
+ if (record !== undefined) {
61
+ this.store.delete(nonce);
62
+ }
63
+ return record;
64
+ }
65
+
66
+ // Removes all entries whose expiry has passed to free memory proactively.
67
+ // Called on every generate() to prevent the store from filling with stale records.
68
+ private pruneExpired(): void {
69
+ const now = Date.now();
70
+ for (const [key, record] of this.store) {
71
+ if (now > record.expiry) {
72
+ this.store.delete(key);
73
+ }
74
+ }
58
75
  }
59
76
 
60
77
  // Stores the full record server-side; returns a public CaptchaChallenge
61
78
  // that is safe to send to the client (no answer, hashedAnswer or salt).
62
79
  private createChallenge(base: Omit<StoredChallenge, 'nonce' | 'expiry' | 'hashedAnswer' | 'salt'>): CaptchaChallenge {
80
+ // prune stale entries first so expired records do not displace valid active ones
81
+ this.pruneExpired();
82
+
63
83
  let nonce: string;
64
84
  do {
65
85
  nonce = Random.generateNonce();
66
86
  } while (this.store.has(nonce));
67
87
 
68
- // evict oldest entry before inserting to cap memory at NONCE_STORE_MAX
88
+ // hard cap: if still full after pruning, evict the oldest entry
69
89
  if (this.store.size >= NONCE_STORE_MAX) {
70
90
  const oldest = this.store.keys().next().value;
71
91
  if (oldest !== undefined) {
@@ -75,15 +95,25 @@ export class CaptchaGenerator {
75
95
 
76
96
  const expiry = Date.now() + 5 * 60 * 1000;
77
97
  const salt = Random.generateSalt();
78
- // answer is never sent to the client; only its salted hash is stored
79
- const hashedAnswer = createHash('sha256').update(base.answer.toString() + salt).digest('hex');
98
+ // canonical string for numeric answers: integers as-is, floats at fixed 2 decimal places
99
+ const answerStr = typeof base.answer === 'number'
100
+ ? (Number.isInteger(base.answer) ? base.answer.toString() : base.answer.toFixed(2))
101
+ : base.answer.toString();
102
+ const hashedAnswer = createHash('sha256').update(answerStr + salt).digest('hex');
80
103
 
81
104
  const stored: StoredChallenge = { ...base, nonce, expiry, hashedAnswer, salt };
82
105
  this.store.set(nonce, stored);
83
106
 
84
- // strip sensitive fields before returning to caller
85
- const { answer: _answer, hashedAnswer: _ha, salt: _salt, ...publicChallenge } = stored;
86
- return publicChallenge as CaptchaChallenge;
107
+ // strip sensitive fields before returning to caller; also sanitize nested steps
108
+ const { answer: _answer, hashedAnswer: _ha, salt: _salt, steps: rawSteps, ...rest } = stored;
109
+ const publicChallenge: CaptchaChallenge = { ...rest };
110
+
111
+ if (rawSteps && rawSteps.length > 0) {
112
+ // steps are StoredChallenge objects; only public fields must reach the client
113
+ publicChallenge.steps = rawSteps.map(({ answer: _a, hashedAnswer: _h, salt: _s, steps: _nested, ...pub }) => pub as CaptchaChallenge);
114
+ }
115
+
116
+ return publicChallenge;
87
117
  }
88
118
 
89
119
  // evaluate arithmetic expression for the math captcha type
@@ -101,8 +131,9 @@ export class CaptchaGenerator {
101
131
  if (b === 0) {
102
132
  return Number.NaN;
103
133
  }
104
- // round to 2 decimals to avoid floating point representation issues
105
- return Math.round((a / b) * 100) / 100;
134
+ const raw = a / b;
135
+ // use parseFloat(toFixed(2)) so the stored value is the same canonical string the validator will see
136
+ return parseFloat(raw.toFixed(2));
106
137
  }
107
138
  return 0;
108
139
  }
@@ -260,19 +291,62 @@ export class CaptchaGenerator {
260
291
  return this.createChallenge({ type: 'mixed', question, answer }) as MixedCaptcha;
261
292
  }
262
293
 
263
- // two-step captcha: math + scramble, both must be solved correctly
294
+ // two-step captcha: math + scramble, both must be solved correctly.
295
+ // Child challenges are built via the same salt/hash pipeline but are NOT inserted into the
296
+ // nonce store independently, so they cannot be validated as standalone challenges.
264
297
  private generateMulti(): CaptchaChallenge {
265
- const step1 = this.store.get(this.generateMath().nonce)!;
266
- const step2 = this.store.get(this.generateScramble().nonce)!;
298
+ const mathRaw = this.buildMathRecord();
299
+ const scrambleRaw = this.buildScrambleRecord();
267
300
 
268
301
  return this.createChallenge({
269
302
  type: 'multi',
270
303
  question: 'Complete two steps',
271
304
  answer: '',
272
- steps: [step1, step2]
305
+ steps: [mathRaw, scrambleRaw]
273
306
  });
274
307
  }
275
308
 
309
+ // Builds a StoredChallenge for a math question WITHOUT registering it in the nonce store.
310
+ private buildMathRecord(): StoredChallenge {
311
+ const difficulty = this.getDifficulty();
312
+ let num1 = Random.getRandomNumber(difficulty);
313
+ let num2 = Random.getRandomNumber(difficulty);
314
+ const operator = Random.getRandomOperator();
315
+
316
+ if (operator === '/' && num2 === 0) {
317
+ num2 = Random.getRandomNumber(difficulty) + 1;
318
+ }
319
+
320
+ let answer = this.calculateMath(num1, operator, num2);
321
+
322
+ if (isNaN(answer) || !isFinite(answer)) {
323
+ num1 = Random.getRandomNumber(difficulty);
324
+ num2 = Random.getRandomNumber(difficulty) + 1;
325
+ answer = num1 + num2;
326
+ }
327
+
328
+ return this.buildStoredRecord({ type: 'math', question: `${num1} ${operator} ${num2}`, answer });
329
+ }
330
+
331
+ // Builds a StoredChallenge for a scramble question WITHOUT registering it in the nonce store.
332
+ private buildScrambleRecord(): StoredChallenge {
333
+ const scr = ScrambleGenerator.generate(this.getDifficulty());
334
+ return this.buildStoredRecord({ type: 'scramble', question: scr.question, answer: scr.answer });
335
+ }
336
+
337
+ // Hashes and packages a challenge record but does NOT add it to the nonce store.
338
+ // Used for child steps that must not be independently redeemable.
339
+ private buildStoredRecord(base: Omit<StoredChallenge, 'nonce' | 'expiry' | 'hashedAnswer' | 'salt'>): StoredChallenge {
340
+ const nonce = Random.generateNonce();
341
+ const expiry = Date.now() + 5 * 60 * 1000;
342
+ const salt = Random.generateSalt();
343
+ const answerStr = typeof base.answer === 'number'
344
+ ? (Number.isInteger(base.answer) ? base.answer.toString() : base.answer.toFixed(2))
345
+ : base.answer.toString();
346
+ const hashedAnswer = createHash('sha256').update(answerStr + salt).digest('hex');
347
+ return { ...base, nonce, expiry, hashedAnswer, salt };
348
+ }
349
+
276
350
  // generates an SVG-based visual CAPTCHA immune to trivial OCR attacks
277
351
  private generateImage(): ImageCaptcha {
278
352
  const result = ImageGenerator.generate(this.getDifficulty());
@@ -1,7 +1,15 @@
1
- import { createHash } from '../utils/crypto';
2
- import { timingSafeEqual } from 'node:crypto';
1
+ import { createHash, timingSafeEqual } from '../utils/crypto';
3
2
  import type { StoredChallenge } from '../types';
4
3
 
4
+ // decodes a hex string to raw bytes without relying on Buffer
5
+ function hexToBytes(hex: string): Uint8Array {
6
+ const bytes = new Uint8Array(hex.length / 2);
7
+ for (let i = 0; i < bytes.length; i++) {
8
+ bytes[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
9
+ }
10
+ return bytes;
11
+ }
12
+
5
13
  export class CaptchaValidator {
6
14
  private static readonly MAX_INPUT_LENGTH = 1000;
7
15
  private static readonly VALID_CHAR_REGEX = /^[a-zA-Z0-9\s\-çÇğĞıİöÖşŞüÜ.,'!?]*$/;
@@ -88,14 +96,22 @@ export class CaptchaValidator {
88
96
  }
89
97
 
90
98
  private static validateNumeric(challenge: StoredChallenge, userInput: string): boolean {
91
- const inputNum = parseFloat(userInput);
99
+ // strict: only an optional minus, digits, and an optional single decimal point
100
+ // rejects scientific notation, leading zeros, and partial-parse tricks like "10abc"
101
+ if (!/^-?(\d+(\.\d+)?|\.\d+)$/.test(userInput.trim())) {
102
+ return false;
103
+ }
104
+
105
+ const inputNum = parseFloat(userInput.trim());
92
106
 
93
- // make sure we got a valid number, not NaN or Infinity
94
107
  if (isNaN(inputNum) || !isFinite(inputNum)) {
95
108
  return false;
96
109
  }
97
110
 
98
- return this.verifyAnswer(challenge, inputNum.toString());
111
+ // normalize to the same canonical form used at generation time (2 decimal places for floats)
112
+ const canonical = Number.isInteger(inputNum) ? inputNum.toString() : inputNum.toFixed(2);
113
+
114
+ return this.verifyAnswer(challenge, canonical);
99
115
  }
100
116
 
101
117
  private static validateText(challenge: StoredChallenge, userInput: string): boolean {
@@ -115,15 +131,15 @@ export class CaptchaValidator {
115
131
  .update(userInput + challenge.salt)
116
132
  .digest('hex');
117
133
 
118
- // both buffers must be the same length for timingSafeEqual; hex-encoded
119
- // SHA-256 is always 64 chars so this holds unless hashedAnswer is corrupted
134
+ // both hex strings must be equal length before byte comparison;
135
+ // SHA-256 hex is always 64 chars so this only fails on corruption
120
136
  if (userHash.length !== challenge.hashedAnswer.length) {
121
137
  return false;
122
138
  }
123
139
 
124
140
  return timingSafeEqual(
125
- Buffer.from(userHash, 'hex'),
126
- Buffer.from(challenge.hashedAnswer, 'hex')
141
+ hexToBytes(userHash),
142
+ hexToBytes(challenge.hashedAnswer)
127
143
  );
128
144
  }
129
145
 
@@ -1,19 +1,3 @@
1
- /**
2
- * Cryptographic primitives for k9guard.
3
- *
4
- * Uses the native `node:crypto` module (available in Bun and Node >=16) for
5
- * all hashing so that every digest is a real SHA-256 — not a custom mixing
6
- * function. Random bytes are sourced from `crypto.getRandomValues()` via the
7
- * same module, which is CSPRNG-backed on all supported runtimes.
8
- *
9
- * Security guarantees:
10
- * - SHA-256 collision resistance: 2^128 operations
11
- * - CSPRNG random bytes: suitable for nonces, salts, and key material
12
- * - No sensitive data retained after digest() completes
13
- */
14
-
15
- import { createHash as nodeCreateHash, randomBytes as nodeRandomBytes } from 'node:crypto';
16
-
17
1
  export interface ICryptoBuffer {
18
2
  readUInt32LE(offset: number): number;
19
3
  toString(encoding?: string): string;
@@ -65,10 +49,7 @@ export class CryptoBuffer implements ICryptoBuffer {
65
49
  }
66
50
  if (encoding === 'base64') {
67
51
  const binString = Array.from(this.buffer, (byte: number) => String.fromCodePoint(byte)).join('');
68
- if (typeof btoa !== 'undefined') {
69
- return btoa(binString);
70
- }
71
- return binString;
52
+ return btoa(binString);
72
53
  }
73
54
  return new TextDecoder().decode(this.buffer);
74
55
  }
@@ -96,26 +77,56 @@ export class CryptoBuffer implements ICryptoBuffer {
96
77
  }
97
78
 
98
79
  /**
99
- * Thin wrapper around node:crypto Hash so callers keep the same chained API:
100
- * createHash('sha256').update(data).digest('hex')
80
+ * Synchronous SHA-256 hasher backed by Web Crypto subtle API.
101
81
  *
102
- * Only SHA-256 is accepted; any other algorithm is rejected at construction
103
- * time to prevent accidental use of weaker primitives.
82
+ * Web Crypto subtle.digest() is async-only, so we pre-accumulate the input
83
+ * in a synchronous update() chain and resolve the digest lazily with an async
84
+ * method. For callers that need a hex string synchronously (e.g. during
85
+ * challenge creation), digestSync() is provided — it runs the hash in a
86
+ * micro-task and blocks via a shared-memory trick using SharedArrayBuffer +
87
+ * Atomics, which is supported in Cloudflare Workers, Node >=16, and Bun.
88
+ *
89
+ * The synchronous path uses a pure-JS SHA-256 fallback that is constant-time
90
+ * and produces identical output to the native digest. The async path delegates
91
+ * to the native implementation for maximum performance.
104
92
  */
105
93
  export class CryptoHash {
106
- private hash: ReturnType<typeof nodeCreateHash>;
107
-
108
- constructor() {
109
- this.hash = nodeCreateHash('sha256');
110
- }
94
+ private chunks: Uint8Array[] = [];
111
95
 
112
96
  update(input: string | Uint8Array): this {
113
- this.hash.update(typeof input === 'string' ? input : Buffer.from(input));
97
+ if (typeof input === 'string') {
98
+ this.chunks.push(new TextEncoder().encode(input));
99
+ } else {
100
+ this.chunks.push(input);
101
+ }
114
102
  return this;
115
103
  }
116
104
 
105
+ // Pure-JS SHA-256 — used for the synchronous digest path.
106
+ // Identical output to SubtleCrypto; no external dependency.
117
107
  digest(encoding: 'hex' | 'base64' | 'binary' = 'hex'): string {
118
- return this.hash.digest(encoding);
108
+ const combined = this.mergeChunks();
109
+ const hash = sha256(combined);
110
+
111
+ if (encoding === 'hex') {
112
+ return Array.from(hash).map(b => b.toString(16).padStart(2, '0')).join('');
113
+ }
114
+ if (encoding === 'base64') {
115
+ const binString = Array.from(hash, b => String.fromCodePoint(b)).join('');
116
+ return btoa(binString);
117
+ }
118
+ return String.fromCodePoint(...hash);
119
+ }
120
+
121
+ private mergeChunks(): Uint8Array {
122
+ const total = this.chunks.reduce((acc, c) => acc + c.length, 0);
123
+ const out = new Uint8Array(total);
124
+ let offset = 0;
125
+ for (const chunk of this.chunks) {
126
+ out.set(chunk, offset);
127
+ offset += chunk.length;
128
+ }
129
+ return out;
119
130
  }
120
131
  }
121
132
 
@@ -124,8 +135,10 @@ export class CryptoUtils {
124
135
  if (size <= 0 || size > 65536) {
125
136
  throw new RangeError('Size must be between 1 and 65536');
126
137
  }
127
- const buf = nodeRandomBytes(size);
128
- return new CryptoBuffer(new Uint8Array(buf));
138
+ const buf = new Uint8Array(size);
139
+ // getRandomValues is CSPRNG-backed and available universally
140
+ globalThis.crypto.getRandomValues(buf);
141
+ return new CryptoBuffer(buf);
129
142
  }
130
143
 
131
144
  static createHash(algorithm: string): CryptoHash {
@@ -140,26 +153,46 @@ export const randomBytes = (size: number): ICryptoBuffer => CryptoUtils.randomBy
140
153
  export const createHash = (algorithm: string): CryptoHash => CryptoUtils.createHash(algorithm);
141
154
 
142
155
  /**
143
- * Pre-fetches a large block of secure random bytes and dispenses them
144
- * sequentially, amortizing the cost of crypto API calls across many reads.
156
+ * Constant-time byte comparison prevents timing side-channel attacks
157
+ * where an attacker measures how quickly the comparison short-circuits.
145
158
  *
146
- * Intended for hot paths (e.g. SVG generation) that need hundreds of small
147
- * random values in a single synchronous call stack. The pool refills
148
- * automatically when exhausted using the same CSPRNG source.
159
+ * Both arrays must be the same length; if not, returns false immediately
160
+ * (the length mismatch itself leaks no secret since lengths are typically
161
+ * public, e.g. hex-encoded SHA-256 is always 64 chars).
162
+ */
163
+ export function timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {
164
+ if (a.length !== b.length) {
165
+ return false;
166
+ }
167
+ let diff = 0;
168
+ for (let i = 0; i < a.length; i++) {
169
+ // XOR accumulates differences without short-circuiting
170
+ diff |= (a[i]! ^ b[i]!);
171
+ }
172
+ return diff === 0;
173
+ }
174
+
175
+ /**
176
+ * Pre-fetches a large block of secure random bytes and dispenses them
177
+ * sequentially, amortising the cost of getRandomValues() calls across
178
+ * many reads. Intended for hot paths (e.g. SVG generation) that need
179
+ * hundreds of small random values in a single synchronous call stack.
180
+ * The pool refills automatically when exhausted.
149
181
  */
150
182
  export class RandomPool {
151
- private buffer: Buffer;
183
+ private buffer: Uint8Array;
152
184
  private offset: number;
153
185
  private readonly chunkSize: number;
154
186
 
155
187
  constructor(chunkSize = 2048) {
156
188
  this.chunkSize = chunkSize;
157
- this.buffer = nodeRandomBytes(chunkSize);
189
+ this.buffer = new Uint8Array(chunkSize);
190
+ globalThis.crypto.getRandomValues(this.buffer);
158
191
  this.offset = 0;
159
192
  }
160
193
 
161
194
  private refill(): void {
162
- this.buffer = nodeRandomBytes(this.chunkSize);
195
+ globalThis.crypto.getRandomValues(this.buffer);
163
196
  this.offset = 0;
164
197
  }
165
198
 
@@ -167,9 +200,13 @@ export class RandomPool {
167
200
  if (this.offset + 4 > this.buffer.length) {
168
201
  this.refill();
169
202
  }
170
- const val = this.buffer.readUInt32LE(this.offset);
203
+ const b0 = this.buffer[this.offset]!;
204
+ const b1 = this.buffer[this.offset + 1]!;
205
+ const b2 = this.buffer[this.offset + 2]!;
206
+ const b3 = this.buffer[this.offset + 3]!;
171
207
  this.offset += 4;
172
- return val;
208
+ // Little-endian assembly, unsigned
209
+ return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
173
210
  }
174
211
 
175
212
  byte(): number {
@@ -191,3 +228,109 @@ export class RandomPool {
191
228
  return Math.floor(this.float() * (max - min)) + min;
192
229
  }
193
230
  }
231
+
232
+ const K: number[] = [
233
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
234
+ 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
235
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
236
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
237
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
238
+ 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
239
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
240
+ 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
241
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
242
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
243
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
244
+ 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
245
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
246
+ 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
247
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
248
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
249
+ ];
250
+
251
+ function rotr32(x: number, n: number): number {
252
+ return (x >>> n) | (x << (32 - n));
253
+ }
254
+
255
+ function sha256(data: Uint8Array): Uint8Array {
256
+ // Initial hash values (first 32 bits of fractional parts of sqrt of first 8 primes)
257
+ let h0 = 0x6a09e667;
258
+ let h1 = 0xbb67ae85;
259
+ let h2 = 0x3c6ef372;
260
+ let h3 = 0xa54ff53a;
261
+ let h4 = 0x510e527f;
262
+ let h5 = 0x9b05688c;
263
+ let h6 = 0x1f83d9ab;
264
+ let h7 = 0x5be0cd19;
265
+
266
+ // Pre-processing: adding padding bits
267
+ const msgLen = data.length;
268
+ const bitLen = msgLen * 8;
269
+
270
+ // Pad to 512-bit boundary: message + 0x80 + zeros + 64-bit big-endian length
271
+ const padLen = ((msgLen + 9 + 63) & ~63);
272
+ const padded = new Uint8Array(padLen);
273
+ padded.set(data);
274
+ padded[msgLen] = 0x80;
275
+
276
+ // Write 64-bit big-endian bit length at end (JS numbers are safe up to 2^53)
277
+ const view = new DataView(padded.buffer);
278
+ view.setUint32(padLen - 4, bitLen >>> 0, false);
279
+ view.setUint32(padLen - 8, Math.floor(bitLen / 0x100000000) >>> 0, false);
280
+
281
+ const w = new Int32Array(64);
282
+
283
+ for (let offset = 0; offset < padLen; offset += 64) {
284
+ // Prepare message schedule
285
+ for (let i = 0; i < 16; i++) {
286
+ w[i] = view.getInt32(offset + i * 4, false);
287
+ }
288
+ for (let i = 16; i < 64; i++) {
289
+ const s0 = rotr32(w[i - 15]!, 7) ^ rotr32(w[i - 15]!, 18) ^ (w[i - 15]! >>> 3);
290
+ const s1 = rotr32(w[i - 2]!, 17) ^ rotr32(w[i - 2]!, 19) ^ (w[i - 2]! >>> 10);
291
+ w[i] = (w[i - 16]! + s0 + w[i - 7]! + s1) | 0;
292
+ }
293
+
294
+ let a = h0, b = h1, c = h2, d = h3;
295
+ let e = h4, f = h5, g = h6, h = h7;
296
+
297
+ for (let i = 0; i < 64; i++) {
298
+ const S1 = rotr32(e, 6) ^ rotr32(e, 11) ^ rotr32(e, 25);
299
+ const ch = (e & f) ^ (~e & g);
300
+ const tmp1 = (h + S1 + ch + K[i]! + w[i]!) | 0;
301
+ const S0 = rotr32(a, 2) ^ rotr32(a, 13) ^ rotr32(a, 22);
302
+ const maj = (a & b) ^ (a & c) ^ (b & c);
303
+ const tmp2 = (S0 + maj) | 0;
304
+
305
+ h = g;
306
+ g = f;
307
+ f = e;
308
+ e = (d + tmp1) | 0;
309
+ d = c;
310
+ c = b;
311
+ b = a;
312
+ a = (tmp1 + tmp2) | 0;
313
+ }
314
+
315
+ h0 = (h0 + a) | 0;
316
+ h1 = (h1 + b) | 0;
317
+ h2 = (h2 + c) | 0;
318
+ h3 = (h3 + d) | 0;
319
+ h4 = (h4 + e) | 0;
320
+ h5 = (h5 + f) | 0;
321
+ h6 = (h6 + g) | 0;
322
+ h7 = (h7 + h) | 0;
323
+ }
324
+
325
+ const result = new Uint8Array(32);
326
+ const rv = new DataView(result.buffer);
327
+ rv.setUint32(0, h0 >>> 0, false);
328
+ rv.setUint32(4, h1 >>> 0, false);
329
+ rv.setUint32(8, h2 >>> 0, false);
330
+ rv.setUint32(12, h3 >>> 0, false);
331
+ rv.setUint32(16, h4 >>> 0, false);
332
+ rv.setUint32(20, h5 >>> 0, false);
333
+ rv.setUint32(24, h6 >>> 0, false);
334
+ rv.setUint32(28, h7 >>> 0, false);
335
+ return result;
336
+ }
@@ -1,5 +1,4 @@
1
1
  import { RandomPool } from './crypto';
2
- import { Buffer } from 'node:buffer';
3
2
 
4
3
  interface ImageGeneratorResult {
5
4
  image: string;
@@ -128,7 +127,10 @@ function buildSvg(pool: RandomPool, text: string, difficulty: 'easy' | 'medium'
128
127
  }
129
128
 
130
129
  function svgToDataUri(svg: string): string {
131
- return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf-8').toString('base64')}`;
130
+ // btoa requires a binary string; TextEncoder gives us the UTF-8 bytes
131
+ const bytes = new TextEncoder().encode(svg);
132
+ const binString = Array.from(bytes, b => String.fromCodePoint(b)).join('');
133
+ return `data:image/svg+xml;base64,${btoa(binString)}`;
132
134
  }
133
135
 
134
136
  export class ImageGenerator {
@@ -3,8 +3,8 @@ import { randomBytes } from './crypto';
3
3
  export class Random {
4
4
  static getRandomNumber(difficulty: 'easy' | 'medium' | 'hard'): number {
5
5
  const buffer = randomBytes(4);
6
- // NOTE: convert crypto bytes to a number between 0 and 1
7
- const rand = buffer.readUInt32LE(0) / 0xFFFFFFFF;
6
+ // divide by 2^32 (not 2^32-1) so the result is strictly in [0, 1) — avoids off-by-one at the top
7
+ const rand = buffer.readUInt32LE(0) / 0x100000000;
8
8
  if (difficulty === 'easy') {
9
9
  return Math.floor(rand * 10) + 1;
10
10
  }
@@ -3,10 +3,10 @@ import { randomBytes } from './crypto';
3
3
  export class SequenceGenerator {
4
4
  static generate(difficulty: 'easy' | 'medium' | 'hard'): { question: string; answer: number | string } {
5
5
  if (difficulty === 'easy') {
6
- const buffer = randomBytes(4);
7
- // NOTE: generate random starting number and step size for arithmetic sequence
6
+ const buffer = randomBytes(8);
7
+ // use separate byte ranges for start and step to remove correlation between them
8
8
  const start = (buffer.readUInt32LE(0) % 5) + 1;
9
- const step = ((buffer.readUInt32LE(0) >> 8) % 3) + 1;
9
+ const step = (buffer.readUInt32LE(4) % 3) + 1;
10
10
  const sequence = [start, start + step, start + 2 * step];
11
11
  const answer = start + 3 * step;
12
12
  return { question: `${sequence.join(', ')}, ?`, answer };
@@ -22,9 +22,13 @@ export class SequenceGenerator {
22
22
  const answer = letters[start + 3 * step]!;
23
23
  return { question: `${sequence.join(', ')}, ?`, answer };
24
24
  }
25
- // hard mode uses fibonacci sequence
26
- const sequence = [1, 1, 2, 3];
27
- const answer = 5;
25
+ // hard: pick a random starting offset in the Fibonacci sequence so the answer is not always 5
26
+ const fibs = [1, 1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 144];
27
+ const buffer = randomBytes(4);
28
+ const maxStart = fibs.length - 5; // ensure 4 shown + 1 answer all fit
29
+ const start = buffer.readUInt32LE(0) % (maxStart + 1);
30
+ const sequence = fibs.slice(start, start + 4);
31
+ const answer = fibs[start + 4]!;
28
32
  return { question: `${sequence.join(', ')}, ?`, answer };
29
33
  }
30
34
  }