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.
- package/LICENSE +21 -0
- package/README.md +74 -34
- package/docs/tr/README.md +76 -36
- package/package.json +7 -7
- package/src/K9Guard.ts +18 -10
- package/src/core/captchaGenerator.ts +131 -66
- package/src/core/captchaValidator.ts +76 -11
- package/src/types/index.ts +34 -22
- package/src/utils/crypto.ts +83 -250
- package/src/utils/emojiGenerator.ts +88 -0
- package/src/utils/imageGenerator.ts +152 -0
- package/src/utils/reverseGenerator.ts +5 -1
- package/src/utils/scrambleGenerator.ts +25 -13
- package/src/utils/sequenceGenerator.ts +5 -8
- package/src/locale/en.json +0 -46
- package/src/locale/tr.json +0 -46
- package/src/utils/logicGenerator.ts +0 -18
- package/src/utils/riddleBank.ts +0 -18
package/src/utils/crypto.ts
CHANGED
|
@@ -1,31 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
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
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
14
|
-
* -
|
|
15
|
-
* -
|
|
16
|
-
* - No sensitive data
|
|
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
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
*
|
|
99
|
+
* Thin wrapper around node:crypto Hash so callers keep the same chained API:
|
|
100
|
+
* createHash('sha256').update(data).digest('hex')
|
|
228
101
|
*
|
|
229
|
-
*
|
|
230
|
-
*
|
|
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
|
|
106
|
+
private hash: ReturnType<typeof nodeCreateHash>;
|
|
235
107
|
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
|
127
|
+
const buf = nodeRandomBytes(size);
|
|
128
|
+
return new CryptoBuffer(new Uint8Array(buf));
|
|
273
129
|
}
|
|
274
130
|
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
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
|
-
|
|
331
|
-
|
|
332
|
-
|
|
161
|
+
private refill(): void {
|
|
162
|
+
this.buffer = nodeRandomBytes(this.chunkSize);
|
|
163
|
+
this.offset = 0;
|
|
164
|
+
}
|
|
333
165
|
|
|
334
|
-
|
|
335
|
-
|
|
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
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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
|
-
|
|
184
|
+
// uniform float in [0, 1)
|
|
185
|
+
float(): number {
|
|
186
|
+
return this.uint32() / 0xffffffff;
|
|
356
187
|
}
|
|
357
|
-
}
|
|
358
188
|
|
|
359
|
-
|
|
360
|
-
|
|
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, '&')
|
|
21
|
+
.replace(/</g, '<')
|
|
22
|
+
.replace(/>/g, '>')
|
|
23
|
+
.replace(/"/g, '"')
|
|
24
|
+
.replace(/'/g, ''');
|
|
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
|
|
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 };
|