k9guard 1.0.1 → 1.0.2

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.
@@ -1,31 +1,19 @@
1
1
  /**
2
- * This module provides Node.js crypto API compatibility for web browsers using the Web Crypto API.
3
- * It implements secure random number generation and hashing functions that work in both browser
4
- * and server environments, eliminating the need for Node.js crypto module in web applications.
2
+ * Cryptographic primitives for k9guard.
5
3
  *
6
- * Key features:
7
- * - Web Crypto API integration with fallback detection
8
- * - Secure random bytes generation
9
- * - SHA-256 hashing with incremental updates
10
- * - Buffer-like interface for compatibility
11
- * - TypeScript support with proper type safety
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.
12
8
  *
13
- * Security Notes:
14
- * - Uses cryptographically secure random number generation
15
- * - Implements deterministic hashing for consistent results
16
- * - No sensitive data is stored or logged
17
- * - All operations are performed in-memory only
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
18
13
  */
19
14
 
20
- declare const window: any;
21
- declare const self: any;
15
+ import { createHash as nodeCreateHash, randomBytes as nodeRandomBytes } from 'node:crypto';
22
16
 
23
- /**
24
- * Interface for crypto buffer operations
25
- *
26
- * Provides Node.js Buffer-like functionality for cryptographic operations.
27
- * Supports array-like access, encoding conversions, and binary data manipulation.
28
- */
29
17
  export interface ICryptoBuffer {
30
18
  readUInt32LE(offset: number): number;
31
19
  toString(encoding?: string): string;
@@ -34,97 +22,13 @@ export interface ICryptoBuffer {
34
22
  [Symbol.iterator](): Iterator<number>;
35
23
  }
36
24
 
37
- /**
38
- * Core cryptographic utilities class
39
- *
40
- * Provides access to Web Crypto API with automatic environment detection.
41
- * Handles browser compatibility and secure random number generation.
42
- */
43
- export class CryptoUtils {
44
- /**
45
- * Detect and return available Web Crypto API instance
46
- *
47
- * Checks multiple global objects to find crypto API in different environments:
48
- * - globalThis (modern browsers/Node.js)
49
- * - window (browser main thread)
50
- * - self (Web Workers/Service Workers)
51
- *
52
- * @throws Error if Web Crypto API is not available
53
- */
54
- private static getWebCrypto(): Crypto {
55
- if (typeof globalThis !== 'undefined' && globalThis.crypto) {
56
- return globalThis.crypto;
57
- }
58
- if (typeof window !== 'undefined' && window.crypto) {
59
- return window.crypto;
60
- }
61
- if (typeof self !== 'undefined' && self.crypto) {
62
- return self.crypto;
63
- }
64
- throw new Error('Web Crypto API not available');
65
- }
66
-
67
- /**
68
- * Generate cryptographically secure random bytes
69
- *
70
- * Uses Web Crypto API's getRandomValues() for secure random number generation.
71
- * This is suitable for cryptographic purposes and provides better entropy than Math.random().
72
- *
73
- * @param size - Number of random bytes to generate (1-65536)
74
- * @returns CryptoBuffer containing random bytes
75
- * @throws RangeError if size is invalid
76
- */
77
- static randomBytes(size: number): ICryptoBuffer {
78
- if (size <= 0 || size > 65536) {
79
- throw new RangeError('Size must be between 1 and 65536');
80
- }
81
-
82
- const crypto = this.getWebCrypto();
83
- const buffer = new Uint8Array(size);
84
- crypto.getRandomValues(buffer);
85
-
86
- return new CryptoBuffer(buffer);
87
- }
88
-
89
- /**
90
- * Create a new hash instance
91
- *
92
- * Currently supports only SHA-256 for security and compatibility reasons.
93
- * SHA-256 provides strong cryptographic hashing suitable for most applications.
94
- *
95
- * @param algorithm - Hash algorithm (currently only 'sha256' supported)
96
- * @returns New CryptoHash instance
97
- * @throws Error if unsupported algorithm is requested
98
- */
99
- static createHash(algorithm: string): CryptoHash {
100
- if (algorithm.toLowerCase() !== 'sha256') {
101
- throw new Error('Only SHA-256 is supported');
102
- }
103
-
104
- return new CryptoHash();
105
- }
106
- }
107
-
108
- /**
109
- * Buffer implementation for cryptographic data
110
- *
111
- * Provides Node.js Buffer-compatible interface using Uint8Array internally.
112
- * Uses Proxy pattern to enable array-like access (buffer[0], buffer[1], etc.)
113
- * while maintaining type safety and compatibility.
114
- */
115
25
  export class CryptoBuffer implements ICryptoBuffer {
116
26
  private buffer: Uint8Array;
117
27
  [index: number]: number | undefined;
118
28
 
119
- /**
120
- * Create a new crypto buffer
121
- *
122
- * @param buffer - Underlying Uint8Array data
123
- */
124
29
  constructor(buffer: Uint8Array) {
125
30
  this.buffer = buffer;
126
- // NOTE: Using Proxy to enable array-like access while maintaining encapsulation
127
- // This allows buffer[0], buffer[1] syntax while keeping internal buffer private
31
+ // Proxy enables numeric index access (buffer[0]) while keeping internal state private
128
32
  const proxy = new Proxy(this, {
129
33
  get(target, prop) {
130
34
  if (typeof prop === 'string' && !isNaN(Number(prop))) {
@@ -136,17 +40,6 @@ export class CryptoBuffer implements ICryptoBuffer {
136
40
  return proxy as any;
137
41
  }
138
42
 
139
- /**
140
- * Read 32-bit unsigned integer in little-endian format
141
- *
142
- * Reads 4 bytes starting at offset and interprets them as a little-endian
143
- * unsigned 32-bit integer. Used for cryptographic operations requiring
144
- * specific byte ordering.
145
- *
146
- * @param offset - Starting position in buffer
147
- * @returns 32-bit unsigned integer value
148
- * @throws RangeError if offset is out of bounds
149
- */
150
43
  readUInt32LE(offset: number): number {
151
44
  if (offset < 0 || offset + 4 > this.buffer.length) {
152
45
  throw new RangeError('Offset out of bounds');
@@ -160,22 +53,10 @@ export class CryptoBuffer implements ICryptoBuffer {
160
53
  throw new RangeError('Buffer read error');
161
54
  }
162
55
 
163
- // NOTE: Little-endian byte order: least significant byte first
164
- // b0 is LSB, b3 is MSB. Using unsigned right shift to ensure positive result
56
+ // Little-endian: b0 is LSB, unsigned right-shift keeps result in [0, 2^32)
165
57
  return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
166
58
  }
167
59
 
168
- /**
169
- * Convert buffer to string with specified encoding
170
- *
171
- * Supports multiple encodings for compatibility with different use cases:
172
- * - 'hex': Hexadecimal representation (2 chars per byte)
173
- * - 'base64': Base64 encoding using browser's btoa() or fallback
174
- * - default: UTF-8 text decoding
175
- *
176
- * @param encoding - Output encoding ('hex', 'base64', or undefined for UTF-8)
177
- * @returns Encoded string representation
178
- */
179
60
  toString(encoding?: string): string {
180
61
  if (encoding === 'hex') {
181
62
  return Array.from(this.buffer)
@@ -184,7 +65,6 @@ export class CryptoBuffer implements ICryptoBuffer {
184
65
  }
185
66
  if (encoding === 'base64') {
186
67
  const binString = Array.from(this.buffer, (byte: number) => String.fromCodePoint(byte)).join('');
187
- // NOTE: btoa() is browser-specific, fallback to raw binary string in Node.js
188
68
  if (typeof btoa !== 'undefined') {
189
69
  return btoa(binString);
190
70
  }
@@ -197,14 +77,6 @@ export class CryptoBuffer implements ICryptoBuffer {
197
77
  return this.buffer.length;
198
78
  }
199
79
 
200
- /**
201
- * Iterator implementation for for...of loops and spread operator
202
- *
203
- * Allows the buffer to be used in modern JavaScript iteration constructs.
204
- * Provides sequential access to individual bytes.
205
- *
206
- * @returns Iterator yielding individual byte values
207
- */
208
80
  [Symbol.iterator](): Iterator<number> {
209
81
  let index = 0;
210
82
  const buffer = this.buffer;
@@ -224,137 +96,98 @@ export class CryptoBuffer implements ICryptoBuffer {
224
96
  }
225
97
 
226
98
  /**
227
- * Incremental hash computation class
99
+ * Thin wrapper around node:crypto Hash so callers keep the same chained API:
100
+ * createHash('sha256').update(data).digest('hex')
228
101
  *
229
- * Provides Node.js crypto.createHash() compatible interface.
230
- * Supports incremental updates for large data processing.
231
- * Uses custom hash algorithm optimized for browser compatibility.
102
+ * Only SHA-256 is accepted; any other algorithm is rejected at construction
103
+ * time to prevent accidental use of weaker primitives.
232
104
  */
233
105
  export class CryptoHash {
234
- private dataChunks: Uint8Array[] = [];
106
+ private hash: ReturnType<typeof nodeCreateHash>;
235
107
 
236
- /**
237
- * Add data to the hash computation
238
- *
239
- * Can be called multiple times to hash large or streaming data.
240
- * Data is accumulated internally until digest() is called.
241
- *
242
- * @param input - Data to hash (string or Uint8Array)
243
- * @returns This instance for method chaining
244
- */
245
- update(input: string | Uint8Array): this {
246
- const inputBytes = typeof input === 'string'
247
- ? new TextEncoder().encode(input)
248
- : input;
108
+ constructor() {
109
+ this.hash = nodeCreateHash('sha256');
110
+ }
249
111
 
250
- this.dataChunks.push(inputBytes);
112
+ update(input: string | Uint8Array): this {
113
+ this.hash.update(typeof input === 'string' ? input : Buffer.from(input));
251
114
  return this;
252
115
  }
253
116
 
254
- /**
255
- * Combine all accumulated data chunks into a single buffer
256
- *
257
- * Internal method used before hash computation.
258
- * Efficiently concatenates all chunks into contiguous memory.
259
- *
260
- * @returns Combined Uint8Array of all input data
261
- */
262
- private getCombinedData(): Uint8Array {
263
- const totalLength = this.dataChunks.reduce((sum, chunk) => sum + chunk.length, 0);
264
- const combined = new Uint8Array(totalLength);
265
- let offset = 0;
117
+ digest(encoding: 'hex' | 'base64' | 'binary' = 'hex'): string {
118
+ return this.hash.digest(encoding);
119
+ }
120
+ }
266
121
 
267
- for (const chunk of this.dataChunks) {
268
- combined.set(chunk, offset);
269
- offset += chunk.length;
122
+ export class CryptoUtils {
123
+ static randomBytes(size: number): ICryptoBuffer {
124
+ if (size <= 0 || size > 65536) {
125
+ throw new RangeError('Size must be between 1 and 65536');
270
126
  }
271
-
272
- return combined;
127
+ const buf = nodeRandomBytes(size);
128
+ return new CryptoBuffer(new Uint8Array(buf));
273
129
  }
274
130
 
275
- /**
276
- * Finalize hash computation and return result
277
- *
278
- * Computes the hash of all accumulated data and returns it in the specified encoding.
279
- * After calling digest(), the hash instance should not be used for further updates.
280
- *
281
- * @param encoding - Output encoding ('hex', 'base64', or undefined for binary)
282
- * @returns Hash digest as encoded string
283
- */
284
- digest(encoding?: string): string {
285
- const combined = this.getCombinedData();
286
- const hashArray = this.computeHashSync(combined);
287
-
288
- if (encoding === 'hex') {
289
- return Array.from(hashArray)
290
- .map(b => b.toString(16).padStart(2, '0'))
291
- .join('');
292
- }
293
- if (encoding === 'base64') {
294
- const binString = Array.from(hashArray, (byte: number) => String.fromCodePoint(byte)).join('');
295
- if (typeof btoa !== 'undefined') {
296
- return btoa(binString);
297
- }
298
- return binString;
131
+ static createHash(algorithm: string): CryptoHash {
132
+ if (algorithm.toLowerCase() !== 'sha256') {
133
+ throw new Error('Only SHA-256 is supported');
299
134
  }
300
-
301
- return new TextDecoder().decode(hashArray);
135
+ return new CryptoHash();
302
136
  }
137
+ }
303
138
 
304
- /**
305
- * Compute hash using custom synchronous algorithm
306
- *
307
- * Implements a deterministic hash function optimized for browser compatibility.
308
- * Uses multiple rounds of mixing operations to ensure good distribution.
309
- *
310
- * Algorithm overview:
311
- * 1. Initial distribution: XOR and multiply operations
312
- * 2. Diffusion rounds: Neighbor-based mixing with round constants
313
- *
314
- * @param data - Input data to hash
315
- * @returns 32-byte hash array
316
- */
317
- private computeHashSync(data: Uint8Array): Uint8Array {
318
- const simpleHash = new Uint8Array(32);
319
-
320
- for (let i = 0; i < data.length; i++) {
321
- const byteVal = data[i];
322
- if (byteVal === undefined) continue;
323
-
324
- const idx1 = i % 32;
325
- const idx2 = (i * 7) % 32; // NOTE: Prime multiplier for better distribution
139
+ export const randomBytes = (size: number): ICryptoBuffer => CryptoUtils.randomBytes(size);
140
+ export const createHash = (algorithm: string): CryptoHash => CryptoUtils.createHash(algorithm);
326
141
 
327
- const val1 = simpleHash[idx1];
328
- const val2 = simpleHash[idx2];
142
+ /**
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.
145
+ *
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.
149
+ */
150
+ export class RandomPool {
151
+ private buffer: Buffer;
152
+ private offset: number;
153
+ private readonly chunkSize: number;
154
+
155
+ constructor(chunkSize = 2048) {
156
+ this.chunkSize = chunkSize;
157
+ this.buffer = nodeRandomBytes(chunkSize);
158
+ this.offset = 0;
159
+ }
329
160
 
330
- if (val1 !== undefined) {
331
- simpleHash[idx1] = val1 ^ byteVal;
332
- }
161
+ private refill(): void {
162
+ this.buffer = nodeRandomBytes(this.chunkSize);
163
+ this.offset = 0;
164
+ }
333
165
 
334
- if (val2 !== undefined) {
335
- simpleHash[idx2] = ((val2 + byteVal) * 31) & 0xFF; // NOTE: 31 is prime for avalanche effect
336
- }
166
+ uint32(): number {
167
+ if (this.offset + 4 > this.buffer.length) {
168
+ this.refill();
337
169
  }
170
+ const val = this.buffer.readUInt32LE(this.offset);
171
+ this.offset += 4;
172
+ return val;
173
+ }
338
174
 
339
- for (let round = 0; round < 16; round++) {
340
- for (let i = 0; i < 32; i++) {
341
- const prevIdx = (i + 31) % 32; // Previous element (wrap around)
342
- const nextIdx = (i + 1) % 32; // Next element (wrap around)
343
-
344
- const prev = simpleHash[prevIdx];
345
- const curr = simpleHash[i];
346
- const next = simpleHash[nextIdx];
347
-
348
- if (prev !== undefined && curr !== undefined && next !== undefined) {
349
- // NOTE: Complex mixing function combining neighbors with round constant
350
- simpleHash[i] = ((prev + curr * 2 + next) * 7 + round) & 0xFF;
351
- }
352
- }
175
+ byte(): number {
176
+ if (this.offset >= this.buffer.length) {
177
+ this.refill();
353
178
  }
179
+ const val = this.buffer[this.offset]!;
180
+ this.offset += 1;
181
+ return val;
182
+ }
354
183
 
355
- return simpleHash;
184
+ // uniform float in [0, 1)
185
+ float(): number {
186
+ return this.uint32() / 0xffffffff;
356
187
  }
357
- }
358
188
 
359
- export const randomBytes = (size: number): ICryptoBuffer => CryptoUtils.randomBytes(size);
360
- export const createHash = (algorithm: string): CryptoHash => CryptoUtils.createHash(algorithm);
189
+ // uniform integer in [min, max)
190
+ int(min: number, max: number): number {
191
+ return Math.floor(this.float() * (max - min)) + min;
192
+ }
193
+ }
@@ -0,0 +1,88 @@
1
+ import { randomBytes } from './crypto';
2
+
3
+ export interface EmojiGeneratorResult {
4
+ emojis: string[];
5
+ category: string;
6
+ answer: string;
7
+ question: string;
8
+ }
9
+
10
+ // each category has >= 20 entries to ensure unique sampling without replacement
11
+ const CATEGORIES: Record<string, string[]> = {
12
+ animals: ['🐶','🐱','🐭','🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐵','🦒','🦓','🐘','🐧','🦅'],
13
+ food: ['🍎','🍊','🍋','🍇','🍓','🍔','🍕','🍜','🍣','🍩','🎂','🥦','🥕','🥑','🧀','🍦','🍫','🥐','🌮','🍱'],
14
+ vehicles: ['🚗','🚕','🚙','🚌','🚑','🚒','🚓','🚜','🏎','🚂','🚁','🛩','🚀','🛸','🚢','⛵','🚲','🛵','🏍','🚛'],
15
+ nature: ['🌸','🌺','🌻','🌹','🌷','🍀','🌿','🌱','🌲','🌳','🍁','🍂','🌊','🌙','⭐','🌈','🌞','🌋','🏔','🌾'],
16
+ sports: ['⚽','🏀','🏈','⚾','🎾','🏐','🏉','🎱','🏓','🏸','🥊','🎿','🛷','⛷','🤸','🚴','🏊','🤺','🥋','🎯'],
17
+ };
18
+
19
+ const CATEGORY_KEYS = Object.keys(CATEGORIES);
20
+
21
+ // crypto-safe index in [0, max)
22
+ function secureIndex(max: number): number {
23
+ const buf = randomBytes(4);
24
+ return buf.readUInt32LE(0) % max;
25
+ }
26
+
27
+ // pick `count` unique items from pool without replacement
28
+ function pickUnique(pool: string[], count: number): string[] {
29
+ const available = pool.slice();
30
+ const result: string[] = [];
31
+ for (let i = 0; i < count && available.length > 0; i++) {
32
+ const idx = secureIndex(available.length);
33
+ result.push(available.splice(idx, 1)[0]!);
34
+ }
35
+ return result;
36
+ }
37
+
38
+ // Fisher-Yates in-place shuffle with crypto random
39
+ function shuffleInPlace(arr: string[]): void {
40
+ const batchBuf = randomBytes(arr.length * 4);
41
+ for (let i = arr.length - 1; i > 0; i--) {
42
+ const j = batchBuf.readUInt32LE((arr.length - 1 - i) * 4) % (i + 1);
43
+ [arr[i]!, arr[j]!] = [arr[j]!, arr[i]!];
44
+ }
45
+ }
46
+
47
+ export class EmojiGenerator {
48
+ static generate(difficulty: 'easy' | 'medium' | 'hard'): EmojiGeneratorResult {
49
+ const targetCount = difficulty === 'easy' ? 2 : difficulty === 'medium' ? 3 : 4;
50
+ const distractorCount = difficulty === 'easy' ? 2 : difficulty === 'medium' ? 3 : 4;
51
+ const totalCount = targetCount + distractorCount;
52
+
53
+ const categoryKey = CATEGORY_KEYS[secureIndex(CATEGORY_KEYS.length)]!;
54
+ const targetPool = CATEGORIES[categoryKey]!;
55
+
56
+ // collect distractor emojis from all categories except the target
57
+ const distractorPool: string[] = [];
58
+ for (const key of CATEGORY_KEYS) {
59
+ if (key !== categoryKey) {
60
+ distractorPool.push(...CATEGORIES[key]!);
61
+ }
62
+ }
63
+
64
+ const targets = pickUnique(targetPool, targetCount);
65
+ const distractors = pickUnique(distractorPool, distractorCount);
66
+
67
+ // combine then shuffle; track indices after shuffle
68
+ const combined = [...targets, ...distractors];
69
+ shuffleInPlace(combined);
70
+
71
+ // mark which positions ended up containing target emojis
72
+ const targetSet = new Set(targets);
73
+ const targetIndices = combined
74
+ .map((e, i) => (targetSet.has(e) ? i : -1))
75
+ .filter(i => i !== -1)
76
+ .sort((a, b) => a - b);
77
+
78
+ // canonical answer: sorted comma-separated zero-based indices e.g. "0,2,4"
79
+ const answer = targetIndices.join(',');
80
+
81
+ return {
82
+ emojis: combined,
83
+ category: categoryKey,
84
+ answer,
85
+ question: `Select all ${categoryKey} from the list (${totalCount} emojis, ${targetCount} correct)`
86
+ };
87
+ }
88
+ }
@@ -0,0 +1,152 @@
1
+ import { RandomPool } from './crypto';
2
+ import { Buffer } from 'node:buffer';
3
+
4
+ interface ImageGeneratorResult {
5
+ image: string;
6
+ answer: string;
7
+ question: string;
8
+ }
9
+
10
+ const CHAR_POOL_EASY = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
11
+ const CHAR_POOL_MEDIUM = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
12
+ const CHAR_POOL_HARD = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
13
+
14
+ const SVG_WIDTH = 200;
15
+ const SVG_HEIGHT = 70;
16
+
17
+ // prevents XML injection via user-controlled strings embedded in SVG
18
+ function escapeXml(str: string): string {
19
+ return str
20
+ .replace(/&/g, '&amp;')
21
+ .replace(/</g, '&lt;')
22
+ .replace(/>/g, '&gt;')
23
+ .replace(/"/g, '&quot;')
24
+ .replace(/'/g, '&#39;');
25
+ }
26
+
27
+ function generateText(pool: RandomPool, charPool: string, length: number): string {
28
+ let result = '';
29
+ for (let i = 0; i < length; i++) {
30
+ result += charPool[pool.byte() % charPool.length]!;
31
+ }
32
+ return result;
33
+ }
34
+
35
+ // each character is rendered individually with rotation and offset to resist OCR
36
+ function renderChar(pool: RandomPool, char: string, x: number, baseY: number, colorHex: string): string {
37
+ const rotate = pool.int(-25, 25);
38
+ const offsetY = pool.int(-6, 6);
39
+ const fontSize = pool.int(22, 30);
40
+ const opacity = (pool.float() * 0.25 + 0.75).toFixed(2);
41
+
42
+ return `<text x="${x}" y="${baseY + offsetY}" transform="rotate(${rotate},${x},${baseY + offsetY})" font-size="${fontSize}" font-family="monospace" font-weight="bold" fill="${colorHex}" opacity="${opacity}" letter-spacing="2">${escapeXml(char)}</text>`;
43
+ }
44
+
45
+ function grayColor(pool: RandomPool): string {
46
+ const v = pool.int(100, 200);
47
+ return `rgb(${v},${v},${v})`;
48
+ }
49
+
50
+ function darkColor(pool: RandomPool): string {
51
+ const r = pool.int(20, 150);
52
+ const g = pool.int(20, 150);
53
+ const b = pool.int(20, 150);
54
+ return `rgb(${r},${g},${b})`;
55
+ }
56
+
57
+ // interference lines make automated segment extraction harder
58
+ function renderNoiseLines(pool: RandomPool, count: number): string {
59
+ let lines = '';
60
+ for (let i = 0; i < count; i++) {
61
+ const x1 = pool.int(0, SVG_WIDTH);
62
+ const y1 = pool.int(0, SVG_HEIGHT);
63
+ const x2 = pool.int(0, SVG_WIDTH);
64
+ const y2 = pool.int(0, SVG_HEIGHT);
65
+ const strokeWidth = (pool.float() * 1.5 + 0.5).toFixed(1);
66
+ const color = grayColor(pool);
67
+ const opacity = (pool.float() * 0.4 + 0.2).toFixed(2);
68
+ lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="${strokeWidth}" opacity="${opacity}"/>`;
69
+ }
70
+ return lines;
71
+ }
72
+
73
+ // noise dots add pixel-level entropy that defeats simple segmentation
74
+ function renderNoiseDots(pool: RandomPool, count: number): string {
75
+ let dots = '';
76
+ for (let i = 0; i < count; i++) {
77
+ const cx = pool.int(0, SVG_WIDTH);
78
+ const cy = pool.int(0, SVG_HEIGHT);
79
+ const r = pool.int(1, 3);
80
+ const color = grayColor(pool);
81
+ const opacity = (pool.float() * 0.5 + 0.1).toFixed(2);
82
+ dots += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}" opacity="${opacity}"/>`;
83
+ }
84
+ return dots;
85
+ }
86
+
87
+ // sinusoidal wave distortion applied as a path overlay
88
+ function renderWavePath(pool: RandomPool): string {
89
+ const amplitude = pool.int(3, 8);
90
+ const frequency = pool.float() * 0.08 + 0.04;
91
+ const phaseShift = pool.float() * Math.PI * 2;
92
+ const strokeColor = grayColor(pool);
93
+
94
+ let d = `M 0 ${SVG_HEIGHT / 2}`;
95
+ for (let x = 1; x <= SVG_WIDTH; x += 2) {
96
+ const y = SVG_HEIGHT / 2 + amplitude * Math.sin(frequency * x + phaseShift);
97
+ d += ` L ${x} ${y.toFixed(2)}`;
98
+ }
99
+
100
+ return `<path d="${d}" stroke="${strokeColor}" stroke-width="1.5" fill="none" opacity="0.35"/>`;
101
+ }
102
+
103
+ function buildSvg(pool: RandomPool, text: string, difficulty: 'easy' | 'medium' | 'hard'): string {
104
+ const charCount = text.length;
105
+ const spacing = SVG_WIDTH / (charCount + 1);
106
+ const baseY = SVG_HEIGHT / 2 + 8;
107
+
108
+ const noiseLineCount = difficulty === 'easy' ? 4 : difficulty === 'medium' ? 7 : 10;
109
+ const noiseDotCount = difficulty === 'easy' ? 20 : difficulty === 'medium' ? 40 : 60;
110
+ const waveCount = difficulty === 'easy' ? 1 : difficulty === 'medium' ? 2 : 3;
111
+
112
+ const bgGray = pool.int(235, 250);
113
+ const background = `<rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" fill="rgb(${bgGray},${bgGray},${bgGray})" rx="6"/>`;
114
+
115
+ let chars = '';
116
+ for (let i = 0; i < charCount; i++) {
117
+ const x = Math.round(spacing * (i + 1));
118
+ const color = darkColor(pool);
119
+ chars += renderChar(pool, text[i]!, x, baseY, color);
120
+ }
121
+
122
+ let waves = '';
123
+ for (let i = 0; i < waveCount; i++) {
124
+ waves += renderWavePath(pool);
125
+ }
126
+
127
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${SVG_WIDTH}" height="${SVG_HEIGHT}" viewBox="0 0 ${SVG_WIDTH} ${SVG_HEIGHT}">${background}${renderNoiseDots(pool, noiseDotCount)}${renderNoiseLines(pool, noiseLineCount)}${waves}${chars}</svg>`;
128
+ }
129
+
130
+ function svgToDataUri(svg: string): string {
131
+ return `data:image/svg+xml;base64,${Buffer.from(svg, 'utf-8').toString('base64')}`;
132
+ }
133
+
134
+ export class ImageGenerator {
135
+ static generate(difficulty: 'easy' | 'medium' | 'hard'): ImageGeneratorResult {
136
+ // single pool allocation covers all random needs for this image — ~1 crypto
137
+ // call instead of the ~500 individual randomBytes(4) calls it replaced
138
+ const pool = new RandomPool(2048);
139
+
140
+ const charPool = difficulty === 'easy' ? CHAR_POOL_EASY : difficulty === 'medium' ? CHAR_POOL_MEDIUM : CHAR_POOL_HARD;
141
+ const length = difficulty === 'easy' ? 4 : difficulty === 'medium' ? 5 : 6;
142
+ const answer = generateText(pool, charPool, length);
143
+ const svg = buildSvg(pool, answer, difficulty);
144
+ const image = svgToDataUri(svg);
145
+
146
+ return {
147
+ image,
148
+ answer: answer.toLowerCase(),
149
+ question: 'Type the characters shown in the image'
150
+ };
151
+ }
152
+ }
@@ -1,3 +1,5 @@
1
+ import { randomBytes } from './crypto';
2
+
1
3
  export class ReverseGenerator {
2
4
  private static readonly easyWords = [
3
5
  'cat', 'dog', 'sun', 'moon', 'star', 'fish', 'bird', 'tree',
@@ -42,7 +44,9 @@ export class ReverseGenerator {
42
44
  wordPool = this.hardWords;
43
45
  }
44
46
 
45
- const text = wordPool![Math.floor(Math.random() * wordPool!.length)]!;
47
+ const buf = randomBytes(4);
48
+ const rand = buf.readUInt32LE(0) / 0xFFFFFFFF;
49
+ const text = wordPool![Math.floor(rand * wordPool!.length)]!;
46
50
  const reversed = text.split('').reverse().join('');
47
51
 
48
52
  return { question: reversed, answer: text };