k9guard 1.0.3 → 1.0.4

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,336 +0,0 @@
1
- export interface ICryptoBuffer {
2
- readUInt32LE(offset: number): number;
3
- toString(encoding?: string): string;
4
- length: number;
5
- [index: number]: number | undefined;
6
- [Symbol.iterator](): Iterator<number>;
7
- }
8
-
9
- export class CryptoBuffer implements ICryptoBuffer {
10
- private buffer: Uint8Array;
11
- [index: number]: number | undefined;
12
-
13
- constructor(buffer: Uint8Array) {
14
- this.buffer = buffer;
15
- // Proxy enables numeric index access (buffer[0]) while keeping internal state private
16
- const proxy = new Proxy(this, {
17
- get(target, prop) {
18
- if (typeof prop === 'string' && !isNaN(Number(prop))) {
19
- return target.buffer[Number(prop)];
20
- }
21
- return (target as any)[prop];
22
- }
23
- });
24
- return proxy as any;
25
- }
26
-
27
- readUInt32LE(offset: number): number {
28
- if (offset < 0 || offset + 4 > this.buffer.length) {
29
- throw new RangeError('Offset out of bounds');
30
- }
31
- const b0 = this.buffer[offset];
32
- const b1 = this.buffer[offset + 1];
33
- const b2 = this.buffer[offset + 2];
34
- const b3 = this.buffer[offset + 3];
35
-
36
- if (b0 === undefined || b1 === undefined || b2 === undefined || b3 === undefined) {
37
- throw new RangeError('Buffer read error');
38
- }
39
-
40
- // Little-endian: b0 is LSB, unsigned right-shift keeps result in [0, 2^32)
41
- return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
42
- }
43
-
44
- toString(encoding?: string): string {
45
- if (encoding === 'hex') {
46
- return Array.from(this.buffer)
47
- .map(b => b.toString(16).padStart(2, '0'))
48
- .join('');
49
- }
50
- if (encoding === 'base64') {
51
- const binString = Array.from(this.buffer, (byte: number) => String.fromCodePoint(byte)).join('');
52
- return btoa(binString);
53
- }
54
- return new TextDecoder().decode(this.buffer);
55
- }
56
-
57
- get length(): number {
58
- return this.buffer.length;
59
- }
60
-
61
- [Symbol.iterator](): Iterator<number> {
62
- let index = 0;
63
- const buffer = this.buffer;
64
- return {
65
- next(): IteratorResult<number> {
66
- if (index < buffer.length) {
67
- const val = buffer[index++];
68
- if (val === undefined) {
69
- return { value: 0, done: true };
70
- }
71
- return { value: val, done: false };
72
- }
73
- return { value: 0, done: true };
74
- }
75
- };
76
- }
77
- }
78
-
79
- /**
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.
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.
92
- */
93
- export class CryptoHash {
94
- private chunks: Uint8Array[] = [];
95
-
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
- }
102
- return this;
103
- }
104
-
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);
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;
130
- }
131
- }
132
-
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');
137
- }
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');
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);
154
-
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;
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.
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
- }
193
-
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();
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
- }
211
-
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
- }
220
-
221
- // uniform float in [0, 1)
222
- float(): number {
223
- return this.uint32() / 0xffffffff;
224
- }
225
-
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;
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,40 +0,0 @@
1
- import { randomBytes } from './crypto';
2
- import type { CustomQuestion } from '../types';
3
-
4
- export class CustomQuestionGenerator {
5
- private questions: CustomQuestion[];
6
-
7
- constructor(questions: CustomQuestion[]) {
8
- this.questions = questions;
9
- }
10
-
11
- generate(difficulty?: 'easy' | 'medium' | 'hard'): { question: string; answer: string } {
12
- // filter questions by difficulty if specified
13
- const candidates = difficulty
14
- ? this.questions.filter(q => q.difficulty === difficulty)
15
- : this.questions;
16
-
17
- // NOTE: fallback to all questions if no match found for difficulty
18
- if (candidates.length === 0) {
19
- return this.selectRandom(this.questions);
20
- }
21
-
22
- return this.selectRandom(candidates);
23
- }
24
-
25
- private selectRandom(questions: CustomQuestion[]): { question: string; answer: string } {
26
- if (questions.length === 0) {
27
- return { question: '', answer: '' };
28
- }
29
-
30
- // use crypto random to pick a question securely
31
- const buffer = randomBytes(4);
32
- const index = buffer.readUInt32LE(0) % questions.length;
33
- const selected = questions[index]!;
34
-
35
- return {
36
- question: selected.question,
37
- answer: selected.answer
38
- };
39
- }
40
- }
@@ -1,88 +0,0 @@
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
- }
@@ -1,154 +0,0 @@
1
- import { RandomPool } from './crypto';
2
-
3
- interface ImageGeneratorResult {
4
- image: string;
5
- answer: string;
6
- question: string;
7
- }
8
-
9
- const CHAR_POOL_EASY = 'ABCDEFGHJKLMNPQRSTUVWXYZ';
10
- const CHAR_POOL_MEDIUM = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
11
- const CHAR_POOL_HARD = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
12
-
13
- const SVG_WIDTH = 200;
14
- const SVG_HEIGHT = 70;
15
-
16
- // prevents XML injection via user-controlled strings embedded in SVG
17
- function escapeXml(str: string): string {
18
- return str
19
- .replace(/&/g, '&amp;')
20
- .replace(/</g, '&lt;')
21
- .replace(/>/g, '&gt;')
22
- .replace(/"/g, '&quot;')
23
- .replace(/'/g, '&#39;');
24
- }
25
-
26
- function generateText(pool: RandomPool, charPool: string, length: number): string {
27
- let result = '';
28
- for (let i = 0; i < length; i++) {
29
- result += charPool[pool.byte() % charPool.length]!;
30
- }
31
- return result;
32
- }
33
-
34
- // each character is rendered individually with rotation and offset to resist OCR
35
- function renderChar(pool: RandomPool, char: string, x: number, baseY: number, colorHex: string): string {
36
- const rotate = pool.int(-25, 25);
37
- const offsetY = pool.int(-6, 6);
38
- const fontSize = pool.int(22, 30);
39
- const opacity = (pool.float() * 0.25 + 0.75).toFixed(2);
40
-
41
- 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>`;
42
- }
43
-
44
- function grayColor(pool: RandomPool): string {
45
- const v = pool.int(100, 200);
46
- return `rgb(${v},${v},${v})`;
47
- }
48
-
49
- function darkColor(pool: RandomPool): string {
50
- const r = pool.int(20, 150);
51
- const g = pool.int(20, 150);
52
- const b = pool.int(20, 150);
53
- return `rgb(${r},${g},${b})`;
54
- }
55
-
56
- // interference lines make automated segment extraction harder
57
- function renderNoiseLines(pool: RandomPool, count: number): string {
58
- let lines = '';
59
- for (let i = 0; i < count; i++) {
60
- const x1 = pool.int(0, SVG_WIDTH);
61
- const y1 = pool.int(0, SVG_HEIGHT);
62
- const x2 = pool.int(0, SVG_WIDTH);
63
- const y2 = pool.int(0, SVG_HEIGHT);
64
- const strokeWidth = (pool.float() * 1.5 + 0.5).toFixed(1);
65
- const color = grayColor(pool);
66
- const opacity = (pool.float() * 0.4 + 0.2).toFixed(2);
67
- lines += `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${color}" stroke-width="${strokeWidth}" opacity="${opacity}"/>`;
68
- }
69
- return lines;
70
- }
71
-
72
- // noise dots add pixel-level entropy that defeats simple segmentation
73
- function renderNoiseDots(pool: RandomPool, count: number): string {
74
- let dots = '';
75
- for (let i = 0; i < count; i++) {
76
- const cx = pool.int(0, SVG_WIDTH);
77
- const cy = pool.int(0, SVG_HEIGHT);
78
- const r = pool.int(1, 3);
79
- const color = grayColor(pool);
80
- const opacity = (pool.float() * 0.5 + 0.1).toFixed(2);
81
- dots += `<circle cx="${cx}" cy="${cy}" r="${r}" fill="${color}" opacity="${opacity}"/>`;
82
- }
83
- return dots;
84
- }
85
-
86
- // sinusoidal wave distortion applied as a path overlay
87
- function renderWavePath(pool: RandomPool): string {
88
- const amplitude = pool.int(3, 8);
89
- const frequency = pool.float() * 0.08 + 0.04;
90
- const phaseShift = pool.float() * Math.PI * 2;
91
- const strokeColor = grayColor(pool);
92
-
93
- let d = `M 0 ${SVG_HEIGHT / 2}`;
94
- for (let x = 1; x <= SVG_WIDTH; x += 2) {
95
- const y = SVG_HEIGHT / 2 + amplitude * Math.sin(frequency * x + phaseShift);
96
- d += ` L ${x} ${y.toFixed(2)}`;
97
- }
98
-
99
- return `<path d="${d}" stroke="${strokeColor}" stroke-width="1.5" fill="none" opacity="0.35"/>`;
100
- }
101
-
102
- function buildSvg(pool: RandomPool, text: string, difficulty: 'easy' | 'medium' | 'hard'): string {
103
- const charCount = text.length;
104
- const spacing = SVG_WIDTH / (charCount + 1);
105
- const baseY = SVG_HEIGHT / 2 + 8;
106
-
107
- const noiseLineCount = difficulty === 'easy' ? 4 : difficulty === 'medium' ? 7 : 10;
108
- const noiseDotCount = difficulty === 'easy' ? 20 : difficulty === 'medium' ? 40 : 60;
109
- const waveCount = difficulty === 'easy' ? 1 : difficulty === 'medium' ? 2 : 3;
110
-
111
- const bgGray = pool.int(235, 250);
112
- const background = `<rect width="${SVG_WIDTH}" height="${SVG_HEIGHT}" fill="rgb(${bgGray},${bgGray},${bgGray})" rx="6"/>`;
113
-
114
- let chars = '';
115
- for (let i = 0; i < charCount; i++) {
116
- const x = Math.round(spacing * (i + 1));
117
- const color = darkColor(pool);
118
- chars += renderChar(pool, text[i]!, x, baseY, color);
119
- }
120
-
121
- let waves = '';
122
- for (let i = 0; i < waveCount; i++) {
123
- waves += renderWavePath(pool);
124
- }
125
-
126
- 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>`;
127
- }
128
-
129
- function svgToDataUri(svg: string): string {
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)}`;
134
- }
135
-
136
- export class ImageGenerator {
137
- static generate(difficulty: 'easy' | 'medium' | 'hard'): ImageGeneratorResult {
138
- // single pool allocation covers all random needs for this image — ~1 crypto
139
- // call instead of the ~500 individual randomBytes(4) calls it replaced
140
- const pool = new RandomPool(2048);
141
-
142
- const charPool = difficulty === 'easy' ? CHAR_POOL_EASY : difficulty === 'medium' ? CHAR_POOL_MEDIUM : CHAR_POOL_HARD;
143
- const length = difficulty === 'easy' ? 4 : difficulty === 'medium' ? 5 : 6;
144
- const answer = generateText(pool, charPool, length);
145
- const svg = buildSvg(pool, answer, difficulty);
146
- const image = svgToDataUri(svg);
147
-
148
- return {
149
- image,
150
- answer: answer.toLowerCase(),
151
- question: 'Type the characters shown in the image'
152
- };
153
- }
154
- }
@@ -1,43 +0,0 @@
1
- import { randomBytes } from './crypto';
2
-
3
- export class Random {
4
- static getRandomNumber(difficulty: 'easy' | 'medium' | 'hard'): number {
5
- const buffer = randomBytes(4);
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
- if (difficulty === 'easy') {
9
- return Math.floor(rand * 10) + 1;
10
- }
11
- if (difficulty === 'medium') {
12
- return Math.floor(rand * 50) + 1;
13
- }
14
- return Math.floor(rand * 100) + 1;
15
- }
16
-
17
- static getRandomOperator(): string {
18
- const operators = ['+', '-', '*', '/'];
19
- const buffer = randomBytes(1) as any;
20
- const index = buffer[0]! % operators.length;
21
- return operators[index]!;
22
- }
23
-
24
- static generateRandomString(difficulty: 'easy' | 'medium' | 'hard'): string {
25
- const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
26
- let result = '';
27
- const length = difficulty === 'easy' ? 4 : difficulty === 'medium' ? 6 : 8;
28
- const buffer = randomBytes(length) as any;
29
- // pick random chars from the charset using crypto random bytes
30
- for (let i = 0; i < length; i++) {
31
- result += chars.charAt(buffer[i]! % chars.length);
32
- }
33
- return result;
34
- }
35
-
36
- static generateNonce(): string {
37
- return randomBytes(16).toString('hex');
38
- }
39
-
40
- static generateSalt(): string {
41
- return randomBytes(8).toString('hex');
42
- }
43
- }