k9guard 1.0.1 → 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.
@@ -1,31 +1,3 @@
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.
5
- *
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
12
- *
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
18
- */
19
-
20
- declare const window: any;
21
- declare const self: any;
22
-
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
1
  export interface ICryptoBuffer {
30
2
  readUInt32LE(offset: number): number;
31
3
  toString(encoding?: string): string;
@@ -34,97 +6,13 @@ export interface ICryptoBuffer {
34
6
  [Symbol.iterator](): Iterator<number>;
35
7
  }
36
8
 
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
9
  export class CryptoBuffer implements ICryptoBuffer {
116
10
  private buffer: Uint8Array;
117
11
  [index: number]: number | undefined;
118
12
 
119
- /**
120
- * Create a new crypto buffer
121
- *
122
- * @param buffer - Underlying Uint8Array data
123
- */
124
13
  constructor(buffer: Uint8Array) {
125
14
  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
15
+ // Proxy enables numeric index access (buffer[0]) while keeping internal state private
128
16
  const proxy = new Proxy(this, {
129
17
  get(target, prop) {
130
18
  if (typeof prop === 'string' && !isNaN(Number(prop))) {
@@ -136,17 +24,6 @@ export class CryptoBuffer implements ICryptoBuffer {
136
24
  return proxy as any;
137
25
  }
138
26
 
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
27
  readUInt32LE(offset: number): number {
151
28
  if (offset < 0 || offset + 4 > this.buffer.length) {
152
29
  throw new RangeError('Offset out of bounds');
@@ -160,22 +37,10 @@ export class CryptoBuffer implements ICryptoBuffer {
160
37
  throw new RangeError('Buffer read error');
161
38
  }
162
39
 
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
40
+ // Little-endian: b0 is LSB, unsigned right-shift keeps result in [0, 2^32)
165
41
  return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
166
42
  }
167
43
 
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
44
  toString(encoding?: string): string {
180
45
  if (encoding === 'hex') {
181
46
  return Array.from(this.buffer)
@@ -184,11 +49,7 @@ export class CryptoBuffer implements ICryptoBuffer {
184
49
  }
185
50
  if (encoding === 'base64') {
186
51
  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
- if (typeof btoa !== 'undefined') {
189
- return btoa(binString);
190
- }
191
- return binString;
52
+ return btoa(binString);
192
53
  }
193
54
  return new TextDecoder().decode(this.buffer);
194
55
  }
@@ -197,14 +58,6 @@ export class CryptoBuffer implements ICryptoBuffer {
197
58
  return this.buffer.length;
198
59
  }
199
60
 
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
61
  [Symbol.iterator](): Iterator<number> {
209
62
  let index = 0;
210
63
  const buffer = this.buffer;
@@ -224,137 +77,260 @@ export class CryptoBuffer implements ICryptoBuffer {
224
77
  }
225
78
 
226
79
  /**
227
- * Incremental hash computation class
80
+ * Synchronous SHA-256 hasher backed by Web Crypto subtle API.
81
+ *
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.
228
88
  *
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.
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.
232
92
  */
233
93
  export class CryptoHash {
234
- private dataChunks: Uint8Array[] = [];
235
-
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;
94
+ private chunks: Uint8Array[] = [];
249
95
 
250
- this.dataChunks.push(inputBytes);
96
+ update(input: string | Uint8Array): this {
97
+ if (typeof input === 'string') {
98
+ this.chunks.push(new TextEncoder().encode(input));
99
+ } else {
100
+ this.chunks.push(input);
101
+ }
251
102
  return this;
252
103
  }
253
104
 
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;
105
+ // Pure-JS SHA-256 — used for the synchronous digest path.
106
+ // Identical output to SubtleCrypto; no external dependency.
107
+ digest(encoding: 'hex' | 'base64' | 'binary' = 'hex'): string {
108
+ const combined = this.mergeChunks();
109
+ const hash = sha256(combined);
266
110
 
267
- for (const chunk of this.dataChunks) {
268
- combined.set(chunk, offset);
269
- offset += chunk.length;
111
+ if (encoding === 'hex') {
112
+ return Array.from(hash).map(b => b.toString(16).padStart(2, '0')).join('');
270
113
  }
271
-
272
- return combined;
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);
273
119
  }
274
120
 
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);
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;
130
+ }
131
+ }
287
132
 
288
- if (encoding === 'hex') {
289
- return Array.from(hashArray)
290
- .map(b => b.toString(16).padStart(2, '0'))
291
- .join('');
133
+ export class CryptoUtils {
134
+ static randomBytes(size: number): ICryptoBuffer {
135
+ if (size <= 0 || size > 65536) {
136
+ throw new RangeError('Size must be between 1 and 65536');
292
137
  }
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;
138
+ const buf = new Uint8Array(size);
139
+ // getRandomValues is CSPRNG-backed and available universally
140
+ globalThis.crypto.getRandomValues(buf);
141
+ return new CryptoBuffer(buf);
142
+ }
143
+
144
+ static createHash(algorithm: string): CryptoHash {
145
+ if (algorithm.toLowerCase() !== 'sha256') {
146
+ throw new Error('Only SHA-256 is supported');
299
147
  }
148
+ return new CryptoHash();
149
+ }
150
+ }
151
+
152
+ export const randomBytes = (size: number): ICryptoBuffer => CryptoUtils.randomBytes(size);
153
+ export const createHash = (algorithm: string): CryptoHash => CryptoUtils.createHash(algorithm);
300
154
 
301
- return new TextDecoder().decode(hashArray);
155
+ /**
156
+ * Constant-time byte comparison — prevents timing side-channel attacks
157
+ * where an attacker measures how quickly the comparison short-circuits.
158
+ *
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;
302
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
+ }
303
174
 
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
326
-
327
- const val1 = simpleHash[idx1];
328
- const val2 = simpleHash[idx2];
329
-
330
- if (val1 !== undefined) {
331
- simpleHash[idx1] = val1 ^ byteVal;
332
- }
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.
181
+ */
182
+ export class RandomPool {
183
+ private buffer: Uint8Array;
184
+ private offset: number;
185
+ private readonly chunkSize: number;
186
+
187
+ constructor(chunkSize = 2048) {
188
+ this.chunkSize = chunkSize;
189
+ this.buffer = new Uint8Array(chunkSize);
190
+ globalThis.crypto.getRandomValues(this.buffer);
191
+ this.offset = 0;
192
+ }
333
193
 
334
- if (val2 !== undefined) {
335
- simpleHash[idx2] = ((val2 + byteVal) * 31) & 0xFF; // NOTE: 31 is prime for avalanche effect
336
- }
194
+ private refill(): void {
195
+ globalThis.crypto.getRandomValues(this.buffer);
196
+ this.offset = 0;
197
+ }
198
+
199
+ uint32(): number {
200
+ if (this.offset + 4 > this.buffer.length) {
201
+ this.refill();
337
202
  }
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]!;
207
+ this.offset += 4;
208
+ // Little-endian assembly, unsigned
209
+ return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
210
+ }
338
211
 
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)
212
+ byte(): number {
213
+ if (this.offset >= this.buffer.length) {
214
+ this.refill();
215
+ }
216
+ const val = this.buffer[this.offset]!;
217
+ this.offset += 1;
218
+ return val;
219
+ }
343
220
 
344
- const prev = simpleHash[prevIdx];
345
- const curr = simpleHash[i];
346
- const next = simpleHash[nextIdx];
221
+ // uniform float in [0, 1)
222
+ float(): number {
223
+ return this.uint32() / 0xffffffff;
224
+ }
347
225
 
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
- }
226
+ // uniform integer in [min, max)
227
+ int(min: number, max: number): number {
228
+ return Math.floor(this.float() * (max - min)) + min;
229
+ }
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;
353
292
  }
354
293
 
355
- return simpleHash;
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;
356
323
  }
357
- }
358
324
 
359
- export const randomBytes = (size: number): ICryptoBuffer => CryptoUtils.randomBytes(size);
360
- export const createHash = (algorithm: string): CryptoHash => CryptoUtils.createHash(algorithm);
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
+ }
@@ -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
+ }