k9guard 1.0.0 → 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 +133 -68
- package/src/core/captchaValidator.ts +77 -12
- package/src/types/index.ts +34 -22
- package/src/utils/crypto.ts +193 -0
- package/src/utils/customQuestionGenerator.ts +1 -1
- package/src/utils/emojiGenerator.ts +88 -0
- package/src/utils/imageGenerator.ts +152 -0
- package/src/utils/random.ts +3 -3
- package/src/utils/reverseGenerator.ts +5 -1
- package/src/utils/scrambleGenerator.ts +26 -14
- package/src/utils/sequenceGenerator.ts +6 -9
- 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
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cryptographic primitives for k9guard.
|
|
3
|
+
*
|
|
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.
|
|
8
|
+
*
|
|
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
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { createHash as nodeCreateHash, randomBytes as nodeRandomBytes } from 'node:crypto';
|
|
16
|
+
|
|
17
|
+
export interface ICryptoBuffer {
|
|
18
|
+
readUInt32LE(offset: number): number;
|
|
19
|
+
toString(encoding?: string): string;
|
|
20
|
+
length: number;
|
|
21
|
+
[index: number]: number | undefined;
|
|
22
|
+
[Symbol.iterator](): Iterator<number>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export class CryptoBuffer implements ICryptoBuffer {
|
|
26
|
+
private buffer: Uint8Array;
|
|
27
|
+
[index: number]: number | undefined;
|
|
28
|
+
|
|
29
|
+
constructor(buffer: Uint8Array) {
|
|
30
|
+
this.buffer = buffer;
|
|
31
|
+
// Proxy enables numeric index access (buffer[0]) while keeping internal state private
|
|
32
|
+
const proxy = new Proxy(this, {
|
|
33
|
+
get(target, prop) {
|
|
34
|
+
if (typeof prop === 'string' && !isNaN(Number(prop))) {
|
|
35
|
+
return target.buffer[Number(prop)];
|
|
36
|
+
}
|
|
37
|
+
return (target as any)[prop];
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
return proxy as any;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
readUInt32LE(offset: number): number {
|
|
44
|
+
if (offset < 0 || offset + 4 > this.buffer.length) {
|
|
45
|
+
throw new RangeError('Offset out of bounds');
|
|
46
|
+
}
|
|
47
|
+
const b0 = this.buffer[offset];
|
|
48
|
+
const b1 = this.buffer[offset + 1];
|
|
49
|
+
const b2 = this.buffer[offset + 2];
|
|
50
|
+
const b3 = this.buffer[offset + 3];
|
|
51
|
+
|
|
52
|
+
if (b0 === undefined || b1 === undefined || b2 === undefined || b3 === undefined) {
|
|
53
|
+
throw new RangeError('Buffer read error');
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Little-endian: b0 is LSB, unsigned right-shift keeps result in [0, 2^32)
|
|
57
|
+
return (b0 | (b1 << 8) | (b2 << 16) | (b3 << 24)) >>> 0;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
toString(encoding?: string): string {
|
|
61
|
+
if (encoding === 'hex') {
|
|
62
|
+
return Array.from(this.buffer)
|
|
63
|
+
.map(b => b.toString(16).padStart(2, '0'))
|
|
64
|
+
.join('');
|
|
65
|
+
}
|
|
66
|
+
if (encoding === 'base64') {
|
|
67
|
+
const binString = Array.from(this.buffer, (byte: number) => String.fromCodePoint(byte)).join('');
|
|
68
|
+
if (typeof btoa !== 'undefined') {
|
|
69
|
+
return btoa(binString);
|
|
70
|
+
}
|
|
71
|
+
return binString;
|
|
72
|
+
}
|
|
73
|
+
return new TextDecoder().decode(this.buffer);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
get length(): number {
|
|
77
|
+
return this.buffer.length;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
[Symbol.iterator](): Iterator<number> {
|
|
81
|
+
let index = 0;
|
|
82
|
+
const buffer = this.buffer;
|
|
83
|
+
return {
|
|
84
|
+
next(): IteratorResult<number> {
|
|
85
|
+
if (index < buffer.length) {
|
|
86
|
+
const val = buffer[index++];
|
|
87
|
+
if (val === undefined) {
|
|
88
|
+
return { value: 0, done: true };
|
|
89
|
+
}
|
|
90
|
+
return { value: val, done: false };
|
|
91
|
+
}
|
|
92
|
+
return { value: 0, done: true };
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Thin wrapper around node:crypto Hash so callers keep the same chained API:
|
|
100
|
+
* createHash('sha256').update(data).digest('hex')
|
|
101
|
+
*
|
|
102
|
+
* Only SHA-256 is accepted; any other algorithm is rejected at construction
|
|
103
|
+
* time to prevent accidental use of weaker primitives.
|
|
104
|
+
*/
|
|
105
|
+
export class CryptoHash {
|
|
106
|
+
private hash: ReturnType<typeof nodeCreateHash>;
|
|
107
|
+
|
|
108
|
+
constructor() {
|
|
109
|
+
this.hash = nodeCreateHash('sha256');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
update(input: string | Uint8Array): this {
|
|
113
|
+
this.hash.update(typeof input === 'string' ? input : Buffer.from(input));
|
|
114
|
+
return this;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
digest(encoding: 'hex' | 'base64' | 'binary' = 'hex'): string {
|
|
118
|
+
return this.hash.digest(encoding);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
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');
|
|
126
|
+
}
|
|
127
|
+
const buf = nodeRandomBytes(size);
|
|
128
|
+
return new CryptoBuffer(new Uint8Array(buf));
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
static createHash(algorithm: string): CryptoHash {
|
|
132
|
+
if (algorithm.toLowerCase() !== 'sha256') {
|
|
133
|
+
throw new Error('Only SHA-256 is supported');
|
|
134
|
+
}
|
|
135
|
+
return new CryptoHash();
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export const randomBytes = (size: number): ICryptoBuffer => CryptoUtils.randomBytes(size);
|
|
140
|
+
export const createHash = (algorithm: string): CryptoHash => CryptoUtils.createHash(algorithm);
|
|
141
|
+
|
|
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
|
+
}
|
|
160
|
+
|
|
161
|
+
private refill(): void {
|
|
162
|
+
this.buffer = nodeRandomBytes(this.chunkSize);
|
|
163
|
+
this.offset = 0;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
uint32(): number {
|
|
167
|
+
if (this.offset + 4 > this.buffer.length) {
|
|
168
|
+
this.refill();
|
|
169
|
+
}
|
|
170
|
+
const val = this.buffer.readUInt32LE(this.offset);
|
|
171
|
+
this.offset += 4;
|
|
172
|
+
return val;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
byte(): number {
|
|
176
|
+
if (this.offset >= this.buffer.length) {
|
|
177
|
+
this.refill();
|
|
178
|
+
}
|
|
179
|
+
const val = this.buffer[this.offset]!;
|
|
180
|
+
this.offset += 1;
|
|
181
|
+
return val;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// uniform float in [0, 1)
|
|
185
|
+
float(): number {
|
|
186
|
+
return this.uint32() / 0xffffffff;
|
|
187
|
+
}
|
|
188
|
+
|
|
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
|
+
}
|
package/src/utils/random.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from 'crypto';
|
|
1
|
+
import { randomBytes } from './crypto';
|
|
2
2
|
|
|
3
3
|
export class Random {
|
|
4
4
|
static getRandomNumber(difficulty: 'easy' | 'medium' | 'hard'): number {
|
|
@@ -16,7 +16,7 @@ export class Random {
|
|
|
16
16
|
|
|
17
17
|
static getRandomOperator(): string {
|
|
18
18
|
const operators = ['+', '-', '*', '/'];
|
|
19
|
-
const buffer = randomBytes(1);
|
|
19
|
+
const buffer = randomBytes(1) as any;
|
|
20
20
|
const index = buffer[0]! % operators.length;
|
|
21
21
|
return operators[index]!;
|
|
22
22
|
}
|
|
@@ -25,7 +25,7 @@ export class Random {
|
|
|
25
25
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
26
26
|
let result = '';
|
|
27
27
|
const length = difficulty === 'easy' ? 4 : difficulty === 'medium' ? 6 : 8;
|
|
28
|
-
const buffer = randomBytes(length);
|
|
28
|
+
const buffer = randomBytes(length) as any;
|
|
29
29
|
// pick random chars from the charset using crypto random bytes
|
|
30
30
|
for (let i = 0; i < length; i++) {
|
|
31
31
|
result += chars.charAt(buffer[i]! % chars.length);
|
|
@@ -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 };
|
|
@@ -1,24 +1,35 @@
|
|
|
1
|
-
import { randomBytes } from 'crypto';
|
|
1
|
+
import { randomBytes } from './crypto';
|
|
2
2
|
|
|
3
3
|
export class ScrambleGenerator {
|
|
4
|
-
private static
|
|
4
|
+
private static readonly easyWords: string[] = [
|
|
5
|
+
'apple', 'cat', 'dog', 'house', 'sun', 'moon', 'car', 'tree', 'book', 'water'
|
|
6
|
+
];
|
|
7
|
+
|
|
8
|
+
private static readonly mediumWords: string[] = [
|
|
5
9
|
'apple', 'cat', 'dog', 'house', 'sun', 'moon', 'car', 'tree', 'book', 'water',
|
|
6
10
|
'bread', 'milk', 'fish', 'bird', 'flower', 'star', 'hand', 'eye', 'nose', 'mouth'
|
|
7
11
|
];
|
|
8
12
|
|
|
13
|
+
// hard pool: longer words with uncommon letter patterns to resist guessing
|
|
14
|
+
private static readonly hardWords: string[] = [
|
|
15
|
+
'typescript', 'javascript', 'algorithm', 'encryption', 'blockchain',
|
|
16
|
+
'metamorphosis', 'cryptography', 'synchronize', 'parallelism', 'obfuscation',
|
|
17
|
+
'infrastructure', 'authentication', 'authorization', 'vulnerability', 'orchestration'
|
|
18
|
+
];
|
|
19
|
+
|
|
9
20
|
static generate(difficulty: 'easy' | 'medium' | 'hard'): { question: string; answer: string } {
|
|
10
|
-
let
|
|
11
|
-
const buffer = randomBytes(4);
|
|
12
|
-
// convert crypto bytes to a float between 0 and 1
|
|
13
|
-
const rand = buffer.readUInt32LE(0) / 0xFFFFFFFF;
|
|
14
|
-
|
|
21
|
+
let pool: string[];
|
|
15
22
|
if (difficulty === 'easy') {
|
|
16
|
-
|
|
23
|
+
pool = this.easyWords;
|
|
17
24
|
} else if (difficulty === 'medium') {
|
|
18
|
-
|
|
25
|
+
pool = this.mediumWords;
|
|
19
26
|
} else {
|
|
20
|
-
|
|
27
|
+
pool = this.hardWords;
|
|
21
28
|
}
|
|
29
|
+
|
|
30
|
+
const buffer = randomBytes(4);
|
|
31
|
+
const rand = buffer.readUInt32LE(0) / 0xFFFFFFFF;
|
|
32
|
+
const word = pool[Math.floor(rand * pool.length)]!;
|
|
22
33
|
|
|
23
34
|
const scrambled = this.scramble(word);
|
|
24
35
|
return { question: scrambled, answer: word };
|
|
@@ -26,10 +37,11 @@ export class ScrambleGenerator {
|
|
|
26
37
|
|
|
27
38
|
private static scramble(word: string): string {
|
|
28
39
|
const arr = word.split('');
|
|
29
|
-
|
|
30
|
-
for
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
const n = arr.length;
|
|
41
|
+
// batch all uint32 values for Fisher-Yates in one crypto call (n-1 swaps × 4 bytes)
|
|
42
|
+
const batchBuf = randomBytes(n * 4);
|
|
43
|
+
for (let i = n - 1; i > 0; i--) {
|
|
44
|
+
const j = batchBuf.readUInt32LE((n - 1 - i) * 4) % (i + 1);
|
|
33
45
|
[arr[i]!, arr[j]!] = [arr[j]!, arr[i]!];
|
|
34
46
|
}
|
|
35
47
|
return arr.join('');
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { randomBytes } from 'crypto';
|
|
1
|
+
import { randomBytes } from './crypto';
|
|
2
2
|
|
|
3
3
|
export class SequenceGenerator {
|
|
4
4
|
static generate(difficulty: 'easy' | 'medium' | 'hard'): { question: string; answer: number | string } {
|
|
@@ -13,16 +13,13 @@ export class SequenceGenerator {
|
|
|
13
13
|
}
|
|
14
14
|
if (difficulty === 'medium') {
|
|
15
15
|
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
|
|
16
|
-
const buffer = randomBytes(4);
|
|
17
|
-
const start = buffer.readUInt32LE(0) % 4;
|
|
18
16
|
const step = 2;
|
|
17
|
+
// max start ensures all 4 elements (start, +2, +4, +6) fit within the 10-letter array
|
|
18
|
+
const maxStart = letters.length - step * 3 - 1;
|
|
19
|
+
const buffer = randomBytes(4);
|
|
20
|
+
const start = buffer.readUInt32LE(0) % (maxStart + 1);
|
|
19
21
|
const sequence = [letters[start], letters[start + step], letters[start + 2 * step]];
|
|
20
|
-
const
|
|
21
|
-
// check if next letter goes out of bounds
|
|
22
|
-
if (nextIndex >= letters.length) {
|
|
23
|
-
return { question: `${sequence.join(', ')}, ?`, answer: '?' };
|
|
24
|
-
}
|
|
25
|
-
const answer = letters[nextIndex]!;
|
|
22
|
+
const answer = letters[start + 3 * step]!;
|
|
26
23
|
return { question: `${sequence.join(', ')}, ?`, answer };
|
|
27
24
|
}
|
|
28
25
|
// hard mode uses fibonacci sequence
|
package/src/locale/en.json
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"riddles": [
|
|
3
|
-
{ "question": "What has keys but can't open locks?", "answer": "piano" },
|
|
4
|
-
{ "question": "What gets wetter as it dries?", "answer": "towel" },
|
|
5
|
-
{ "question": "What has a head, a tail, but no body?", "answer": "coin" },
|
|
6
|
-
{ "question": "What can you catch but not throw?", "answer": "cold" },
|
|
7
|
-
{ "question": "What has one eye but can't see?", "answer": "needle" },
|
|
8
|
-
{ "question": "What is always in front of you but can't be seen?", "answer": "future" },
|
|
9
|
-
{ "question": "What has many teeth but can't bite?", "answer": "comb" },
|
|
10
|
-
{ "question": "What is full of holes but still holds water?", "answer": "sponge" },
|
|
11
|
-
{ "question": "What has a neck but no head?", "answer": "bottle" },
|
|
12
|
-
{ "question": "What can travel around the world while staying in a corner?", "answer": "stamp" },
|
|
13
|
-
{ "question": "What has hands but cannot clap?", "answer": "clock" },
|
|
14
|
-
{ "question": "What runs but never walks?", "answer": "water" },
|
|
15
|
-
{ "question": "What has a bed but never sleeps?", "answer": "river" },
|
|
16
|
-
{ "question": "What has a face and two hands but no arms or legs?", "answer": "clock" },
|
|
17
|
-
{ "question": "What goes up but never comes down?", "answer": "age" },
|
|
18
|
-
{ "question": "What has words but never speaks?", "answer": "book" },
|
|
19
|
-
{ "question": "What has a thumb and four fingers but is not alive?", "answer": "glove" },
|
|
20
|
-
{ "question": "What can fill a room but takes up no space?", "answer": "light" },
|
|
21
|
-
{ "question": "What breaks when you say its name?", "answer": "silence" },
|
|
22
|
-
{ "question": "What has legs but cannot walk?", "answer": "table" }
|
|
23
|
-
],
|
|
24
|
-
"logics": [
|
|
25
|
-
{ "question": "If today is Monday, tomorrow is Tuesday. True or False?", "answer": "True" },
|
|
26
|
-
{ "question": "A cat is an animal. True or False?", "answer": "True" },
|
|
27
|
-
{ "question": "Water is dry. True or False?", "answer": "False" },
|
|
28
|
-
{ "question": "All apples are red. True or False?", "answer": "False" },
|
|
29
|
-
{ "question": "5 is greater than 3. True or False?", "answer": "True" },
|
|
30
|
-
{ "question": "If A > B and B > C, then A > C. True or False?", "answer": "True" },
|
|
31
|
-
{ "question": "A square has 3 sides. True or False?", "answer": "False" },
|
|
32
|
-
{ "question": "The sun rises in the east. True or False?", "answer": "True" },
|
|
33
|
-
{ "question": "Fish can fly. True or False?", "answer": "False" },
|
|
34
|
-
{ "question": "2 + 2 = 4. True or False?", "answer": "True" },
|
|
35
|
-
{ "question": "A triangle has 3 corners. True or False?", "answer": "True" },
|
|
36
|
-
{ "question": "Ice is hot. True or False?", "answer": "False" },
|
|
37
|
-
{ "question": "10 is an even number. True or False?", "answer": "True" },
|
|
38
|
-
{ "question": "Humans need oxygen to breathe. True or False?", "answer": "True" },
|
|
39
|
-
{ "question": "A year has 12 months. True or False?", "answer": "True" },
|
|
40
|
-
{ "question": "Birds are mammals. True or False?", "answer": "False" },
|
|
41
|
-
{ "question": "7 is less than 15. True or False?", "answer": "True" },
|
|
42
|
-
{ "question": "A circle has corners. True or False?", "answer": "False" },
|
|
43
|
-
{ "question": "The earth is flat. True or False?", "answer": "False" },
|
|
44
|
-
{ "question": "A week has 7 days. True or False?", "answer": "True" }
|
|
45
|
-
]
|
|
46
|
-
}
|
package/src/locale/tr.json
DELETED
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"riddles": [
|
|
3
|
-
{ "question": "Anahtarları var ama kapıları açamaz?", "answer": "piyano" },
|
|
4
|
-
{ "question": "Kurudukça ıslanan nedir?", "answer": "havlu" },
|
|
5
|
-
{ "question": "Baş ve kuyruğu var ama vücudu yok?", "answer": "madeni para" },
|
|
6
|
-
{ "question": "Yakalayabilirsin ama atamazsın?", "answer": "soğuk algınlığı" },
|
|
7
|
-
{ "question": "Bir gözü var ama göremez?", "answer": "iğne" },
|
|
8
|
-
{ "question": "Her zaman önünde ama görülemez?", "answer": "gelecek" },
|
|
9
|
-
{ "question": "Çok dişleri var ama ısırmaz?", "answer": "tarak" },
|
|
10
|
-
{ "question": "Delik dolu ama suyu tutar?", "answer": "sünger" },
|
|
11
|
-
{ "question": "Boynu var ama başı yok?", "answer": "şişe" },
|
|
12
|
-
{ "question": "Dünyayı dolaşır ama köşede kalır?", "answer": "posta pulu" },
|
|
13
|
-
{ "question": "Elleri var ama alkışlayamaz?", "answer": "saat" },
|
|
14
|
-
{ "question": "Koşar ama asla yürümez?", "answer": "su" },
|
|
15
|
-
{ "question": "Yatağı var ama asla uyumaz?", "answer": "nehir" },
|
|
16
|
-
{ "question": "Yüzü ve iki eli var ama kolu ve bacağı yok?", "answer": "saat" },
|
|
17
|
-
{ "question": "Yukarı çıkar ama asla aşağı inmez?", "answer": "yaş" },
|
|
18
|
-
{ "question": "Kelimeleri var ama asla konuşmaz?", "answer": "kitap" },
|
|
19
|
-
{ "question": "Başparmağı ve dört parmağı var ama canlı değil?", "answer": "eldiven" },
|
|
20
|
-
{ "question": "Bir odayı doldurur ama yer kaplamaz?", "answer": "ışık" },
|
|
21
|
-
{ "question": "Adını söylediğinde kırılır?", "answer": "sessizlik" },
|
|
22
|
-
{ "question": "Bacakları var ama yürüyemez?", "answer": "masa" }
|
|
23
|
-
],
|
|
24
|
-
"logics": [
|
|
25
|
-
{ "question": "Bugün Pazartesi ise, yarın Salı'dır. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
26
|
-
{ "question": "Kedi bir hayvandır. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
27
|
-
{ "question": "Su kurudur. Doğru mu Yanlış mı?", "answer": "Yanlış" },
|
|
28
|
-
{ "question": "Tüm elmalar kırmızıdır. Doğru mu Yanlış mı?", "answer": "Yanlış" },
|
|
29
|
-
{ "question": "5, 3'ten büyüktür. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
30
|
-
{ "question": "Eğer A > B ve B > C ise, A > C olur. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
31
|
-
{ "question": "Karenin 3 kenarı vardır. Doğru mu Yanlış mı?", "answer": "Yanlış" },
|
|
32
|
-
{ "question": "Güneş doğudan doğar. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
33
|
-
{ "question": "Balıklar uçar. Doğru mu Yanlış mı?", "answer": "Yanlış" },
|
|
34
|
-
{ "question": "2 + 2 = 4. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
35
|
-
{ "question": "Üçgenin 3 köşesi vardır. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
36
|
-
{ "question": "Buz sıcaktır. Doğru mu Yanlış mı?", "answer": "Yanlış" },
|
|
37
|
-
{ "question": "10 çift sayıdır. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
38
|
-
{ "question": "İnsanlar nefes almak için oksijene ihtiyaç duyar. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
39
|
-
{ "question": "Bir yılda 12 ay vardır. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
40
|
-
{ "question": "Kuşlar memelidir. Doğru mu Yanlış mı?", "answer": "Yanlış" },
|
|
41
|
-
{ "question": "7, 15'ten küçüktür. Doğru mu Yanlış mı?", "answer": "Doğru" },
|
|
42
|
-
{ "question": "Dairenin köşeleri vardır. Doğru mu Yanlış mı?", "answer": "Yanlış" },
|
|
43
|
-
{ "question": "Dünya düzdür. Doğru mu Yanlış mı?", "answer": "Yanlış" },
|
|
44
|
-
{ "question": "Bir haftada 7 gün vardır. Doğru mu Yanlış mı?", "answer": "Doğru" }
|
|
45
|
-
]
|
|
46
|
-
}
|