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.
- package/README.md +196 -5
- package/dist/index.cjs +1555 -0
- package/dist/index.d.cts +141 -0
- package/dist/index.d.ts +141 -0
- package/dist/index.js +1526 -0
- package/docs/tr/README.md +196 -5
- package/package.json +35 -13
- package/index.ts +0 -4
- package/src/K9Guard.ts +0 -97
- package/src/core/captchaGenerator.ts +0 -372
- package/src/core/captchaValidator.ts +0 -198
- package/src/types/index.ts +0 -84
- package/src/utils/crypto.ts +0 -336
- package/src/utils/customQuestionGenerator.ts +0 -40
- package/src/utils/emojiGenerator.ts +0 -88
- package/src/utils/imageGenerator.ts +0 -154
- package/src/utils/random.ts +0 -43
- package/src/utils/reverseGenerator.ts +0 -54
- package/src/utils/scrambleGenerator.ts +0 -49
- package/src/utils/sequenceGenerator.ts +0 -34
- package/src/validators/customQuestionValidator.ts +0 -88
- package/tsconfig.json +0 -29
package/src/utils/crypto.ts
DELETED
|
@@ -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, '&')
|
|
20
|
-
.replace(/</g, '<')
|
|
21
|
-
.replace(/>/g, '>')
|
|
22
|
-
.replace(/"/g, '"')
|
|
23
|
-
.replace(/'/g, ''');
|
|
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
|
-
}
|
package/src/utils/random.ts
DELETED
|
@@ -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
|
-
}
|