k9guard 1.0.0
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 +235 -0
- package/docs/tr/README.md +235 -0
- package/index.ts +4 -0
- package/package.json +64 -0
- package/src/K9Guard.ts +83 -0
- package/src/core/captchaGenerator.ts +233 -0
- package/src/core/captchaValidator.ts +117 -0
- package/src/locale/en.json +46 -0
- package/src/locale/tr.json +46 -0
- package/src/types/index.ts +72 -0
- package/src/utils/customQuestionGenerator.ts +40 -0
- package/src/utils/logicGenerator.ts +18 -0
- package/src/utils/random.ts +43 -0
- package/src/utils/reverseGenerator.ts +50 -0
- package/src/utils/riddleBank.ts +18 -0
- package/src/utils/scrambleGenerator.ts +37 -0
- package/src/utils/sequenceGenerator.ts +33 -0
- package/src/validators/customQuestionValidator.ts +88 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'crypto';
|
|
2
|
+
import { Random } from '../utils/random';
|
|
3
|
+
import { RiddleBank } from '../utils/riddleBank';
|
|
4
|
+
import { SequenceGenerator } from '../utils/sequenceGenerator';
|
|
5
|
+
import { ScrambleGenerator } from '../utils/scrambleGenerator';
|
|
6
|
+
import { LogicGenerator } from '../utils/logicGenerator';
|
|
7
|
+
import { ReverseGenerator } from '../utils/reverseGenerator';
|
|
8
|
+
import { CustomQuestionGenerator } from '../utils/customQuestionGenerator';
|
|
9
|
+
import { CustomQuestionValidator } from '../validators/customQuestionValidator';
|
|
10
|
+
import type { K9GuardOptions, K9GuardCustomOptions, CaptchaChallenge, MathCaptcha, TextCaptcha, RiddleCaptcha, SequenceCaptcha, ScrambleCaptcha, LogicCaptcha, ReverseCaptcha, MixedCaptcha, CustomCaptcha, CustomQuestion } from '../types';
|
|
11
|
+
|
|
12
|
+
export class CaptchaGenerator {
|
|
13
|
+
private standardOptions: K9GuardOptions | null = null;
|
|
14
|
+
private customOptions: K9GuardCustomOptions | null = null;
|
|
15
|
+
private customGenerator: CustomQuestionGenerator | null = null;
|
|
16
|
+
private usedNonces: Set<string> = new Set();
|
|
17
|
+
|
|
18
|
+
// set up the generator and check custom questions if they exist
|
|
19
|
+
constructor(options: K9GuardOptions | K9GuardCustomOptions) {
|
|
20
|
+
if (this.isCustomOptions(options)) {
|
|
21
|
+
this.customOptions = options;
|
|
22
|
+
const validation = CustomQuestionValidator.validate(options.questions);
|
|
23
|
+
if (!validation.valid) {
|
|
24
|
+
throw new Error(`Invalid custom questions: ${validation.error}`);
|
|
25
|
+
}
|
|
26
|
+
const sanitized = CustomQuestionValidator.sanitize(options.questions);
|
|
27
|
+
this.customGenerator = new CustomQuestionGenerator(sanitized);
|
|
28
|
+
} else {
|
|
29
|
+
this.standardOptions = options;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// check if we got custom captcha options
|
|
34
|
+
private isCustomOptions(options: unknown): options is K9GuardCustomOptions {
|
|
35
|
+
if (typeof options !== 'object' || options === null) {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
const opt = options as Record<string, unknown>;
|
|
39
|
+
return opt.type === 'custom' && Array.isArray(opt.questions);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private getDifficulty(): 'easy' | 'medium' | 'hard' {
|
|
43
|
+
if (!this.standardOptions) {
|
|
44
|
+
return 'easy';
|
|
45
|
+
}
|
|
46
|
+
return this.standardOptions.difficulty;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private getLocale(): 'en' | 'tr' {
|
|
50
|
+
if (!this.standardOptions) {
|
|
51
|
+
return 'en';
|
|
52
|
+
}
|
|
53
|
+
return this.standardOptions.locale || 'en';
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// add security stuff to the challenge like unique nonce, expiry time, and hashed answer
|
|
57
|
+
private createChallenge(base: Omit<CaptchaChallenge, 'nonce' | 'expiry' | 'hashedAnswer' | 'salt'>): CaptchaChallenge {
|
|
58
|
+
let nonce: string;
|
|
59
|
+
do {
|
|
60
|
+
// NOTE: make sure each nonce is unique to stop replay attacks
|
|
61
|
+
nonce = Random.generateNonce();
|
|
62
|
+
} while (this.usedNonces.has(nonce));
|
|
63
|
+
|
|
64
|
+
this.usedNonces.add(nonce);
|
|
65
|
+
|
|
66
|
+
// challenge will expire in 5 minutes
|
|
67
|
+
const expiry = Date.now() + 5 * 60 * 1000;
|
|
68
|
+
const salt = Random.generateSalt();
|
|
69
|
+
// never save the real answer, only the hash for checking later
|
|
70
|
+
const hashedAnswer = createHash('sha256').update(base.answer.toString() + salt).digest('hex');
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
...base,
|
|
74
|
+
nonce,
|
|
75
|
+
expiry,
|
|
76
|
+
hashedAnswer,
|
|
77
|
+
salt
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// do the math based on which operator we got
|
|
82
|
+
private calculateMath(a: number, op: string, b: number): number {
|
|
83
|
+
if (op === '+') {
|
|
84
|
+
return a + b;
|
|
85
|
+
}
|
|
86
|
+
if (op === '-') {
|
|
87
|
+
return a - b;
|
|
88
|
+
}
|
|
89
|
+
if (op === '*') {
|
|
90
|
+
return a * b;
|
|
91
|
+
}
|
|
92
|
+
if (op === '/') {
|
|
93
|
+
if (b === 0) {
|
|
94
|
+
// can't divide by zero, return NaN
|
|
95
|
+
return Number.NaN;
|
|
96
|
+
}
|
|
97
|
+
// round to 2 decimals to avoid weird floating point numbers
|
|
98
|
+
return Math.round((a / b) * 100) / 100;
|
|
99
|
+
}
|
|
100
|
+
return 0;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// main function that picks the right generator for the captcha type
|
|
104
|
+
generate(): CaptchaChallenge {
|
|
105
|
+
if (this.customOptions) {
|
|
106
|
+
return this.generateCustom();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (!this.standardOptions) {
|
|
110
|
+
throw new Error('Generator not properly initialized');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const captchaType = this.standardOptions.type;
|
|
114
|
+
|
|
115
|
+
if (captchaType === 'math') {
|
|
116
|
+
return this.generateMath();
|
|
117
|
+
}
|
|
118
|
+
if (captchaType === 'text') {
|
|
119
|
+
return this.generateText();
|
|
120
|
+
}
|
|
121
|
+
if (captchaType === 'riddle') {
|
|
122
|
+
return this.generateRiddle();
|
|
123
|
+
}
|
|
124
|
+
if (captchaType === 'sequence') {
|
|
125
|
+
return this.generateSequence();
|
|
126
|
+
}
|
|
127
|
+
if (captchaType === 'scramble') {
|
|
128
|
+
return this.generateScramble();
|
|
129
|
+
}
|
|
130
|
+
if (captchaType === 'logic') {
|
|
131
|
+
return this.generateLogic();
|
|
132
|
+
}
|
|
133
|
+
if (captchaType === 'reverse') {
|
|
134
|
+
return this.generateReverse();
|
|
135
|
+
}
|
|
136
|
+
if (captchaType === 'multi') {
|
|
137
|
+
return this.generateMulti();
|
|
138
|
+
}
|
|
139
|
+
return this.generateMixed();
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
private generateCustom(): CustomCaptcha {
|
|
143
|
+
if (!this.customGenerator) {
|
|
144
|
+
throw new Error('Custom generator not initialized');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const custom = this.customGenerator.generate();
|
|
148
|
+
return this.createChallenge({ type: 'custom', question: custom.question, answer: custom.answer }) as CustomCaptcha;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private generateMath(): MathCaptcha {
|
|
152
|
+
let num1 = Random.getRandomNumber(this.getDifficulty());
|
|
153
|
+
let num2 = Random.getRandomNumber(this.getDifficulty());
|
|
154
|
+
const operator = Random.getRandomOperator();
|
|
155
|
+
|
|
156
|
+
if (operator === '/' && num2 === 0) {
|
|
157
|
+
num2 = Random.getRandomNumber(this.getDifficulty()) + 1;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const question = `${num1} ${operator} ${num2}`;
|
|
161
|
+
const answer = this.calculateMath(num1, operator, num2);
|
|
162
|
+
|
|
163
|
+
if (isNaN(answer)) {
|
|
164
|
+
return this.generateMath();
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return this.createChallenge({ type: 'math', question, answer }) as MathCaptcha;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
private generateText(): TextCaptcha {
|
|
171
|
+
const text = Random.generateRandomString(this.getDifficulty());
|
|
172
|
+
return this.createChallenge({ type: 'text', question: text, answer: text }) as TextCaptcha;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private generateRiddle(): RiddleCaptcha {
|
|
176
|
+
const riddle = RiddleBank.getRandom(this.getLocale(), this.getDifficulty());
|
|
177
|
+
return this.createChallenge({ type: 'riddle', question: riddle.question, answer: riddle.answer }) as RiddleCaptcha;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
private generateSequence(): SequenceCaptcha {
|
|
181
|
+
const seq = SequenceGenerator.generate(this.getDifficulty());
|
|
182
|
+
return this.createChallenge({ type: 'sequence', question: seq.question, answer: seq.answer }) as SequenceCaptcha;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
private generateScramble(): ScrambleCaptcha {
|
|
186
|
+
const scr = ScrambleGenerator.generate(this.getDifficulty());
|
|
187
|
+
return this.createChallenge({ type: 'scramble', question: scr.question, answer: scr.answer }) as ScrambleCaptcha;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
private generateLogic(): LogicCaptcha {
|
|
191
|
+
const logic = LogicGenerator.getRandom(this.getLocale(), this.getDifficulty());
|
|
192
|
+
return this.createChallenge({ type: 'logic', question: logic.question, answer: logic.answer }) as LogicCaptcha;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
private generateReverse(): ReverseCaptcha {
|
|
196
|
+
const rev = ReverseGenerator.generate(this.getDifficulty());
|
|
197
|
+
return this.createChallenge({ type: 'reverse', question: rev.question, answer: rev.answer }) as ReverseCaptcha;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// generates a mixed captcha by randomly selecting a type and generating accordingly
|
|
201
|
+
private generateMixed(): MixedCaptcha {
|
|
202
|
+
const types: ('math' | 'text' | 'riddle' | 'sequence' | 'scramble' | 'logic' | 'reverse')[] = ['math', 'text', 'riddle', 'sequence', 'scramble', 'logic', 'reverse'];
|
|
203
|
+
const buffer = randomBytes(1);
|
|
204
|
+
const randomType = types[buffer[0]! % types.length]!;
|
|
205
|
+
|
|
206
|
+
const previousType = this.standardOptions?.type;
|
|
207
|
+
if (this.standardOptions) {
|
|
208
|
+
// NOTE: change the type temporarily so we can call generate() again
|
|
209
|
+
this.standardOptions.type = randomType;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const challenge = this.generate();
|
|
213
|
+
if (this.standardOptions && previousType) {
|
|
214
|
+
// put back the old type when done
|
|
215
|
+
this.standardOptions.type = previousType;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
return this.createChallenge({ ...challenge, type: 'mixed' }) as MixedCaptcha;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// make a two-step captcha with math and riddle
|
|
222
|
+
private generateMulti(): CaptchaChallenge {
|
|
223
|
+
const step1 = this.generateMath();
|
|
224
|
+
const step2 = this.generateRiddle();
|
|
225
|
+
|
|
226
|
+
return this.createChallenge({
|
|
227
|
+
type: 'multi',
|
|
228
|
+
question: 'Complete two steps',
|
|
229
|
+
answer: '',
|
|
230
|
+
steps: [step1, step2]
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { createHash } from 'crypto';
|
|
2
|
+
import type { CaptchaChallenge } from '../types';
|
|
3
|
+
|
|
4
|
+
export class CaptchaValidator {
|
|
5
|
+
private static readonly MAX_INPUT_LENGTH = 1000;
|
|
6
|
+
private static readonly VALID_CHAR_REGEX = /^[a-zA-Z0-9\s\-çÇğĞıİöÖşŞüÜ.,'!?]*$/;
|
|
7
|
+
|
|
8
|
+
// main validation entry point, routes to the correct validator based on challenge type
|
|
9
|
+
static validate(challenge: CaptchaChallenge, userInput: string): boolean {
|
|
10
|
+
if (!this.isValidInput(userInput)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (challenge.type === 'multi') {
|
|
15
|
+
return this.validateMulti(challenge, userInput);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
if (challenge.type === 'custom') {
|
|
19
|
+
return this.validateCustom(challenge, userInput);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (this.isNumericChallenge(challenge)) {
|
|
23
|
+
return this.validateNumeric(challenge, userInput);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return this.validateText(challenge, userInput);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private static isValidInput(input: unknown): boolean {
|
|
30
|
+
if (typeof input !== 'string') {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
if (input.length === 0 || input.length > this.MAX_INPUT_LENGTH) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// validates multi step captchas by checking each step individually
|
|
40
|
+
private static validateMulti(challenge: CaptchaChallenge, userInput: string): boolean {
|
|
41
|
+
if (!challenge.steps || challenge.steps.length === 0) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
let parsed: unknown;
|
|
46
|
+
try {
|
|
47
|
+
// NOTE: user input should be a JSON array of answers
|
|
48
|
+
parsed = JSON.parse(userInput);
|
|
49
|
+
} catch {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!Array.isArray(parsed) || parsed.length !== challenge.steps.length) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// check each step answer matches its challenge
|
|
58
|
+
for (let i = 0; i < challenge.steps.length; i++) {
|
|
59
|
+
const step = challenge.steps[i];
|
|
60
|
+
const input = parsed[i];
|
|
61
|
+
|
|
62
|
+
if (!step || typeof input !== 'string') {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (!this.validate(step, input)) {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
private static isNumericChallenge(challenge: CaptchaChallenge): boolean {
|
|
75
|
+
return challenge.type === 'math' || challenge.type === 'sequence' || (challenge.type === 'mixed' && typeof challenge.answer === 'number');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
private static validateNumeric(challenge: CaptchaChallenge, userInput: string): boolean {
|
|
79
|
+
const inputNum = parseFloat(userInput);
|
|
80
|
+
|
|
81
|
+
// make sure we got a valid number, not NaN or Infinity
|
|
82
|
+
if (isNaN(inputNum) || !isFinite(inputNum)) {
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return this.verifyAnswer(challenge, inputNum.toString());
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
private static validateText(challenge: CaptchaChallenge, userInput: string): boolean {
|
|
90
|
+
const sanitized = userInput.trim();
|
|
91
|
+
|
|
92
|
+
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return this.verifyAnswer(challenge, sanitized);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// NOTE: hash the user input with same salt and compare to stored hash
|
|
100
|
+
private static verifyAnswer(challenge: CaptchaChallenge, userInput: string): boolean {
|
|
101
|
+
const userHash = createHash('sha256')
|
|
102
|
+
.update(userInput + challenge.salt)
|
|
103
|
+
.digest('hex');
|
|
104
|
+
|
|
105
|
+
return userHash === challenge.hashedAnswer;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private static validateCustom(challenge: CaptchaChallenge, userInput: string): boolean {
|
|
109
|
+
const sanitized = userInput.trim();
|
|
110
|
+
|
|
111
|
+
if (!this.VALID_CHAR_REGEX.test(sanitized)) {
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return this.verifyAnswer(challenge, sanitized);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
export interface K9GuardOptions {
|
|
2
|
+
type: 'math' | 'text' | 'riddle' | 'sequence' | 'scramble' | 'logic' | 'reverse' | 'mixed' | 'multi';
|
|
3
|
+
difficulty: 'easy' | 'medium' | 'hard';
|
|
4
|
+
locale?: 'en' | 'tr';
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface CustomQuestion {
|
|
8
|
+
question: string;
|
|
9
|
+
answer: string;
|
|
10
|
+
difficulty: 'easy' | 'medium' | 'hard';
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface K9GuardCustomOptions {
|
|
14
|
+
type: 'custom';
|
|
15
|
+
questions: CustomQuestion[];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface CaptchaChallenge {
|
|
19
|
+
type: 'math' | 'text' | 'riddle' | 'sequence' | 'scramble' | 'logic' | 'reverse' | 'mixed' | 'multi' | 'custom';
|
|
20
|
+
question: string;
|
|
21
|
+
answer: string | number;
|
|
22
|
+
nonce: string;
|
|
23
|
+
expiry: number;
|
|
24
|
+
hashedAnswer: string;
|
|
25
|
+
salt: string;
|
|
26
|
+
steps?: CaptchaChallenge[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface MathCaptcha extends CaptchaChallenge {
|
|
30
|
+
type: 'math';
|
|
31
|
+
answer: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface TextCaptcha extends CaptchaChallenge {
|
|
35
|
+
type: 'text';
|
|
36
|
+
answer: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface RiddleCaptcha extends CaptchaChallenge {
|
|
40
|
+
type: 'riddle';
|
|
41
|
+
answer: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface SequenceCaptcha extends CaptchaChallenge {
|
|
45
|
+
type: 'sequence';
|
|
46
|
+
answer: string | number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface ScrambleCaptcha extends CaptchaChallenge {
|
|
50
|
+
type: 'scramble';
|
|
51
|
+
answer: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface LogicCaptcha extends CaptchaChallenge {
|
|
55
|
+
type: 'logic';
|
|
56
|
+
answer: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ReverseCaptcha extends CaptchaChallenge {
|
|
60
|
+
type: 'reverse';
|
|
61
|
+
answer: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface MixedCaptcha extends CaptchaChallenge {
|
|
65
|
+
type: 'mixed';
|
|
66
|
+
answer: string | number;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface CustomCaptcha extends CaptchaChallenge {
|
|
70
|
+
type: 'custom';
|
|
71
|
+
answer: string;
|
|
72
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
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
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto';
|
|
2
|
+
import enData from '../locale/en.json';
|
|
3
|
+
import trData from '../locale/tr.json';
|
|
4
|
+
|
|
5
|
+
export class LogicGenerator {
|
|
6
|
+
private static data: Record<'en' | 'tr', { question: string; answer: string }[]> = {
|
|
7
|
+
en: enData.logics,
|
|
8
|
+
tr: trData.logics
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
static getRandom(locale: 'en' | 'tr' = 'en', difficulty: 'easy' | 'medium' | 'hard' = 'easy'): { question: string; answer: string } {
|
|
12
|
+
const logics = this.data[locale];
|
|
13
|
+
// use crypto random to select a logic puzzle securely
|
|
14
|
+
const buffer = randomBytes(4);
|
|
15
|
+
const index = buffer.readUInt32LE(0) % logics.length;
|
|
16
|
+
return logics[index]!;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
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);
|
|
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);
|
|
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
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export class ReverseGenerator {
|
|
2
|
+
private static readonly easyWords = [
|
|
3
|
+
'cat', 'dog', 'sun', 'moon', 'star', 'fish', 'bird', 'tree',
|
|
4
|
+
'ball', 'book', 'door', 'lamp', 'rock', 'leaf', 'wind', 'fire',
|
|
5
|
+
'ice', 'sky', 'sea', 'fog', 'rain', 'snow', 'bear', 'wolf',
|
|
6
|
+
'coin', 'key', 'cup', 'pen', 'box', 'hat', 'map', 'web'
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
private static readonly mediumWords = [
|
|
10
|
+
'apple', 'house', 'water', 'bread', 'ocean', 'river', 'tiger', 'eagle',
|
|
11
|
+
'storm', 'cloud', 'flame', 'frost', 'stone', 'metal', 'glass', 'paper',
|
|
12
|
+
'forest', 'desert', 'valley', 'castle', 'bridge', 'garden', 'market', 'temple',
|
|
13
|
+
'dragon', 'wizard', 'knight', 'shield', 'sword', 'crown', 'jewel', 'crystal',
|
|
14
|
+
'planet', 'galaxy', 'comet', 'nebula', 'cipher', 'enigma', 'puzzle', 'riddle',
|
|
15
|
+
'shadow', 'mirror', 'portal', 'beacon', 'anchor', 'compass', 'lantern', 'prism'
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
private static readonly hardWords = [
|
|
19
|
+
'typescript', 'javascript', 'encryption', 'cryptography', 'algorithm', 'infrastructure',
|
|
20
|
+
'architecture', 'authentication', 'authorization', 'vulnerability', 'cybersecurity',
|
|
21
|
+
'blockchain', 'metamorphosis', 'constellation', 'synchronization', 'transformation',
|
|
22
|
+
'illumination', 'orchestration', 'denomination', 'comprehension', 'manifestation',
|
|
23
|
+
'extraordinary', 'revolutionary', 'sophisticated', 'kaleidoscope', 'optimization',
|
|
24
|
+
'crystallization', 'configuration', 'implementation', 'parallelization', 'serialization',
|
|
25
|
+
'decentralization', 'internationalization', 'containerization', 'virtualization',
|
|
26
|
+
'obfuscation', 'triangulation', 'interpolation', 'extrapolation', 'approximation',
|
|
27
|
+
'segmentation', 'fragmentation', 'concatenation', 'regeneration', 'degeneration'
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
static generate(difficulty: 'easy' | 'medium' | 'hard'): { question: string; answer: string } {
|
|
31
|
+
let wordPool: string[];
|
|
32
|
+
|
|
33
|
+
if (difficulty === 'easy') {
|
|
34
|
+
wordPool = this.easyWords;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (difficulty === 'medium') {
|
|
38
|
+
wordPool = this.mediumWords;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (difficulty === 'hard') {
|
|
42
|
+
wordPool = this.hardWords;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const text = wordPool![Math.floor(Math.random() * wordPool!.length)]!;
|
|
46
|
+
const reversed = text.split('').reverse().join('');
|
|
47
|
+
|
|
48
|
+
return { question: reversed, answer: text };
|
|
49
|
+
}
|
|
50
|
+
}
|