k9guard 1.0.2 → 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 +202 -14
- 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 +203 -15
- package/package.json +35 -13
- package/index.ts +0 -4
- package/src/K9Guard.ts +0 -91
- package/src/core/captchaGenerator.ts +0 -298
- package/src/core/captchaValidator.ts +0 -182
- package/src/types/index.ts +0 -84
- package/src/utils/crypto.ts +0 -193
- package/src/utils/customQuestionGenerator.ts +0 -40
- package/src/utils/emojiGenerator.ts +0 -88
- package/src/utils/imageGenerator.ts +0 -152
- 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 -30
- package/src/validators/customQuestionValidator.ts +0 -88
- package/tsconfig.json +0 -29
package/src/utils/crypto.ts
DELETED
|
@@ -1,193 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -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,152 +0,0 @@
|
|
|
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
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
|
-
// NOTE: convert crypto bytes to a number between 0 and 1
|
|
7
|
-
const rand = buffer.readUInt32LE(0) / 0xFFFFFFFF;
|
|
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
|
-
}
|
|
@@ -1,54 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from './crypto';
|
|
2
|
-
|
|
3
|
-
export class ReverseGenerator {
|
|
4
|
-
private static readonly easyWords = [
|
|
5
|
-
'cat', 'dog', 'sun', 'moon', 'star', 'fish', 'bird', 'tree',
|
|
6
|
-
'ball', 'book', 'door', 'lamp', 'rock', 'leaf', 'wind', 'fire',
|
|
7
|
-
'ice', 'sky', 'sea', 'fog', 'rain', 'snow', 'bear', 'wolf',
|
|
8
|
-
'coin', 'key', 'cup', 'pen', 'box', 'hat', 'map', 'web'
|
|
9
|
-
];
|
|
10
|
-
|
|
11
|
-
private static readonly mediumWords = [
|
|
12
|
-
'apple', 'house', 'water', 'bread', 'ocean', 'river', 'tiger', 'eagle',
|
|
13
|
-
'storm', 'cloud', 'flame', 'frost', 'stone', 'metal', 'glass', 'paper',
|
|
14
|
-
'forest', 'desert', 'valley', 'castle', 'bridge', 'garden', 'market', 'temple',
|
|
15
|
-
'dragon', 'wizard', 'knight', 'shield', 'sword', 'crown', 'jewel', 'crystal',
|
|
16
|
-
'planet', 'galaxy', 'comet', 'nebula', 'cipher', 'enigma', 'puzzle', 'riddle',
|
|
17
|
-
'shadow', 'mirror', 'portal', 'beacon', 'anchor', 'compass', 'lantern', 'prism'
|
|
18
|
-
];
|
|
19
|
-
|
|
20
|
-
private static readonly hardWords = [
|
|
21
|
-
'typescript', 'javascript', 'encryption', 'cryptography', 'algorithm', 'infrastructure',
|
|
22
|
-
'architecture', 'authentication', 'authorization', 'vulnerability', 'cybersecurity',
|
|
23
|
-
'blockchain', 'metamorphosis', 'constellation', 'synchronization', 'transformation',
|
|
24
|
-
'illumination', 'orchestration', 'denomination', 'comprehension', 'manifestation',
|
|
25
|
-
'extraordinary', 'revolutionary', 'sophisticated', 'kaleidoscope', 'optimization',
|
|
26
|
-
'crystallization', 'configuration', 'implementation', 'parallelization', 'serialization',
|
|
27
|
-
'decentralization', 'internationalization', 'containerization', 'virtualization',
|
|
28
|
-
'obfuscation', 'triangulation', 'interpolation', 'extrapolation', 'approximation',
|
|
29
|
-
'segmentation', 'fragmentation', 'concatenation', 'regeneration', 'degeneration'
|
|
30
|
-
];
|
|
31
|
-
|
|
32
|
-
static generate(difficulty: 'easy' | 'medium' | 'hard'): { question: string; answer: string } {
|
|
33
|
-
let wordPool: string[];
|
|
34
|
-
|
|
35
|
-
if (difficulty === 'easy') {
|
|
36
|
-
wordPool = this.easyWords;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (difficulty === 'medium') {
|
|
40
|
-
wordPool = this.mediumWords;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
if (difficulty === 'hard') {
|
|
44
|
-
wordPool = this.hardWords;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
const buf = randomBytes(4);
|
|
48
|
-
const rand = buf.readUInt32LE(0) / 0xFFFFFFFF;
|
|
49
|
-
const text = wordPool![Math.floor(rand * wordPool!.length)]!;
|
|
50
|
-
const reversed = text.split('').reverse().join('');
|
|
51
|
-
|
|
52
|
-
return { question: reversed, answer: text };
|
|
53
|
-
}
|
|
54
|
-
}
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from './crypto';
|
|
2
|
-
|
|
3
|
-
export class ScrambleGenerator {
|
|
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[] = [
|
|
9
|
-
'apple', 'cat', 'dog', 'house', 'sun', 'moon', 'car', 'tree', 'book', 'water',
|
|
10
|
-
'bread', 'milk', 'fish', 'bird', 'flower', 'star', 'hand', 'eye', 'nose', 'mouth'
|
|
11
|
-
];
|
|
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
|
-
|
|
20
|
-
static generate(difficulty: 'easy' | 'medium' | 'hard'): { question: string; answer: string } {
|
|
21
|
-
let pool: string[];
|
|
22
|
-
if (difficulty === 'easy') {
|
|
23
|
-
pool = this.easyWords;
|
|
24
|
-
} else if (difficulty === 'medium') {
|
|
25
|
-
pool = this.mediumWords;
|
|
26
|
-
} else {
|
|
27
|
-
pool = this.hardWords;
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
const buffer = randomBytes(4);
|
|
31
|
-
const rand = buffer.readUInt32LE(0) / 0xFFFFFFFF;
|
|
32
|
-
const word = pool[Math.floor(rand * pool.length)]!;
|
|
33
|
-
|
|
34
|
-
const scrambled = this.scramble(word);
|
|
35
|
-
return { question: scrambled, answer: word };
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
private static scramble(word: string): string {
|
|
39
|
-
const arr = word.split('');
|
|
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);
|
|
45
|
-
[arr[i]!, arr[j]!] = [arr[j]!, arr[i]!];
|
|
46
|
-
}
|
|
47
|
-
return arr.join('');
|
|
48
|
-
}
|
|
49
|
-
}
|
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import { randomBytes } from './crypto';
|
|
2
|
-
|
|
3
|
-
export class SequenceGenerator {
|
|
4
|
-
static generate(difficulty: 'easy' | 'medium' | 'hard'): { question: string; answer: number | string } {
|
|
5
|
-
if (difficulty === 'easy') {
|
|
6
|
-
const buffer = randomBytes(4);
|
|
7
|
-
// NOTE: generate random starting number and step size for arithmetic sequence
|
|
8
|
-
const start = (buffer.readUInt32LE(0) % 5) + 1;
|
|
9
|
-
const step = ((buffer.readUInt32LE(0) >> 8) % 3) + 1;
|
|
10
|
-
const sequence = [start, start + step, start + 2 * step];
|
|
11
|
-
const answer = start + 3 * step;
|
|
12
|
-
return { question: `${sequence.join(', ')}, ?`, answer };
|
|
13
|
-
}
|
|
14
|
-
if (difficulty === 'medium') {
|
|
15
|
-
const letters = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J'];
|
|
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);
|
|
21
|
-
const sequence = [letters[start], letters[start + step], letters[start + 2 * step]];
|
|
22
|
-
const answer = letters[start + 3 * step]!;
|
|
23
|
-
return { question: `${sequence.join(', ')}, ?`, answer };
|
|
24
|
-
}
|
|
25
|
-
// hard mode uses fibonacci sequence
|
|
26
|
-
const sequence = [1, 1, 2, 3];
|
|
27
|
-
const answer = 5;
|
|
28
|
-
return { question: `${sequence.join(', ')}, ?`, answer };
|
|
29
|
-
}
|
|
30
|
-
}
|